KeiSeiKit-1.0/_primitives/_rust/kei-ledger/src/schema.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

120 lines
4.5 KiB
Rust

//! SQL schema runner for the agent ledger.
//!
//! Constructor Pattern: this cube is the runner; the DDL list lives in
//! the sibling [`crate::migrations_list`] module. Splitting keeps the
//! file under the 200-LOC ceiling now that v8 (skill_invocations) has
//! landed.
use crate::error::LedgerError;
use rusqlite::{Connection, Result};
/// Maximum length (chars) accepted for `branch` and `parent_branch` columns.
/// Enforced by SQL CHECK (v3 migration) and CLI `value_parser` length cap.
pub const MAX_BRANCH_LEN: usize = 256;
/// Re-export the migration list for backward-compat. Existing callers
/// (`tests/v6_cost.rs`, `tests/v7_micro.rs`, `tests/integration.rs`,
/// `schema_test.rs`) import via `crate::schema::MIGRATIONS`. SSoT lives
/// in `migrations_list.rs`; this `pub use` keeps the import path stable.
pub use crate::migrations_list::MIGRATIONS;
/// Schema version constant — index of the latest migration entry.
/// Callers (CLI / lib / tests) compare against `PRAGMA user_version` to
/// confirm the ledger is up to date. Bumped together with the migration
/// list. v9 (2026-04-30) added kei-model-router posterior columns:
/// tokens_in, tokens_out, stubs_count, outcome, escalation_depth, plus the
/// VIRTUAL `task_class_dna` column (DNA with trailing nonce stripped) for
/// per-task-class empirical posterior aggregation.
pub const SCHEMA_VERSION: u32 = 9;
/// Schema version the v5 pre-check guards. Kept as a named constant so the
/// branch in `migrate()` stays self-documenting when future migrations land.
const V5_TARGET: i64 = 5;
/// Apply all pending migrations atomically (one transaction per version).
///
/// Prior design ran `execute_batch` and bumped `user_version` in a separate
/// call — partial failure left the schema half-applied and wedged restarts.
/// Now each version's DDL + the `user_version` bump share a transaction, so
/// any error rolls everything back and the next startup retries cleanly.
///
/// The return type is `LedgerError` (not `rusqlite::Error`) because v5
/// surfaces a typed `DnaMigrationBlocked` when pre-existing duplicates would
/// make the UNIQUE index creation fail — callers deserve a structured error,
/// not an opaque "UNIQUE constraint failed" from raw SQL.
pub fn migrate(conn: &Connection) -> std::result::Result<(), LedgerError> {
let current: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap_or(0);
for (i, sql) in MIGRATIONS.iter().enumerate() {
let target = (i + 1) as i64;
if current < target {
if target == V5_TARGET {
precheck_dna_uniqueness(conn)?;
}
apply_one(conn, sql, target).map_err(LedgerError::Sql)?;
}
}
Ok(())
}
/// v5 pre-check — scan existing rows for duplicate non-NULL DNAs. If any
/// exist, abort with `DnaMigrationBlocked` listing each offender and its
/// count. NULL DNAs are ignored because SQLite's default UNIQUE semantics
/// treat multiple NULLs as distinct (legacy pre-v2 rows stay valid).
fn precheck_dna_uniqueness(conn: &Connection) -> std::result::Result<(), LedgerError> {
let mut stmt = conn
.prepare(
"SELECT dna, COUNT(*) AS c FROM agents
WHERE dna IS NOT NULL
GROUP BY dna HAVING c > 1
ORDER BY c DESC, dna ASC",
)
.map_err(LedgerError::Sql)?;
let rows = stmt
.query_map([], |r| {
let dna: String = r.get(0)?;
let count: i64 = r.get(1)?;
Ok((dna, count as usize))
})
.map_err(LedgerError::Sql)?;
let duplicates: Vec<(String, usize)> = rows
.collect::<Result<Vec<_>>>()
.map_err(LedgerError::Sql)?;
if duplicates.is_empty() {
Ok(())
} else {
Err(LedgerError::DnaMigrationBlocked { duplicates })
}
}
/// Apply a single migration atomically: DDL + user_version bump in one txn.
fn apply_one(conn: &Connection, sql: &str, target: i64) -> Result<()> {
conn.execute_batch("BEGIN IMMEDIATE")?;
let step = (|| -> Result<()> {
conn.execute_batch(sql)?;
conn.pragma_update(None, "user_version", target)?;
Ok(())
})();
match step {
Ok(()) => conn.execute_batch("COMMIT"),
Err(e) => {
let _ = conn.execute_batch("ROLLBACK");
Err(e)
}
}
}
/// Six required artefacts per agent (RULE 0.12 §completion bundle).
pub const REQUIRED_ARTEFACTS: &[&str] = &[
"spec.md",
"plan.md",
"progress.json",
"chatlog.md",
"handoffs.md",
"review.md",
];
#[cfg(test)]
#[path = "schema_test.rs"]
mod tests;