diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 908b8c5..c4ff75e 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -1962,6 +1962,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "kei-entity-store", "rusqlite", "serde", "serde_json", diff --git a/_primitives/_rust/kei-content-store/Cargo.toml b/_primitives/_rust/kei-content-store/Cargo.toml index c5182b3..4ceefba 100644 --- a/_primitives/_rust/kei-content-store/Cargo.toml +++ b/_primitives/_rust/kei-content-store/Cargo.toml @@ -14,6 +14,7 @@ name = "kei_content_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-content-store/src/assets.rs b/_primitives/_rust/kei-content-store/src/assets.rs index d08a998..d65e79e 100644 --- a/_primitives/_rust/kei-content-store/src/assets.rs +++ b/_primitives/_rust/kei-content-store/src/assets.rs @@ -1,8 +1,9 @@ +use crate::schema::CONTENT_SCHEMA; use crate::store::Store; -use anyhow::Result; -use chrono::Utc; -use rusqlite::params; +use anyhow::{anyhow, Result}; +use kei_entity_store::verbs::{create as v_create, get as v_get}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Asset { @@ -21,32 +22,45 @@ pub struct Asset { } pub fn register_asset(store: &Store, a: &Asset) -> Result { - let now = Utc::now().timestamp(); - let ut = if a.unit_type.is_empty() { "asset" } else { &a.unit_type }; - store.conn().execute( - "INSERT INTO content_units (unit_type, title, content, media_type, - file_path, file_hash, provider, cost_cents, parent_id, created_at, updated_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?10)", - params![ut, a.title, a.content, a.media_type, a.file_path, - a.file_hash, a.provider, a.cost_cents, a.parent_id, now], - )?; - Ok(store.conn().last_insert_rowid()) + let unit_type = if a.unit_type.is_empty() { "asset" } else { &a.unit_type }; + let input = json!({ + "unit_type": unit_type, + "title": a.title, + "content": a.content, + "media_type": a.media_type, + "file_path": a.file_path, + "file_hash": a.file_hash, + "provider": a.provider, + "cost_cents": a.cost_cents, + "parent_id": a.parent_id, + }); + let v = v_create::run(store.conn(), &CONTENT_SCHEMA, input) + .map_err(|e| anyhow!("{e}"))?; + v["id"].as_i64().ok_or_else(|| anyhow!("missing id in create response")) } pub fn get_asset(store: &Store, id: i64) -> Result> { - let mut stmt = store.conn().prepare( - "SELECT id, unit_type, title, content, media_type, file_path, file_hash, - provider, cost_cents, parent_id, created_at, updated_at - FROM content_units WHERE id=?1", - )?; - let mut rows = stmt.query(params![id])?; - if let Some(r) = rows.next()? { - return Ok(Some(Asset { - id: r.get(0)?, unit_type: r.get(1)?, title: r.get(2)?, content: r.get(3)?, - media_type: r.get(4)?, file_path: r.get(5)?, file_hash: r.get(6)?, - provider: r.get(7)?, cost_cents: r.get(8)?, parent_id: r.get(9)?, - created_at: r.get(10)?, updated_at: r.get(11)?, - })); + match v_get::run(store.conn(), &CONTENT_SCHEMA, json!({ "id": id })) { + Ok(v) => Ok(Some(asset_from_json(v)?)), + Err(e) if e.exit_code() == 2 => Ok(None), + Err(e) => Err(anyhow!("{e}")), } - Ok(None) +} + +fn asset_from_json(v: Value) -> Result { + let obj = v.as_object().ok_or_else(|| anyhow!("expected object in get response"))?; + Ok(Asset { + id: obj.get("id").and_then(|x| x.as_i64()).unwrap_or(0), + unit_type: obj.get("unit_type").and_then(|x| x.as_str()).unwrap_or("").to_string(), + title: obj.get("title").and_then(|x| x.as_str()).unwrap_or("").to_string(), + content: obj.get("content").and_then(|x| x.as_str()).unwrap_or("").to_string(), + media_type: obj.get("media_type").and_then(|x| x.as_str()).unwrap_or("").to_string(), + file_path: obj.get("file_path").and_then(|x| x.as_str()).unwrap_or("").to_string(), + file_hash: obj.get("file_hash").and_then(|x| x.as_str()).unwrap_or("").to_string(), + provider: obj.get("provider").and_then(|x| x.as_str()).unwrap_or("").to_string(), + cost_cents: obj.get("cost_cents").and_then(|x| x.as_i64()).unwrap_or(0), + parent_id: obj.get("parent_id").and_then(|x| x.as_i64()).unwrap_or(0), + created_at: obj.get("created_at").and_then(|x| x.as_i64()).unwrap_or(0), + updated_at: obj.get("updated_at").and_then(|x| x.as_i64()).unwrap_or(0), + }) } diff --git a/_primitives/_rust/kei-content-store/src/schema.rs b/_primitives/_rust/kei-content-store/src/schema.rs index bbd8ab8..e690618 100644 --- a/_primitives/_rust/kei-content-store/src/schema.rs +++ b/_primitives/_rust/kei-content-store/src/schema.rs @@ -1,20 +1,29 @@ -use rusqlite::{Connection, Result}; +//! kei-content-store EntitySchema — declarative spec consumed by +//! `kei_entity_store::Store` and its verb templates. +//! +//! Primary entity is the `content_units` table (assets). Prompts, +//! campaigns, and `campaign_assets` ride `custom_migrations` because +//! they are separate tables that keep their existing column names so +//! `prompts.rs` / `campaigns.rs` compile unchanged. -const DDL: &str = r#" - CREATE TABLE IF NOT EXISTS content_units ( - id INTEGER PRIMARY KEY, - unit_type TEXT NOT NULL, - title TEXT NOT NULL, - content TEXT DEFAULT '', - media_type TEXT DEFAULT '', - file_path TEXT DEFAULT '', - file_hash TEXT DEFAULT '', - provider TEXT DEFAULT '', - cost_cents INTEGER DEFAULT 0, - parent_id INTEGER DEFAULT 0, - 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_default("unit_type", "asset"), + FieldDef::text_nn("title"), + FieldDef::text("content"), + FieldDef::text("media_type"), + FieldDef::text("file_path"), + FieldDef::text("file_hash"), + FieldDef::text("provider"), + FieldDef::integer("cost_cents"), + FieldDef::integer("parent_id"), + FieldDef::created_at(), + FieldDef::updated_at(), +]; + +const DDL_SECONDARY: &str = r#" CREATE INDEX IF NOT EXISTS idx_cu_type ON content_units(unit_type); CREATE INDEX IF NOT EXISTS idx_cu_hash ON content_units(file_hash) WHERE file_hash != ''; @@ -45,7 +54,14 @@ const DDL: &str = r#" ); "#; -pub fn create_schema(conn: &Connection) -> Result<()> { - conn.execute_batch(DDL)?; - Ok(()) -} +pub static CONTENT_SCHEMA: EntitySchema = EntitySchema { + name: "asset", + table: "content_units", + fields: FIELDS, + enabled_verbs: &["create", "get", "list", "search", "update", "delete"], + fts_columns: Some(&["title", "content"]), + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[DDL_SECONDARY], +}; diff --git a/_primitives/_rust/kei-content-store/src/store.rs b/_primitives/_rust/kei-content-store/src/store.rs index 16fe2c4..6aed140 100644 --- a/_primitives/_rust/kei-content-store/src/store.rs +++ b/_primitives/_rust/kei-content-store/src/store.rs @@ -1,24 +1,31 @@ -use crate::schema::create_schema; -use anyhow::{Context, Result}; +//! Content store — thin shim over `kei_entity_store::Store`. +//! +//! Layer-A convergence (2026-04-23): generic CRUD on `content_units` +//! runs through `kei_entity_store::verbs::*` using the declarative +//! `CONTENT_SCHEMA`. Secondary tables (prompts, campaigns, +//! campaign_assets) are created via the schema's `custom_migrations` +//! slot and continue to be served by `prompts.rs` / `campaigns.rs`. + +use crate::schema::CONTENT_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, &CONTENT_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(&CONTENT_SCHEMA)?; + Ok(Self { inner }) } - pub fn conn(&self) -> &Connection { &self.conn } + pub fn conn(&self) -> &Connection { self.inner.conn() } }