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 crate::store::Store;
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use chrono::Utc;
|
use kei_entity_store::verbs::create as v_create;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
pub fn create_campaign(store: &Store, name: &str, description: &str) -> Result<i64> {
|
pub fn create_campaign(store: &Store, name: &str, description: &str) -> Result<i64> {
|
||||||
let now = Utc::now().timestamp();
|
let input = json!({ "name": name, "description": description });
|
||||||
store.conn().execute(
|
let v = v_create::run(store.conn(), &CAMPAIGNS_SCHEMA, input)
|
||||||
"INSERT INTO campaigns (name, description, created_at) VALUES (?1,?2,?3)",
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
params![name, description, now],
|
v["id"].as_i64().ok_or_else(|| anyhow!("missing id in create response"))
|
||||||
)?;
|
|
||||||
Ok(store.conn().last_insert_rowid())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn attach_asset(store: &Store, campaign_id: i64, asset_id: i64) -> Result<()> {
|
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 crate::store::Store;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Utc;
|
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.
|
//! `kei_entity_store::Store` and its verb templates.
|
||||||
//!
|
//!
|
||||||
//! Primary entity is the `content_units` table (assets). Prompts,
|
//! Shape (multi-schema convergence, 2026-04-23):
|
||||||
//! campaigns, and `campaign_assets` ride `custom_migrations` because
|
//!
|
||||||
//! they are separate tables that keep their existing column names so
|
//! - `CONTENT_SCHEMA`: primary entity `content_units` (assets; INTEGER
|
||||||
//! `prompts.rs` / `campaigns.rs` compile unchanged.
|
//! 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};
|
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
|
||||||
|
|
||||||
|
// ---- content_units (primary, assets) ---------------------------------
|
||||||
|
|
||||||
static FIELDS: &[FieldDef] = &[
|
static FIELDS: &[FieldDef] = &[
|
||||||
FieldDef::pk("id"),
|
FieldDef::pk("id"),
|
||||||
FieldDef::text_default("unit_type", "asset"),
|
FieldDef::text_default("unit_type", "asset"),
|
||||||
|
|
@ -23,6 +39,10 @@ static FIELDS: &[FieldDef] = &[
|
||||||
FieldDef::updated_at(),
|
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#"
|
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_type ON content_units(unit_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_cu_hash ON content_units(file_hash) WHERE file_hash != '';
|
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)
|
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 (
|
CREATE TABLE IF NOT EXISTS campaign_assets (
|
||||||
campaign_id INTEGER NOT NULL,
|
campaign_id INTEGER NOT NULL,
|
||||||
asset_id INTEGER NOT NULL,
|
asset_id INTEGER NOT NULL,
|
||||||
|
|
@ -65,3 +77,29 @@ pub static CONTENT_SCHEMA: EntitySchema = EntitySchema {
|
||||||
archived_field: None,
|
archived_field: None,
|
||||||
custom_migrations: &[DDL_SECONDARY],
|
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`.
|
//! Content store — thin shim over `kei_entity_store::Store`.
|
||||||
//!
|
//!
|
||||||
//! Layer-A convergence (2026-04-23): generic CRUD on `content_units`
|
//! Multi-schema convergence (2026-04-23): both `content_units` and
|
||||||
//! runs through `kei_entity_store::verbs::*` using the declarative
|
//! `campaigns` are engine-owned. `Store::open` hands the engine
|
||||||
//! `CONTENT_SCHEMA`. Secondary tables (prompts, campaigns,
|
//! `ALL_SCHEMAS` so migrations for both tables run in a single
|
||||||
//! campaign_assets) are created via the schema's `custom_migrations`
|
//! atomic transaction.
|
||||||
//! slot and continue to be served by `prompts.rs` / `campaigns.rs`.
|
//!
|
||||||
|
//! 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 anyhow::Result;
|
||||||
use kei_entity_store::Store as EntityStore;
|
use kei_entity_store::Store as EntityStore;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
@ -18,12 +23,12 @@ pub struct Store {
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
let inner = EntityStore::open(path, &[&CONTENT_SCHEMA])?;
|
let inner = EntityStore::open(path, ALL_SCHEMAS)?;
|
||||||
Ok(Self { inner })
|
Ok(Self { inner })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_memory() -> Result<Self> {
|
pub fn open_memory() -> Result<Self> {
|
||||||
let inner = EntityStore::open_memory(&[&CONTENT_SCHEMA])?;
|
let inner = EntityStore::open_memory(ALL_SCHEMAS)?;
|
||||||
Ok(Self { inner })
|
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 crate::store::Store;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chrono::Utc;
|
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
|
//! Shape (multi-schema audit, 2026-04-23):
|
||||||
//! `organizations` and `interactions` ride `custom_migrations`: they are
|
//!
|
||||||
//! not generic CRUD (orgs use name-keyed upsert; interactions are an
|
//! - `SOCIAL_SCHEMA`: primary entity `person` (table `people`; INTEGER
|
||||||
//! append-only log with per-person index) and keep their existing
|
//! PK; engine-owned create/get/search/list + FTS).
|
||||||
//! column names byte-for-byte so on-disk DBs written before this
|
//! - `ALL_SCHEMAS`: the `&[&EntitySchema]` slice for `Store::open`.
|
||||||
//! migration still open cleanly.
|
//!
|
||||||
|
//! 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
|
//! FTS columns cover name, handle, email, bio — search verb routes
|
||||||
//! through `fts_people`. The legacy `fts_social` virtual table is
|
//! through `fts_people`. The legacy `fts_social` virtual table is
|
||||||
|
|
@ -64,3 +77,8 @@ pub static SOCIAL_SCHEMA: EntitySchema = EntitySchema {
|
||||||
archived_field: None,
|
archived_field: None,
|
||||||
custom_migrations: &[DDL_SECONDARY],
|
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
|
//! use the raw connection against tables declared in
|
||||||
//! `custom_migrations` — they are not generic-CRUD.
|
//! `custom_migrations` — they are not generic-CRUD.
|
||||||
|
|
||||||
use crate::schema::SOCIAL_SCHEMA;
|
use crate::schema::ALL_SCHEMAS;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use kei_entity_store::Store as EntityStore;
|
use kei_entity_store::Store as EntityStore;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
@ -18,12 +18,12 @@ pub struct Store {
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
let inner = EntityStore::open(path, &[&SOCIAL_SCHEMA])?;
|
let inner = EntityStore::open(path, ALL_SCHEMAS)?;
|
||||||
Ok(Self { inner })
|
Ok(Self { inner })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_memory() -> Result<Self> {
|
pub fn open_memory() -> Result<Self> {
|
||||||
let inner = EntityStore::open_memory(&[&SOCIAL_SCHEMA])?;
|
let inner = EntityStore::open_memory(ALL_SCHEMAS)?;
|
||||||
Ok(Self { inner })
|
Ok(Self { inner })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue