Merge M2 — kei-content-store migration

This commit is contained in:
Parfii-bot 2026-04-23 05:55:35 +08:00
commit a28ce2b36c
5 changed files with 98 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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