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:
Parfii-bot 2026-04-23 05:55:13 +08:00
parent e075ae8df1
commit 0b645db646
6 changed files with 123 additions and 84 deletions

View file

@ -2158,6 +2158,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"kei-entity-store",
"rusqlite",
"serde",
"serde_json",

View file

@ -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"] }

View file

@ -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> {

View file

@ -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],
};

View file

@ -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"),
})
}

View file

@ -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() }
}