From 5bd4a6166ff55b2dbaf2e231e125cbe4fbcc8a2f Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 14:44:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(w12a):=20sister=20re-migration=20=E2=80=94?= =?UTF-8?q?=20content-store=20campaigns=20promoted=20to=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content-store: CAMPAIGNS_SCHEMA added (INTEGER PK, plain CRUD). create_campaign now delegates to engine v_create::run. Stayed bespoke (documented): - prompts (INSERT OR IGNORE + hash-dedup semantics) - campaign_assets (composite PK not supported by engine) - organizations in social-store (UNIQUE name upsert) - interactions in social-store (FK CASCADE + graph aggregate) Coverage: content-store 45%→67%, social-store unchanged 50%. Tests: 4/4 content-store, 5/5 social-store preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_rust/kei-content-store/src/campaigns.rs | 29 ++++++--- .../_rust/kei-content-store/src/prompts.rs | 9 +++ .../_rust/kei-content-store/src/schema.rs | 64 +++++++++++++++---- .../_rust/kei-content-store/src/store.rs | 21 +++--- .../kei-social-store/src/interactions.rs | 11 ++++ .../_rust/kei-social-store/src/schema.rs | 32 ++++++++-- .../_rust/kei-social-store/src/store.rs | 6 +- 7 files changed, 133 insertions(+), 39 deletions(-) diff --git a/_primitives/_rust/kei-content-store/src/campaigns.rs b/_primitives/_rust/kei-content-store/src/campaigns.rs index 207ef83..9d84adc 100644 --- a/_primitives/_rust/kei-content-store/src/campaigns.rs +++ b/_primitives/_rust/kei-content-store/src/campaigns.rs @@ -1,15 +1,28 @@ +//! Campaigns + campaign_assets join. +//! +//! `create_campaign` delegates to `kei_entity_store::verbs::create` under +//! `CAMPAIGNS_SCHEMA` — plain INTEGER-PK CRUD, engine-owned since +//! 2026-04-23. +//! +//! `attach_asset` / `campaign_assets` stay bespoke: `campaign_assets` +//! has a composite `(campaign_id, asset_id)` PK with no single-column +//! id, so it cannot be described as an `EntitySchema` (engine requires +//! exactly one PK field). The attach path also uses `INSERT OR IGNORE` +//! for idempotent joins, which the engine's plain-INSERT `create` verb +//! would not preserve. + +use crate::schema::CAMPAIGNS_SCHEMA; use crate::store::Store; -use anyhow::Result; -use chrono::Utc; +use anyhow::{anyhow, Result}; +use kei_entity_store::verbs::create as v_create; use rusqlite::params; +use serde_json::json; pub fn create_campaign(store: &Store, name: &str, description: &str) -> Result { - let now = Utc::now().timestamp(); - store.conn().execute( - "INSERT INTO campaigns (name, description, created_at) VALUES (?1,?2,?3)", - params![name, description, now], - )?; - Ok(store.conn().last_insert_rowid()) + let input = json!({ "name": name, "description": description }); + let v = v_create::run(store.conn(), &CAMPAIGNS_SCHEMA, input) + .map_err(|e| anyhow!("{e}"))?; + v["id"].as_i64().ok_or_else(|| anyhow!("missing id in create response")) } pub fn attach_asset(store: &Store, campaign_id: i64, asset_id: i64) -> Result<()> { diff --git a/_primitives/_rust/kei-content-store/src/prompts.rs b/_primitives/_rust/kei-content-store/src/prompts.rs index fbaf2c3..d9d03aa 100644 --- a/_primitives/_rust/kei-content-store/src/prompts.rs +++ b/_primitives/_rust/kei-content-store/src/prompts.rs @@ -1,3 +1,12 @@ +//! Prompts — hash-deduplicated prompt registry. +//! +//! Stays bespoke (not promoted to engine) because `register_prompt` +//! uses `INSERT OR IGNORE` + re-query by `UNIQUE(prompt_hash, model)` +//! to collapse duplicate text+model submissions to the same id. +//! The engine `create` verb is plain `INSERT` (no OR IGNORE), which +//! would break `prompt_dedup_by_hash` semantics. The table DDL still +//! lives in `CONTENT_SCHEMA::custom_migrations`. + use crate::store::Store; use anyhow::Result; use chrono::Utc; diff --git a/_primitives/_rust/kei-content-store/src/schema.rs b/_primitives/_rust/kei-content-store/src/schema.rs index e690618..992be23 100644 --- a/_primitives/_rust/kei-content-store/src/schema.rs +++ b/_primitives/_rust/kei-content-store/src/schema.rs @@ -1,13 +1,29 @@ -//! kei-content-store EntitySchema — declarative spec consumed by +//! kei-content-store EntitySchemas — declarative specs 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. +//! Shape (multi-schema convergence, 2026-04-23): +//! +//! - `CONTENT_SCHEMA`: primary entity `content_units` (assets; INTEGER +//! PK; engine-owned create/get/list/search/update/delete + FTS). +//! - `CAMPAIGNS_SCHEMA`: plain-CRUD INTEGER-PK table promoted to engine +//! on this pass (create/get only — no idempotency or dedup). +//! - `ALL_SCHEMAS`: the `&[&EntitySchema]` slice `Store::open` hands +//! to the engine. +//! +//! Secondary tables that stay in `custom_migrations` (on CONTENT_SCHEMA) +//! and keep bespoke SQL in their sibling modules: +//! +//! - `prompts` — hash-dedup via `INSERT OR IGNORE` + re-query by +//! `UNIQUE(prompt_hash, model)`; engine `create` is plain INSERT, +//! would break `prompt_dedup_by_hash` test. Sibling: `prompts.rs`. +//! - `campaign_assets` — composite `(campaign_id, asset_id)` PK, no +//! single-column PK; engine schemas require one PK field. Also uses +//! `INSERT OR IGNORE` for idempotent attach. Sibling: `campaigns.rs`. use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; +// ---- content_units (primary, assets) --------------------------------- + static FIELDS: &[FieldDef] = &[ FieldDef::pk("id"), FieldDef::text_default("unit_type", "asset"), @@ -23,6 +39,10 @@ static FIELDS: &[FieldDef] = &[ FieldDef::updated_at(), ]; +/// Secondary DDL co-located with `content_units` — indexes on the +/// primary table plus the two bespoke-CRUD tables (prompts, +/// campaign_assets). Kept byte-for-byte compatible with the legacy +/// pre-multi-schema DB layout. 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 != ''; @@ -39,14 +59,6 @@ const DDL_SECONDARY: &str = r#" UNIQUE(prompt_hash, model) ); - CREATE TABLE IF NOT EXISTS campaigns ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT DEFAULT '', - status TEXT DEFAULT 'draft', - created_at INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS campaign_assets ( campaign_id INTEGER NOT NULL, asset_id INTEGER NOT NULL, @@ -65,3 +77,29 @@ pub static CONTENT_SCHEMA: EntitySchema = EntitySchema { archived_field: None, custom_migrations: &[DDL_SECONDARY], }; + +// ---- campaigns (promoted 2026-04-23) -------------------------------- + +static CAMPAIGN_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("name"), + FieldDef::text_default("description", ""), + FieldDef::text_default("status", "draft"), + FieldDef::created_at(), +]; + +pub static CAMPAIGNS_SCHEMA: EntitySchema = EntitySchema { + name: "campaign", + table: "campaigns", + fields: CAMPAIGN_FIELDS, + enabled_verbs: &["create", "get"], + fts_columns: None, + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[], +}; + +// ---- aggregate slice for Store::open -------------------------------- + +pub static ALL_SCHEMAS: &[&EntitySchema] = &[&CONTENT_SCHEMA, &CAMPAIGNS_SCHEMA]; diff --git a/_primitives/_rust/kei-content-store/src/store.rs b/_primitives/_rust/kei-content-store/src/store.rs index ffec6ee..2c3ba2d 100644 --- a/_primitives/_rust/kei-content-store/src/store.rs +++ b/_primitives/_rust/kei-content-store/src/store.rs @@ -1,12 +1,17 @@ //! 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`. +//! Multi-schema convergence (2026-04-23): both `content_units` and +//! `campaigns` are engine-owned. `Store::open` hands the engine +//! `ALL_SCHEMAS` so migrations for both tables run in a single +//! atomic transaction. +//! +//! Verbs dispatch per-schema: callers that act on assets pass +//! `CONTENT_SCHEMA`, callers that act on campaigns pass +//! `CAMPAIGNS_SCHEMA`. Two bespoke SQL paths remain: +//! `prompts.rs::register_prompt` (hash-dedup) and +//! `campaigns.rs::{attach_asset,campaign_assets}` (composite PK). -use crate::schema::CONTENT_SCHEMA; +use crate::schema::ALL_SCHEMAS; use anyhow::Result; use kei_entity_store::Store as EntityStore; use rusqlite::Connection; @@ -18,12 +23,12 @@ pub struct Store { impl Store { pub fn open(path: &Path) -> Result { - let inner = EntityStore::open(path, &[&CONTENT_SCHEMA])?; + let inner = EntityStore::open(path, ALL_SCHEMAS)?; Ok(Self { inner }) } pub fn open_memory() -> Result { - let inner = EntityStore::open_memory(&[&CONTENT_SCHEMA])?; + let inner = EntityStore::open_memory(ALL_SCHEMAS)?; Ok(Self { inner }) } diff --git a/_primitives/_rust/kei-social-store/src/interactions.rs b/_primitives/_rust/kei-social-store/src/interactions.rs index ea710ef..2461716 100644 --- a/_primitives/_rust/kei-social-store/src/interactions.rs +++ b/_primitives/_rust/kei-social-store/src/interactions.rs @@ -1,3 +1,14 @@ +//! Interactions — append-only per-person event log. +//! +//! Stays bespoke (not promoted to engine) because: +//! - `FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE CASCADE` +//! is not expressible via `EntitySchema` fields. +//! - `interactions_for(person_id)` is a filter query by FK column, +//! not a generic `list` with offset/limit. +//! - `graph.rs::relationship_graph` runs `GROUP BY person_id, +//! target_id, channel` which is out of scope for engine verbs. +//! Table DDL still lives in `SOCIAL_SCHEMA::custom_migrations`. + use crate::store::Store; use anyhow::Result; use chrono::Utc; diff --git a/_primitives/_rust/kei-social-store/src/schema.rs b/_primitives/_rust/kei-social-store/src/schema.rs index cc76952..cfe5747 100644 --- a/_primitives/_rust/kei-social-store/src/schema.rs +++ b/_primitives/_rust/kei-social-store/src/schema.rs @@ -1,11 +1,24 @@ -//! kei-social-store EntitySchema — Layer A convergence. +//! kei-social-store EntitySchemas — 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. +//! Shape (multi-schema audit, 2026-04-23): +//! +//! - `SOCIAL_SCHEMA`: primary entity `person` (table `people`; INTEGER +//! PK; engine-owned create/get/search/list + FTS). +//! - `ALL_SCHEMAS`: the `&[&EntitySchema]` slice for `Store::open`. +//! +//! Secondary tables stay in `custom_migrations` and keep bespoke SQL +//! paths — **none were promotable** on this pass: +//! +//! - `organizations` — uses `INSERT OR IGNORE` + re-query by +//! `UNIQUE(name)` for idempotent name-keyed upsert (`orgs_idempotent` +//! test relies on `add_org` returning the SAME id for repeat names). +//! The engine `create` verb is plain INSERT, not OR-IGNORE, and +//! would break that semantic. Sibling: `people.rs::add_org`. +//! - `interactions` — append-only log with `FOREIGN KEY ... ON DELETE +//! CASCADE` on `person_id`, filter query `WHERE person_id=?`, and +//! aggregate `GROUP BY person_id, target_id, channel` used by +//! `graph.rs::relationship_graph`. None of these are covered by +//! the engine's generic verbs. Sibling: `interactions.rs` + `graph.rs`. //! //! FTS columns cover name, handle, email, bio — search verb routes //! through `fts_people`. The legacy `fts_social` virtual table is @@ -64,3 +77,8 @@ pub static SOCIAL_SCHEMA: EntitySchema = EntitySchema { archived_field: None, custom_migrations: &[DDL_SECONDARY], }; + +/// Aggregate slice for `Store::open`. Currently a single-element slice; +/// lives here for parity with sister multi-schema stores +/// (kei-chat-store, kei-content-store) and future promotions. +pub static ALL_SCHEMAS: &[&EntitySchema] = &[&SOCIAL_SCHEMA]; diff --git a/_primitives/_rust/kei-social-store/src/store.rs b/_primitives/_rust/kei-social-store/src/store.rs index 7b8a553..86c6c5c 100644 --- a/_primitives/_rust/kei-social-store/src/store.rs +++ b/_primitives/_rust/kei-social-store/src/store.rs @@ -6,7 +6,7 @@ //! use the raw connection against tables declared in //! `custom_migrations` — they are not generic-CRUD. -use crate::schema::SOCIAL_SCHEMA; +use crate::schema::ALL_SCHEMAS; use anyhow::Result; use kei_entity_store::Store as EntityStore; use rusqlite::Connection; @@ -18,12 +18,12 @@ pub struct Store { impl Store { pub fn open(path: &Path) -> Result { - let inner = EntityStore::open(path, &[&SOCIAL_SCHEMA])?; + let inner = EntityStore::open(path, ALL_SCHEMAS)?; Ok(Self { inner }) } pub fn open_memory() -> Result { - let inner = EntityStore::open_memory(&[&SOCIAL_SCHEMA])?; + let inner = EntityStore::open_memory(ALL_SCHEMAS)?; Ok(Self { inner }) }