Merge W12A — sister re-migration (campaigns promoted)

This commit is contained in:
Parfii-bot 2026-04-23 14:44:31 +08:00
commit 0f586f0143
7 changed files with 133 additions and 39 deletions

View file

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

View file

@ -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;

View file

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

View file

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

View file

@ -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;

View file

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

View file

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