Merge M2 — kei-content-store migration
This commit is contained in:
commit
a28ce2b36c
5 changed files with 98 additions and 59 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -1963,6 +1963,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"kei-entity-store",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
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<Option<Asset>> {
|
||||
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<Asset> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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, &CONTENT_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(&CONTENT_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