diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 908b8c5..e5acfff 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -2158,6 +2158,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "kei-entity-store", "rusqlite", "serde", "serde_json", diff --git a/_primitives/_rust/kei-social-store/Cargo.toml b/_primitives/_rust/kei-social-store/Cargo.toml index 544cc6f..27b5790 100644 --- a/_primitives/_rust/kei-social-store/Cargo.toml +++ b/_primitives/_rust/kei-social-store/Cargo.toml @@ -14,6 +14,7 @@ name = "kei_social_store" path = "src/lib.rs" [dependencies] +kei-entity-store = { path = "../kei-entity-store" } rusqlite = { version = "0.31", features = ["bundled"] } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } diff --git a/_primitives/_rust/kei-social-store/src/people.rs b/_primitives/_rust/kei-social-store/src/people.rs index 2605f6c..82b3422 100644 --- a/_primitives/_rust/kei-social-store/src/people.rs +++ b/_primitives/_rust/kei-social-store/src/people.rs @@ -1,8 +1,18 @@ +//! People + organizations. +//! +//! `add_person` / `get_person` delegate to `kei_entity_store::verbs::*` +//! under `SOCIAL_SCHEMA`. Organizations live in a `custom_migrations` +//! table (name-keyed upsert semantics, not generic CRUD) and keep their +//! bespoke SQL path. + +use crate::schema::SOCIAL_SCHEMA; use crate::store::Store; -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::Utc; +use kei_entity_store::verbs::{create as v_create, get as v_get}; use rusqlite::params; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Person { @@ -28,37 +38,37 @@ pub struct Organization { } pub fn add_person(store: &Store, p: &Person) -> Result { - let now = Utc::now().timestamp(); - let source = if p.source.is_empty() { "manual" } else { &p.source }; - store.conn().execute( - "INSERT INTO people (name, email, handle, role, organization, - source, bio, created_at, updated_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?8)", - params![p.name, p.email, p.handle, p.role, p.organization, - source, p.bio, now], - )?; - let id = store.conn().last_insert_rowid(); - store.conn().execute( - "INSERT INTO fts_social (person_id, name, email, bio) VALUES (?1,?2,?3,?4)", - params![id, p.name, p.email, p.bio], - )?; - Ok(id) + let input = json!({ + "name": p.name, + "email": p.email, + "handle": p.handle, + "role": p.role, + "organization": p.organization, + "source": p.source, + "bio": p.bio, + }); + let v = v_create::run(store.conn(), &SOCIAL_SCHEMA, input) + .map_err(|e| anyhow!("{e}"))?; + v["id"].as_i64().ok_or_else(|| anyhow!("missing id in create response")) } pub fn get_person(store: &Store, id: i64) -> Result> { - let mut stmt = store.conn().prepare( - "SELECT id, name, email, handle, role, organization, source, bio, - created_at, updated_at FROM people WHERE id=?1", - )?; - let mut rows = stmt.query(params![id])?; - if let Some(r) = rows.next()? { - return Ok(Some(Person { - id: r.get(0)?, name: r.get(1)?, email: r.get(2)?, handle: r.get(3)?, - role: r.get(4)?, organization: r.get(5)?, source: r.get(6)?, - bio: r.get(7)?, created_at: r.get(8)?, updated_at: r.get(9)?, - })); + match v_get::run(store.conn(), &SOCIAL_SCHEMA, json!({ "id": id })) { + Ok(v) => Ok(Some(person_from_json(v)?)), + Err(e) if e.exit_code() == 2 => Ok(None), + Err(e) => Err(anyhow!("{e}")), } - Ok(None) +} + +fn person_from_json(v: Value) -> Result { + let obj = v.as_object().ok_or_else(|| anyhow!("expected object in get response"))?; + let s = |k: &str| obj.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let i = |k: &str| obj.get(k).and_then(|x| x.as_i64()).unwrap_or(0); + Ok(Person { + id: i("id"), name: s("name"), email: s("email"), handle: s("handle"), + role: s("role"), organization: s("organization"), source: s("source"), + bio: s("bio"), created_at: i("created_at"), updated_at: i("updated_at"), + }) } pub fn add_org(store: &Store, o: &Organization) -> Result { diff --git a/_primitives/_rust/kei-social-store/src/schema.rs b/_primitives/_rust/kei-social-store/src/schema.rs index 3aa31ff..cc76952 100644 --- a/_primitives/_rust/kei-social-store/src/schema.rs +++ b/_primitives/_rust/kei-social-store/src/schema.rs @@ -1,18 +1,32 @@ -use rusqlite::{Connection, Result}; +//! kei-social-store EntitySchema — Layer A convergence. +//! +//! Primary entity = `person` (table `people`). Secondary tables +//! `organizations` and `interactions` ride `custom_migrations`: they are +//! not generic CRUD (orgs use name-keyed upsert; interactions are an +//! append-only log with per-person index) and keep their existing +//! column names byte-for-byte so on-disk DBs written before this +//! migration still open cleanly. +//! +//! FTS columns cover name, handle, email, bio — search verb routes +//! through `fts_people`. The legacy `fts_social` virtual table is +//! replaced; FTS is rebuilt on first open against the new name. -const DDL_MAIN: &str = r#" - CREATE TABLE IF NOT EXISTS people ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - email TEXT DEFAULT '', - handle TEXT DEFAULT '', - role TEXT DEFAULT '', - organization TEXT DEFAULT '', - source TEXT NOT NULL DEFAULT 'manual', - bio TEXT DEFAULT '', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; + +static FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("name"), + FieldDef::text("email"), + FieldDef::text("handle"), + FieldDef::text("role"), + FieldDef::text("organization"), + FieldDef::text_default("source", "manual"), + FieldDef::text("bio"), + FieldDef::created_at(), + FieldDef::updated_at(), +]; + +const DDL_SECONDARY: &str = r#" CREATE UNIQUE INDEX IF NOT EXISTS idx_people_email ON people(email) WHERE email != ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_people_handle_source @@ -39,13 +53,14 @@ const DDL_MAIN: &str = r#" CREATE INDEX IF NOT EXISTS idx_int_person ON interactions(person_id); "#; -const DDL_FTS: &str = r#" - CREATE VIRTUAL TABLE IF NOT EXISTS fts_social - USING fts5(person_id UNINDEXED, name, email, bio, tokenize='porter unicode61'); -"#; - -pub fn create_schema(conn: &Connection) -> Result<()> { - conn.execute_batch(DDL_MAIN)?; - conn.execute_batch(DDL_FTS)?; - Ok(()) -} +pub static SOCIAL_SCHEMA: EntitySchema = EntitySchema { + name: "person", + table: "people", + fields: FIELDS, + enabled_verbs: &["create", "get", "search", "list"], + fts_columns: Some(&["name", "handle", "email", "bio"]), + edge_table: None, // interactions has bespoke columns — managed by interactions.rs + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[DDL_SECONDARY], +}; diff --git a/_primitives/_rust/kei-social-store/src/search.rs b/_primitives/_rust/kei-social-store/src/search.rs index fa9590e..aa7a058 100644 --- a/_primitives/_rust/kei-social-store/src/search.rs +++ b/_primitives/_rust/kei-social-store/src/search.rs @@ -1,25 +1,28 @@ +//! FTS search over `people` — routes through `kei_entity_store::verbs::search`. + use crate::people::Person; +use crate::schema::SOCIAL_SCHEMA; use crate::store::Store; -use anyhow::Result; -use rusqlite::params; +use anyhow::{anyhow, Result}; +use kei_entity_store::verbs::search as v_search; +use serde_json::{json, Value}; pub fn search_people(store: &Store, q: &str, limit: i64) -> Result> { - let lim = if limit <= 0 { 20 } else { limit }; - let mut stmt = store.conn().prepare( - "SELECT p.id, p.name, p.email, p.handle, p.role, p.organization, - p.source, p.bio, p.created_at, p.updated_at - FROM fts_social f - JOIN people p ON p.id = f.person_id - WHERE fts_social MATCH ?1 ORDER BY rank LIMIT ?2", - )?; - let rows = stmt.query_map(params![q, lim], |r| { - Ok(Person { - id: r.get(0)?, name: r.get(1)?, email: r.get(2)?, handle: r.get(3)?, - role: r.get(4)?, organization: r.get(5)?, source: r.get(6)?, - bio: r.get(7)?, created_at: r.get(8)?, updated_at: r.get(9)?, - }) - })?; - let mut out = Vec::new(); - for r in rows { out.push(r?); } - Ok(out) + let input = json!({ "query": q, "limit": if limit <= 0 { 20 } else { limit } }); + let v = v_search::run(store.conn(), &SOCIAL_SCHEMA, input) + .map_err(|e| anyhow!("{e}"))?; + let results = v.get("results").and_then(|r| r.as_array()) + .ok_or_else(|| anyhow!("missing results array"))?; + results.iter().map(person_from_value).collect() +} + +fn person_from_value(v: &Value) -> Result { + let obj = v.as_object().ok_or_else(|| anyhow!("expected object"))?; + let s = |k: &str| obj.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let i = |k: &str| obj.get(k).and_then(|x| x.as_i64()).unwrap_or(0); + Ok(Person { + id: i("id"), name: s("name"), email: s("email"), handle: s("handle"), + role: s("role"), organization: s("organization"), source: s("source"), + bio: s("bio"), created_at: i("created_at"), updated_at: i("updated_at"), + }) } diff --git a/_primitives/_rust/kei-social-store/src/store.rs b/_primitives/_rust/kei-social-store/src/store.rs index f4c30de..54940d3 100644 --- a/_primitives/_rust/kei-social-store/src/store.rs +++ b/_primitives/_rust/kei-social-store/src/store.rs @@ -1,22 +1,31 @@ -use crate::schema::create_schema; -use anyhow::{Context, Result}; +//! Social store — thin shim over `kei_entity_store::Store`. +//! +//! Layer-A convergence (2026-04-23): generic CRUD verbs on `people` +//! (create/get/search/list) run through `kei_entity_store::verbs::*` +//! with `SOCIAL_SCHEMA`. Organization and interaction helpers still +//! use the raw connection against tables declared in +//! `custom_migrations` — they are not generic-CRUD. + +use crate::schema::SOCIAL_SCHEMA; +use anyhow::Result; +use kei_entity_store::Store as EntityStore; use rusqlite::Connection; use std::path::Path; -pub struct Store { conn: Connection } +pub struct Store { + inner: EntityStore, +} impl Store { pub fn open(path: &Path) -> Result { - if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let conn = Connection::open(path).context("open sqlite")?; - conn.pragma_update(None, "journal_mode", "WAL").ok(); - create_schema(&conn)?; - Ok(Self { conn }) + let inner = EntityStore::open(path, &SOCIAL_SCHEMA)?; + Ok(Self { inner }) } + pub fn open_memory() -> Result { - let conn = Connection::open_in_memory()?; - create_schema(&conn)?; - Ok(Self { conn }) + let inner = EntityStore::open_memory(&SOCIAL_SCHEMA)?; + Ok(Self { inner }) } - pub fn conn(&self) -> &Connection { &self.conn } + + pub fn conn(&self) -> &Connection { self.inner.conn() } }