feat(w12a): sister re-migration — content-store campaigns promoted to engine
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) <noreply@anthropic.com>
This commit is contained in:
parent
7e2e5c642c
commit
5bd4a6166f
7 changed files with 133 additions and 39 deletions
|
|
@ -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<i64> {
|
||||
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<()> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
let inner = EntityStore::open(path, &[&CONTENT_SCHEMA])?;
|
||||
let inner = EntityStore::open(path, ALL_SCHEMAS)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
pub fn open_memory() -> Result<Self> {
|
||||
let inner = EntityStore::open_memory(&[&CONTENT_SCHEMA])?;
|
||||
let inner = EntityStore::open_memory(ALL_SCHEMAS)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
let inner = EntityStore::open(path, &[&SOCIAL_SCHEMA])?;
|
||||
let inner = EntityStore::open(path, ALL_SCHEMAS)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
pub fn open_memory() -> Result<Self> {
|
||||
let inner = EntityStore::open_memory(&[&SOCIAL_SCHEMA])?;
|
||||
let inner = EntityStore::open_memory(ALL_SCHEMAS)?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue