feat(m3): migrate kei-social-store to kei-entity-store engine
5/5 tests preserved. Primary entity = person; orgs + interactions + relationship_graph stay custom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e075ae8df1
commit
0b645db646
6 changed files with 123 additions and 84 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -2158,6 +2158,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"kei-entity-store",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
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<Option<Person>> {
|
||||
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<Person> {
|
||||
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<i64> {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Vec<Person>> {
|
||||
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<Person> {
|
||||
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"),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<Self> {
|
||||
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() }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue