Skip to main content

kimetsu_brain/
schema.rs

1use rusqlite::Connection;
2
3use kimetsu_core::KimetsuResult;
4
5/// Apply performance-tuning SQLite pragmas to `conn`.
6///
7/// Safe on both read-write AND read-only connections: pragmas that cannot
8/// be set on a read-only DB (WAL mode, mmap_size) are skipped when they
9/// error, so the same function is called unconditionally from every open path.
10///
11/// Pragmas set:
12/// - `cache_size = -65536`      → 64 MiB page cache (negative = KiB)
13/// - `mmap_size = 268435456`    → 256 MiB memory-mapped I/O window
14/// - `synchronous = NORMAL`     → safe under WAL; avoids full fsync per commit
15/// - `temp_store = MEMORY`      → keep temp tables / sort buffers in RAM
16///
17/// `journal_mode = WAL` and `busy_timeout` are set by `create_baseline`
18/// (the read-write init path); they are NOT repeated here because
19/// `PRAGMA journal_mode` is a structural change that errors on read-only
20/// connections (the mode is already persisted in the DB file header).
21pub fn apply_pragmas(conn: &Connection) -> KimetsuResult<()> {
22    // cache_size and temp_store are safe on any connection.
23    conn.pragma_update(None, "cache_size", -65536_i64)?;
24    conn.pragma_update(None, "temp_store", "MEMORY")?;
25
26    // mmap_size and synchronous may fail on a read-only connection opened
27    // against a DB that's being written by another process in WAL mode.
28    // Best-effort: ignore errors from these two.
29    let _ = conn.pragma_update(None, "mmap_size", 268_435_456_i64);
30    let _ = conn.pragma_update(None, "synchronous", "NORMAL");
31
32    Ok(())
33}
34
35pub fn initialize(conn: &Connection) -> KimetsuResult<()> {
36    apply_pragmas(conn)?;
37    create_baseline(conn)?;
38    crate::migrate::run_migrations(conn)?;
39
40    // T3c: the old brute-force `memory_vec` vec0 virtual table is gone (usearch
41    // supersedes it). Best-effort drop to reclaim space in upgraded brains.
42    //
43    // Best-effort: a vec0 vtable can't be dropped without the (now-removed)
44    // sqlite-vec module loaded, so this DROP raises "no such module: vec0" on
45    // upgraded brains. We deliberately ignore the Result so that error can NEVER
46    // propagate and break connection-open. An orphaned, never-accessed
47    // memory_vec is harmless — SQLite loads a vtable module lazily, only on
48    // access, and nothing in the codebase queries memory_vec anymore. New brains
49    // never create it.
50    let _ = conn.execute_batch("DROP TABLE IF EXISTS memory_vec;");
51
52    Ok(())
53}
54
55/// Test seam: exposes `create_baseline` so integration tests in sibling
56/// modules can build a v1 DB without going through the full `initialize`
57/// (which would run all migrations and advance to the current version).
58#[cfg(test)]
59pub fn create_baseline_for_test(conn: &Connection) -> KimetsuResult<()> {
60    create_baseline(conn)
61}
62
63/// Create the baseline v1 schema (pragmas + all tables/indexes/FTS as of the
64/// original v1 shape). Seeds `schema_info` with version **1** so the migration
65/// runner knows where to start. On an existing DB every CREATE is a no-op
66/// (`IF NOT EXISTS`).
67fn create_baseline(conn: &Connection) -> KimetsuResult<()> {
68    conn.pragma_update(None, "journal_mode", "WAL")?;
69    conn.pragma_update(None, "busy_timeout", 15_000)?;
70
71    conn.execute_batch(
72        "
73        CREATE TABLE IF NOT EXISTS schema_info (
74            key TEXT PRIMARY KEY,
75            value INTEGER NOT NULL
76        );
77
78        INSERT OR IGNORE INTO schema_info (key, value)
79        VALUES ('kimetsu_schema_version', 1);
80
81        CREATE TABLE IF NOT EXISTS runs (
82            run_id TEXT PRIMARY KEY,
83            project_id TEXT NOT NULL,
84            task TEXT NOT NULL,
85            started_at TEXT NOT NULL,
86            ended_at TEXT,
87            terminal_kind TEXT,
88            model TEXT,
89            total_cost_usd REAL NOT NULL DEFAULT 0
90        );
91
92        CREATE TABLE IF NOT EXISTS events (
93            event_id TEXT PRIMARY KEY,
94            run_id TEXT NOT NULL,
95            ts TEXT NOT NULL,
96            kind TEXT NOT NULL,
97            schema_version INTEGER NOT NULL,
98            payload_json TEXT NOT NULL,
99            origin TEXT,
100            hlc TEXT
101        );
102
103        CREATE INDEX IF NOT EXISTS idx_events_run_ts ON events (run_id, ts);
104        CREATE INDEX IF NOT EXISTS idx_events_kind_ts ON events (kind, ts);
105
106        CREATE TABLE IF NOT EXISTS sources (
107            source_id TEXT PRIMARY KEY,
108            kind TEXT NOT NULL,
109            ref TEXT NOT NULL,
110            hash TEXT,
111            added_at TEXT NOT NULL
112        );
113
114        CREATE TABLE IF NOT EXISTS memories (
115            memory_id TEXT PRIMARY KEY,
116            scope TEXT NOT NULL,
117            kind TEXT NOT NULL,
118            text TEXT NOT NULL,
119            normalized_text TEXT NOT NULL,
120            confidence REAL NOT NULL,
121            source_event_id TEXT,
122            provenance_snapshot_json TEXT NOT NULL,
123            created_at TEXT NOT NULL,
124            last_used_at TEXT,
125            use_count INTEGER NOT NULL DEFAULT 0,
126            usefulness_score REAL NOT NULL DEFAULT 0.0,
127            invalidated_at TEXT,
128            invalidated_reason TEXT
129        );
130
131        CREATE INDEX IF NOT EXISTS idx_memories_scope_kind_norm
132            ON memories (scope, kind, normalized_text);
133        CREATE TABLE IF NOT EXISTS memory_proposals (
134            proposal_id TEXT PRIMARY KEY,
135            run_id TEXT NOT NULL,
136            scope TEXT NOT NULL,
137            kind TEXT NOT NULL,
138            text TEXT NOT NULL,
139            rationale TEXT NOT NULL,
140            proposed_confidence REAL NOT NULL,
141            source_event_ids_json TEXT NOT NULL,
142            status TEXT NOT NULL,
143            decided_at TEXT,
144            decided_by TEXT,
145            decided_reason TEXT
146        );
147
148        CREATE INDEX IF NOT EXISTS idx_memory_proposals_status_run
149            ON memory_proposals (status, run_id);
150
151        CREATE TABLE IF NOT EXISTS repo_files (
152            repo_root TEXT NOT NULL,
153            path TEXT NOT NULL,
154            hash TEXT NOT NULL,
155            size INTEGER NOT NULL,
156            mtime TEXT NOT NULL,
157            language_guess TEXT NOT NULL,
158            snippet TEXT NOT NULL,
159            PRIMARY KEY (repo_root, path)
160        );
161
162        CREATE INDEX IF NOT EXISTS idx_repo_files_language
163            ON repo_files (repo_root, language_guess);
164
165        CREATE TABLE IF NOT EXISTS repo_manifests (
166            repo_root TEXT NOT NULL,
167            manifest_path TEXT NOT NULL,
168            manifest_kind TEXT NOT NULL,
169            parsed_summary_json TEXT NOT NULL,
170            hash TEXT NOT NULL,
171            mtime TEXT NOT NULL,
172            PRIMARY KEY (repo_root, manifest_path)
173        );
174
175        CREATE VIRTUAL TABLE IF NOT EXISTS repo_files_fts
176            USING fts5(repo_root, path, snippet, language_guess);
177
178        CREATE VIRTUAL TABLE IF NOT EXISTS repo_manifests_fts
179            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
180
181        CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
182            USING fts5(memory_id UNINDEXED, text, kind, scope);
183        ",
184    )?;
185    Ok(())
186}
187
188/// The v1→v2 migration: folds every historical in-place patch
189/// (additive columns, citations/conflicts tables, FTS reshapes) into one
190/// idempotent step. Real-world DBs were all stamped v1, so this brings
191/// them — and freshly-created baselines — to the v2 shape.
192///
193/// NOTE: this function runs INSIDE a transaction owned by the migration
194/// runner. Do NOT issue BEGIN/COMMIT here.
195pub(crate) fn migrate_v1_to_v2(conn: &Connection) -> KimetsuResult<()> {
196    // In-place column additions for v0.1 brain.db files predating each
197    // column. Each ALTER is idempotent: we ignore the duplicate-column error
198    // so an upgraded binary opens an older brain.db without forcing a
199    // `kimetsu brain rebuild`.
200    add_column_if_missing(conn, "memory_proposals", "decided_reason TEXT")?;
201    // MP-4a: usefulness_score tracks the net outcome correlation of each
202    // memory. Incremented when a memory was in the context of a run.finished
203    // event; decremented for run.failed with category != "Gate". Used by the
204    // broker (MP-4b) to bias retrieval and by auto-accept (MP-4c) to shadow
205    // re-acceptance of low-usefulness patterns.
206    add_column_if_missing(
207        conn,
208        "memories",
209        "usefulness_score REAL NOT NULL DEFAULT 0.0",
210    )?;
211    // MP-4d: invalidated_at is set by `kimetsu brain memory invalidate` so
212    // the human reviewer can permanently retire a memory without rewriting
213    // the trace. The broker excludes invalidated rows from retrieval.
214    add_column_if_missing(conn, "memories", "invalidated_at TEXT")?;
215    add_column_if_missing(conn, "memories", "invalidated_reason TEXT")?;
216    // v0.4.2: hybrid retrieval scaffolding.
217    //   * `embedding`        — little-endian f32 BLOB, NULL on pre-v0.4.2 rows
218    //   * `embedding_model`  — opaque model id ("bge-small-en-v1.5",
219    //                          "stub-d8", "noop"), NULL when no embedding
220    //                          was produced (e.g. NoopEmbedder).
221    // Retrieval reads both: when `embedding` is non-NULL AND
222    // `embedding_model` matches the active embedder's id, the cosine
223    // score contributes to ranking. Otherwise the row is scored
224    // lexical-only (FTS) — exact v0.4.1 behavior, no regression.
225    add_column_if_missing(conn, "memories", "embedding BLOB")?;
226    add_column_if_missing(conn, "memories", "embedding_model TEXT")?;
227    // v0.5.1: timestamp of the most recent time this memory was
228    // cited AND the citing run ended in run.finished. Used by the
229    // broker's decay term: `effective = base * exp(-ln(2) *
230    // age_days / half_life)` so a memory that helped 6 months ago
231    // doesn't outvote one that helped yesterday.
232    //
233    // Distinct from `last_used_at` (bumped on every retrieval) —
234    // `last_useful_at` only tracks confirmed successful uses
235    // attributed via the v0.5.0 cite_memory tool.
236    //
237    // NULL on pre-v0.5.1 rows + on memories that have never been
238    // cited successfully. Retrieval falls back to `created_at` for
239    // the decay reference timestamp so brand-new memories don't
240    // get penalized for never having been cited yet.
241    add_column_if_missing(conn, "memories", "last_useful_at TEXT")?;
242    conn.execute_batch(
243        "
244        CREATE INDEX IF NOT EXISTS idx_memories_active_created
245            ON memories (invalidated_at, created_at);
246        ",
247    )?;
248    // v0.5.1: per-run, per-turn memory citation log.
249    //
250    // The model emits a `memory.cited` event (via the `cite_memory`
251    // tool) when it consciously leveraged a retrieved capsule. The
252    // projector mirrors each event into this table so the
253    // `kimetsu brain memory blame <run-id>` CLI + MCP tool can
254    // walk attribution without re-scanning the `events` table.
255    //
256    // Multiple citations per turn are allowed (a turn can use
257    // several memories). The PK includes `turn` so re-cites of the
258    // same memory across turns don't collide.
259    //
260    // Usefulness scoring upgrade (v0.5.1 sibling change in
261    // `projector::apply_run_finished` / `apply_run_failed`): cited
262    // memories get the full +/-1 delta; retrieved-but-not-cited
263    // memories get a weaker +/-0.1 — the strong signal goes to
264    // memories the model actually reasoned with, the weak signal
265    // stays for the silent passengers.
266    conn.execute_batch(
267        "
268        CREATE TABLE IF NOT EXISTS memory_citations (
269            run_id     TEXT NOT NULL,
270            memory_id  TEXT NOT NULL,
271            turn       INTEGER NOT NULL,
272            cited_at   TEXT NOT NULL,
273            rationale  TEXT,
274            PRIMARY KEY (run_id, memory_id, turn)
275        );
276        CREATE INDEX IF NOT EXISTS idx_citations_run
277            ON memory_citations (run_id);
278        CREATE INDEX IF NOT EXISTS idx_citations_memory
279            ON memory_citations (memory_id);
280        ",
281    )?;
282    // v0.5.2: conflict-detection log. When `add_memory` (or
283    // `add_user_memory`) inserts a new capsule whose embedding is
284    // close to an existing capsule in the same scope but whose
285    // normalized text differs, the conflict is logged here for
286    // operator review via `kimetsu brain memory conflicts`.
287    //
288    // We use `INSERT OR IGNORE` on (new_memory_id,
289    // existing_memory_id) so a re-scan over the same pair stays
290    // idempotent. `resolved_at` IS NULL marks an open conflict;
291    // `resolution` stores `'kept_new'`, `'kept_existing'`, or
292    // `'kept_both'` after operator decision.
293    //
294    // Embedder-only: conflict detection runs ONLY when a real
295    // embedder is available (cosine math requires it). NoopEmbedder
296    // builds silently skip the scan and never write to this table,
297    // so pre-v0.5.2 brain.db files opened by a lean build see no
298    // new rows.
299    conn.execute_batch(
300        "
301        CREATE TABLE IF NOT EXISTS memory_conflicts (
302            conflict_id        TEXT PRIMARY KEY,
303            new_memory_id      TEXT NOT NULL,
304            existing_memory_id TEXT NOT NULL,
305            scope              TEXT NOT NULL,
306            kind               TEXT NOT NULL,
307            similarity         REAL NOT NULL,
308            detected_at        TEXT NOT NULL,
309            resolved_at        TEXT,
310            resolution         TEXT,
311            UNIQUE (new_memory_id, existing_memory_id)
312        );
313        CREATE INDEX IF NOT EXISTS idx_conflicts_unresolved
314            ON memory_conflicts (resolved_at, detected_at);
315        CREATE INDEX IF NOT EXISTS idx_conflicts_new_memory
316            ON memory_conflicts (new_memory_id);
317
318        -- v3.0 #3 Slice B: concurrent-supersede conflicts surfaced during team
319        -- sync (a member superseded to two DIFFERENT survivors by concurrent
320        -- edits). HLC replay still picks a deterministic winner; this records the
321        -- collision for human review. A PROJECTION — cleared + repopulated by
322        -- rebuild. survivor_a < survivor_b (canonicalized) so it records once.
323        CREATE TABLE IF NOT EXISTS sync_conflicts (
324            member_id    TEXT NOT NULL,
325            survivor_a   TEXT NOT NULL,
326            survivor_b   TEXT NOT NULL,
327            detected_at  TEXT NOT NULL,
328            PRIMARY KEY (member_id, survivor_a, survivor_b)
329        );
330        ",
331    )?;
332    ensure_memories_fts_shape(conn)?;
333    ensure_repo_manifests_fts_shape(conn)?;
334
335    // v1.0 (Tier-1 perf): covering index for scope + embedding_model
336    // filtering in conflict detection and ANN pool fetch. Additive — the
337    // IF NOT EXISTS guard makes it idempotent on already-upgraded DBs.
338    conn.execute_batch(
339        "CREATE INDEX IF NOT EXISTS idx_memories_scope_model_active
340             ON memories (scope, embedding_model, invalidated_at);",
341    )?;
342
343    Ok(())
344}
345
346/// The v2→v3 migration: add `superseded_by` column to `memories`.
347///
348/// Superseded rows point at their survivor via this column.  Retrieval,
349/// listing, and export already filter `invalidated_at IS NULL`; this
350/// migration adds a companion `AND superseded_by IS NULL` guard to all
351/// such queries (applied in `context.rs`, `user_brain.rs`, and
352/// `project.rs`).  The FTS index and ANN index keep the same "remove on
353/// supersede" semantics as invalidation.
354///
355/// NOTE: this function runs INSIDE a transaction owned by the migration
356/// runner. Do NOT issue BEGIN/COMMIT here.
357pub(crate) fn migrate_v2_to_v3(conn: &Connection) -> KimetsuResult<()> {
358    add_column_if_missing(conn, "memories", "superseded_by TEXT")?;
359    conn.execute_batch(
360        "CREATE INDEX IF NOT EXISTS idx_memories_superseded
361             ON memories (superseded_by);",
362    )?;
363    Ok(())
364}
365
366/// The v3→v4 migration: add the `memory_edges` typed-edge projection table.
367///
368/// This table is the storage substrate for the S5.2 `GraphLiteBackend`.
369/// It is a **projection** (derivable from the event log) — `reset_projection`
370/// clears it and `rebuild_in_place` repopulates it by replaying events.
371///
372/// Edge types:
373/// * `supersedes`         — populated NOW from `memory.superseded` events.
374///   The surviving memory acquires a directed edge toward each member it
375///   absorbed.  Edge direction: `src_id` (survivor) → `dst_id` (member).
376/// * `refines`            — reserved; populated by the live write path when
377///   a `memory.accepted` event carries `refines_id` in the payload
378///   (Flagship 1 / Story 1.7).
379/// * `dead_end_of`        — reserved; populated when an episodic resume
380///   event closes a task-dead-end chain.
381/// * `decision_touches`   — reserved; decision memory → touched file paths.
382/// * `lesson_from`        — reserved; lesson memory → source memory / run.
383///
384/// NOTE: this function runs INSIDE a transaction owned by the migration
385/// runner.  Do NOT issue BEGIN/COMMIT here.
386pub(crate) fn migrate_v3_to_v4(conn: &Connection) -> KimetsuResult<()> {
387    conn.execute_batch(
388        "
389        CREATE TABLE IF NOT EXISTS memory_edges (
390            src_id      TEXT NOT NULL,
391            dst_id      TEXT NOT NULL,
392            edge_type   TEXT NOT NULL,
393            created_at  TEXT NOT NULL,
394            PRIMARY KEY (src_id, dst_id, edge_type)
395        );
396
397        CREATE INDEX IF NOT EXISTS idx_memory_edges_src
398            ON memory_edges (src_id, edge_type);
399
400        CREATE INDEX IF NOT EXISTS idx_memory_edges_dst
401            ON memory_edges (dst_id, edge_type);
402        ",
403    )?;
404    Ok(())
405}
406
407/// The v4→v5 migration: add the `work_episodes` per-repo episodic-resume
408/// projection table (Flagship 1, Story 1.3).
409///
410/// `work_episodes` is a **projection** derivable from `work.episode` events:
411/// `reset_projection` clears it and `rebuild_in_place` repopulates it.
412///
413/// NOTE: this function runs INSIDE a transaction owned by the migration
414/// runner.  Do NOT issue BEGIN/COMMIT here.
415pub(crate) fn migrate_v4_to_v5(conn: &Connection) -> KimetsuResult<()> {
416    crate::episode::create_work_episodes_table(conn)
417}
418
419/// The v5→v6 migration: add the `skill_proposals` table for Flagship 2
420/// Memory → Skill synthesis.
421///
422/// `skill_proposals` stores skill drafts (or candidate reports) produced
423/// by the skill-synthesis engine. Each row records:
424///   - a unique proposal id (ULID)
425///   - the draft SKILL.md content (NULL = report-only mode, no draft)
426///   - the suggested skill name / description
427///   - a JSON array of the source memory ids used to ground the draft
428///   - the trigger kind (`citations` or `cluster`)
429///   - citation count / cluster size that triggered synthesis
430///   - status: `pending` | `accepted` | `rejected`
431///   - when it was accepted and where the installed skill ended up
432///
433/// This table is a projection (not event-sourced) — proposals are created
434/// by the synthesis engine and consumed interactively; they are not
435/// replayed by `rebuild_in_place`.
436///
437/// NOTE: this function runs INSIDE a transaction owned by the migration
438/// runner. Do NOT issue BEGIN/COMMIT here.
439pub(crate) fn migrate_v5_to_v6(conn: &Connection) -> KimetsuResult<()> {
440    conn.execute_batch(
441        "
442        CREATE TABLE IF NOT EXISTS skill_proposals (
443            proposal_id   TEXT PRIMARY KEY,
444            skill_name    TEXT NOT NULL,
445            description   TEXT NOT NULL,
446            draft_content TEXT,
447            source_memory_ids_json TEXT NOT NULL DEFAULT '[]',
448            trigger_kind  TEXT NOT NULL,
449            trigger_count INTEGER NOT NULL DEFAULT 0,
450            status        TEXT NOT NULL DEFAULT 'pending',
451            decided_at    TEXT,
452            installed_path TEXT,
453            created_at    TEXT NOT NULL
454        );
455        CREATE INDEX IF NOT EXISTS idx_skill_proposals_status
456            ON skill_proposals (status, created_at);
457        ",
458    )?;
459    Ok(())
460}
461
462/// The v6→v7 migration: add `valid_from` and `valid_to` columns to `memories`
463/// for temporal validity modelling (Flagship 1 Pass A).
464///
465/// A memory with `valid_to` set to an ISO-8601 timestamp in the PAST is
466/// considered **expired** and is excluded from retrieval by default. Together
467/// with the existing `superseded_by IS NULL` guard this gives the retrieval
468/// pipeline a complete "is this fact still true?" filter.
469///
470/// Both columns are TEXT (ISO-8601 / RFC 3339) and nullable:
471///   * NULL `valid_from` → "valid since the memory was created" (no past-only guard).
472///   * NULL `valid_to`   → "valid indefinitely" (never expires).
473///   * Non-NULL `valid_to` with a value in the past → expired, excluded by default.
474///
475/// Populated by the `memory.temporal` event (projector: `apply_memory_temporal`).
476/// The columns survive a `reset_projection` + `rebuild_in_place` replay because
477/// the projector re-stamps them from the event log.
478///
479/// An index on `valid_to` is added so the retrieval WHERE clause
480///   `(valid_to IS NULL OR valid_to > <now>)`
481/// can use an index scan on the small subset of rows that are NOT NULL.
482///
483/// NOTE: this function runs INSIDE a transaction owned by the migration
484/// runner. Do NOT issue BEGIN/COMMIT here.
485pub(crate) fn migrate_v6_to_v7(conn: &Connection) -> KimetsuResult<()> {
486    add_column_if_missing(conn, "memories", "valid_from TEXT")?;
487    add_column_if_missing(conn, "memories", "valid_to TEXT")?;
488    conn.execute_batch(
489        "CREATE INDEX IF NOT EXISTS idx_memories_valid_to
490             ON memories (valid_to);",
491    )?;
492    Ok(())
493}
494
495/// v3.0 #3 (fleet write-safety): add a per-event `origin` column so every event
496/// records the device + agent that wrote it (`<machine_id>/<agent>`). Nullable;
497/// pre-v8 events read back as `origin = NULL` ("unknown"). Rebuild-safe and
498/// sync-ready (the origin is replicated verbatim).
499pub(crate) fn migrate_v7_to_v8(conn: &Connection) -> KimetsuResult<()> {
500    add_column_if_missing(conn, "events", "origin TEXT")?;
501    Ok(())
502}
503
504/// v3.0 #3 Slice B (team sync): add a per-event `hlc` column (Hybrid Logical
505/// Clock, canonical string) for globally-deterministic total-order replay.
506/// Existing rows are backfilled as `0000000000000.{rowid:010}.local` — `wall = 0`
507/// so all pre-v9 events sort BEFORE any new HLC event, ordered among themselves by
508/// `rowid` (their original insertion/causal order). This preserves a never-synced
509/// brain's projection exactly while giving every event a sortable HLC. Width (10)
510/// matches `Hlc::to_canonical` so backfilled and live HLCs compare consistently.
511pub(crate) fn migrate_v8_to_v9(conn: &Connection) -> KimetsuResult<()> {
512    add_column_if_missing(conn, "events", "hlc TEXT")?;
513    conn.execute_batch(
514        "UPDATE events
515         SET hlc = printf('%013d.%010d.local', 0, rowid)
516         WHERE hlc IS NULL;",
517    )?;
518    Ok(())
519}
520
521pub fn validate(conn: &Connection) -> KimetsuResult<()> {
522    // Apply performance pragmas on read-only connections too. The helper
523    // skips pragmas that error (journal_mode/mmap_size on some read-only
524    // opens), so this is always safe to call here.
525    apply_pragmas(conn)?;
526    use kimetsu_core::KIMETSU_SCHEMA_VERSION;
527    let current: i64 = conn.query_row(
528        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
529        [],
530        |row| row.get(0),
531    )?;
532    let target = KIMETSU_SCHEMA_VERSION;
533    if current > target {
534        return Err(format!(
535            "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
536        )
537        .into());
538    }
539    if current < target {
540        return Err(Box::new(crate::migrate::SchemaNeedsMigration {
541            from: current,
542            to: target,
543        }));
544    }
545    Ok(())
546}
547
548fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
549    let column_name = column_def
550        .split_whitespace()
551        .next()
552        .ok_or("empty column definition")?;
553    let exists: bool = {
554        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
555        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
556        let mut found = false;
557        for row in rows {
558            if row? == column_name {
559                found = true;
560                break;
561            }
562        }
563        found
564    };
565    if !exists {
566        conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
567    }
568    Ok(())
569}
570
571fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
572    if table_has_column(conn, "memories_fts", "memory_id")? {
573        return Ok(());
574    }
575    conn.execute_batch(
576        "
577        DROP TABLE IF EXISTS memories_fts;
578        CREATE VIRTUAL TABLE memories_fts
579            USING fts5(memory_id UNINDEXED, text, kind, scope);
580        INSERT INTO memories_fts (memory_id, text, kind, scope)
581            SELECT memory_id, text, kind, scope FROM memories;
582        ",
583    )?;
584    Ok(())
585}
586
587fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
588    if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
589        return Ok(());
590    }
591    conn.execute_batch(
592        "
593        DROP TABLE IF EXISTS repo_manifests_fts;
594        CREATE VIRTUAL TABLE repo_manifests_fts
595            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
596        INSERT INTO repo_manifests_fts (
597            repo_root, manifest_path, manifest_kind, parsed_summary_json
598        )
599            SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
600            FROM repo_manifests;
601        ",
602    )?;
603    Ok(())
604}
605
606fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
607    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
608    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
609    for row in rows {
610        if row? == column {
611            return Ok(true);
612        }
613    }
614    Ok(false)
615}
616
617// ---------------------------------------------------------------------------
618// Tests
619// ---------------------------------------------------------------------------
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::migrate;
625    use rusqlite::Connection;
626
627    fn column_names(conn: &Connection, table: &str) -> Vec<String> {
628        let mut stmt = conn
629            .prepare(&format!("PRAGMA table_info({table})"))
630            .expect("prepare table_info");
631        stmt.query_map([], |row| row.get::<_, String>(1))
632            .expect("query_map")
633            .map(|r| r.expect("row"))
634            .collect()
635    }
636
637    fn table_exists(conn: &Connection, name: &str) -> bool {
638        let count: i64 = conn
639            .query_row(
640                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
641                [name],
642                |r| r.get(0),
643            )
644            .unwrap_or(0);
645        count > 0
646    }
647
648    // ------------------------------------------------------------------
649    // 1. Fresh init reaches current schema version with full shape
650    // ------------------------------------------------------------------
651    #[test]
652    fn fresh_init_reaches_current_version_with_full_shape() {
653        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
654        let conn = Connection::open_in_memory().expect("open_in_memory");
655        initialize(&conn).expect("initialize");
656
657        // Version must be at target.
658        assert_eq!(
659            migrate::current_version(&conn).expect("current_version"),
660            KIMETSU_SCHEMA_VERSION,
661            "fresh DB must be at current schema version after initialize"
662        );
663
664        // Post-migration columns exist on `memories`.
665        let mem_cols = column_names(&conn, "memories");
666        assert!(
667            mem_cols.contains(&"embedding".to_string()),
668            "memories must have `embedding` column"
669        );
670        assert!(
671            mem_cols.contains(&"embedding_model".to_string()),
672            "memories must have `embedding_model` column"
673        );
674        assert!(
675            mem_cols.contains(&"last_useful_at".to_string()),
676            "memories must have `last_useful_at` column"
677        );
678        // v3: superseded_by column
679        assert!(
680            mem_cols.contains(&"superseded_by".to_string()),
681            "memories must have `superseded_by` column after v3 migration"
682        );
683        // v7: temporal validity columns (Flagship 1 Pass A)
684        assert!(
685            mem_cols.contains(&"valid_from".to_string()),
686            "memories must have `valid_from` column after v7 migration"
687        );
688        assert!(
689            mem_cols.contains(&"valid_to".to_string()),
690            "memories must have `valid_to` column after v7 migration"
691        );
692
693        // Tables added by the migrations exist.
694        assert!(
695            table_exists(&conn, "memory_citations"),
696            "memory_citations table must exist"
697        );
698        assert!(
699            table_exists(&conn, "memory_conflicts"),
700            "memory_conflicts table must exist"
701        );
702        // v4: typed-edge projection table
703        assert!(
704            table_exists(&conn, "memory_edges"),
705            "memory_edges table must exist after v4 migration"
706        );
707        // v5: episodic resume table
708        assert!(
709            table_exists(&conn, "work_episodes"),
710            "work_episodes table must exist after v5 migration"
711        );
712        // v6: skill proposals table (Flagship 2 Memory → Skill synthesis)
713        assert!(
714            table_exists(&conn, "skill_proposals"),
715            "skill_proposals table must exist after v6 migration"
716        );
717    }
718
719    // ------------------------------------------------------------------
720    // 2. Idempotent re-run: run_migrations again after initialize is a no-op
721    // ------------------------------------------------------------------
722    #[test]
723    fn idempotent_rerun_preserves_data() {
724        let conn = Connection::open_in_memory().expect("open_in_memory");
725        initialize(&conn).expect("initialize");
726
727        // Insert a memories row.
728        conn.execute_batch(
729            "INSERT INTO memories (
730                memory_id, scope, kind, text, normalized_text,
731                confidence, provenance_snapshot_json, created_at,
732                use_count, usefulness_score
733             ) VALUES (
734                'mem-1', 'test', 'fact', 'hello world', 'hello world',
735                0.9, '{}', '2024-01-01T00:00:00Z',
736                0, 0.0
737             );",
738        )
739        .expect("insert row");
740
741        // Re-run migrations — must be a no-op at target.
742        let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
743        assert_eq!(
744            outcome.applied,
745            Vec::<i64>::new(),
746            "second run_migrations must apply nothing"
747        );
748        assert_eq!(
749            migrate::current_version(&conn).expect("current_version"),
750            kimetsu_core::KIMETSU_SCHEMA_VERSION,
751            "version must still be at target"
752        );
753
754        // Data must be intact.
755        let text: String = conn
756            .query_row(
757                "SELECT text FROM memories WHERE memory_id = 'mem-1'",
758                [],
759                |r| r.get(0),
760            )
761            .expect("row must survive");
762        assert_eq!(text, "hello world");
763    }
764
765    // ------------------------------------------------------------------
766    // 3. Idempotent initialize: calling initialize twice succeeds, version stays at target
767    // ------------------------------------------------------------------
768    #[test]
769    fn idempotent_initialize_twice() {
770        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
771        let conn = Connection::open_in_memory().expect("open_in_memory");
772        initialize(&conn).expect("first initialize");
773        initialize(&conn).expect("second initialize must not error");
774        assert_eq!(
775            migrate::current_version(&conn).expect("current_version"),
776            KIMETSU_SCHEMA_VERSION,
777            "version must still be at target after double initialize"
778        );
779    }
780
781    // ------------------------------------------------------------------
782    // Fix 1: apply_pragmas sets the tuned cache_size on both RW and RO
783    // ------------------------------------------------------------------
784    #[test]
785    fn apply_pragmas_sets_cache_size_on_rw_connection() {
786        let conn = Connection::open_in_memory().expect("open_in_memory");
787        initialize(&conn).expect("initialize");
788        // After initialize (which calls apply_pragmas), cache_size must be -65536
789        // (the negative-KiB form we set). SQLite may return it as a page count
790        // (positive) or keep the -KiB form; we just assert it's not the default
791        // -2000 pages, which is what SQLite uses without any pragma_update.
792        let cache_size: i64 = conn
793            .pragma_query_value(None, "cache_size", |row| row.get(0))
794            .expect("cache_size query");
795        assert_ne!(
796            cache_size, -2000,
797            "cache_size must have been updated from the 2 MiB default, got {cache_size}"
798        );
799        // The tuned value should be a large negative number (KiB) or a large
800        // positive page count — either way not the stock default.
801        assert!(
802            !(-2000..=2000).contains(&cache_size),
803            "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
804        );
805    }
806
807    /// Fix 1: validate() (the read-only open path) also calls apply_pragmas.
808    /// We can't open a true read-only connection to an in-memory DB via OpenFlags,
809    /// so we exercise the helper directly and verify it doesn't error.
810    #[test]
811    fn apply_pragmas_does_not_error_on_in_memory_conn() {
812        let conn = Connection::open_in_memory().expect("open_in_memory");
813        apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
814        let cache_size: i64 = conn
815            .pragma_query_value(None, "cache_size", |row| row.get(0))
816            .expect("cache_size");
817        assert!(
818            !(-2000..=2000).contains(&cache_size),
819            "apply_pragmas must update cache_size from the default, got {cache_size}"
820        );
821    }
822
823    // Helper: seed an in-memory conn with only schema_info at the given version.
824    fn seed_schema_info(version: i64) -> Connection {
825        let conn = Connection::open_in_memory().expect("open_in_memory");
826        conn.execute_batch(&format!(
827            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
828             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
829        ))
830        .expect("seed schema_info");
831        conn
832    }
833
834    // ------------------------------------------------------------------
835    // A5-1. validate Ok at target version
836    // ------------------------------------------------------------------
837    #[test]
838    fn validate_ok_at_target() {
839        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
840        let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
841        validate(&conn).expect("validate at target must return Ok(())");
842    }
843
844    // ------------------------------------------------------------------
845    // A5-2. validate returns SchemaNeedsMigration for an older DB
846    // ------------------------------------------------------------------
847    #[test]
848    fn validate_returns_needs_migration_for_older_db() {
849        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
850        let conn = seed_schema_info(1);
851        let err = validate(&conn).expect_err("validate on v1 DB must return Err");
852        let snm = err
853            .downcast_ref::<migrate::SchemaNeedsMigration>()
854            .expect("error must downcast to SchemaNeedsMigration");
855        assert_eq!(
856            snm,
857            &migrate::SchemaNeedsMigration {
858                from: 1,
859                to: KIMETSU_SCHEMA_VERSION,
860            },
861            "SchemaNeedsMigration must carry the correct from/to versions"
862        );
863    }
864
865    // ------------------------------------------------------------------
866    // A5-v3. v2→v3 migration adds superseded_by + index
867    // ------------------------------------------------------------------
868    #[test]
869    fn v2_to_v3_migration_adds_superseded_by() {
870        let conn = Connection::open_in_memory().expect("open_in_memory");
871        // Seed a v2 DB manually (baseline + v1→v2 migration, no v2→v3).
872        create_baseline(&conn).expect("create_baseline");
873        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
874        conn.execute(
875            "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
876            [],
877        )
878        .expect("set v2");
879
880        // superseded_by must NOT exist yet.
881        let cols_before = column_names(&conn, "memories");
882        assert!(
883            !cols_before.contains(&"superseded_by".to_string()),
884            "superseded_by must not exist before v3 migration"
885        );
886
887        // Run v2→v3.
888        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
889
890        // Now it must exist.
891        let cols_after = column_names(&conn, "memories");
892        assert!(
893            cols_after.contains(&"superseded_by".to_string()),
894            "superseded_by must exist after v3 migration"
895        );
896
897        // Index must also exist.
898        let idx_count: i64 = conn
899            .query_row(
900                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
901                [],
902                |r| r.get(0),
903            )
904            .expect("query index");
905        assert_eq!(
906            idx_count, 1,
907            "idx_memories_superseded must exist after v3 migration"
908        );
909    }
910
911    // ------------------------------------------------------------------
912    // A5-3. validate hard-errors (non-SchemaNeedsMigration) for a newer DB
913    // ------------------------------------------------------------------
914    #[test]
915    fn validate_hard_errors_for_newer_db() {
916        let conn = seed_schema_info(999);
917        let err = validate(&conn).expect_err("validate on v999 DB must return Err");
918        assert!(
919            err.downcast_ref::<migrate::SchemaNeedsMigration>()
920                .is_none(),
921            "error for a newer DB must NOT downcast to SchemaNeedsMigration"
922        );
923        let msg = err.to_string();
924        assert!(
925            msg.contains("newer"),
926            "error message must contain 'newer', got: {msg}"
927        );
928    }
929
930    // ------------------------------------------------------------------
931    // S5.2-v4. v3→v4 migration adds memory_edges table + indexes
932    // ------------------------------------------------------------------
933    #[test]
934    fn v3_to_v4_migration_adds_memory_edges() {
935        let conn = Connection::open_in_memory().expect("open_in_memory");
936        // Build a v3 DB (baseline + v1→v2 + v2→v3, no v3→v4).
937        create_baseline(&conn).expect("create_baseline");
938        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
939        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
940        conn.execute(
941            "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
942            [],
943        )
944        .expect("set v3");
945
946        // memory_edges must NOT exist yet.
947        assert!(
948            !table_exists(&conn, "memory_edges"),
949            "memory_edges must not exist before v4 migration"
950        );
951
952        // Run v3→v4.
953        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
954
955        // Table must now exist.
956        assert!(
957            table_exists(&conn, "memory_edges"),
958            "memory_edges must exist after v4 migration"
959        );
960
961        // Indexes must exist.
962        let src_idx: i64 = conn
963            .query_row(
964                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
965                [],
966                |r| r.get(0),
967            )
968            .expect("query idx_memory_edges_src");
969        assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
970
971        let dst_idx: i64 = conn
972            .query_row(
973                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
974                [],
975                |r| r.get(0),
976            )
977            .expect("query idx_memory_edges_dst");
978        assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
979    }
980
981    // ------------------------------------------------------------------
982    // F1-v5. v4→v5 migration adds work_episodes table + indexes
983    // ------------------------------------------------------------------
984    #[test]
985    fn v4_to_v5_migration_adds_work_episodes() {
986        let conn = Connection::open_in_memory().expect("open_in_memory");
987        // Build a v4 DB (baseline + v1→v2 + v2→v3 + v3→v4, no v4→v5).
988        create_baseline(&conn).expect("create_baseline");
989        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
990        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
991        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
992        conn.execute(
993            "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
994            [],
995        )
996        .expect("set v4");
997
998        // work_episodes must NOT exist yet.
999        assert!(
1000            !table_exists(&conn, "work_episodes"),
1001            "work_episodes must not exist before v5 migration"
1002        );
1003
1004        // Run v4→v5.
1005        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1006
1007        // Table must now exist.
1008        assert!(
1009            table_exists(&conn, "work_episodes"),
1010            "work_episodes must exist after v5 migration"
1011        );
1012
1013        // Repo-live index must exist.
1014        let idx: i64 = conn
1015            .query_row(
1016                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1017                [],
1018                |r| r.get(0),
1019            )
1020            .expect("query idx_episodes_repo_live");
1021        assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1022    }
1023
1024    // ------------------------------------------------------------------
1025    // F2-v6. v5→v6 migration adds skill_proposals table + index
1026    // ------------------------------------------------------------------
1027    #[test]
1028    fn v5_to_v6_migration_adds_skill_proposals() {
1029        let conn = Connection::open_in_memory().expect("open_in_memory");
1030        // Build a v5 DB (all prior migrations, no v5→v6).
1031        create_baseline(&conn).expect("create_baseline");
1032        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1033        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1034        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1035        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1036        conn.execute(
1037            "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1038            [],
1039        )
1040        .expect("set v5");
1041
1042        // skill_proposals must NOT exist yet.
1043        assert!(
1044            !table_exists(&conn, "skill_proposals"),
1045            "skill_proposals must not exist before v6 migration"
1046        );
1047
1048        // Run v5→v6.
1049        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1050
1051        // Table must now exist.
1052        assert!(
1053            table_exists(&conn, "skill_proposals"),
1054            "skill_proposals must exist after v6 migration"
1055        );
1056
1057        // Status index must exist.
1058        let idx: i64 = conn
1059            .query_row(
1060                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1061                [],
1062                |r| r.get(0),
1063            )
1064            .expect("query idx_skill_proposals_status");
1065        assert_eq!(
1066            idx, 1,
1067            "idx_skill_proposals_status must exist after v6 migration"
1068        );
1069    }
1070
1071    // ------------------------------------------------------------------
1072    // F1A-v7. v6→v7 migration adds valid_from + valid_to columns + index
1073    // ------------------------------------------------------------------
1074    #[test]
1075    fn v6_to_v7_migration_adds_temporal_validity_columns() {
1076        let conn = Connection::open_in_memory().expect("open_in_memory");
1077        // Build a v6 DB (all prior migrations, no v6→v7).
1078        create_baseline(&conn).expect("create_baseline");
1079        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1080        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1081        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1082        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1083        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1084        conn.execute(
1085            "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1086            [],
1087        )
1088        .expect("set v6");
1089
1090        // valid_from and valid_to must NOT exist yet.
1091        let cols_before = column_names(&conn, "memories");
1092        assert!(
1093            !cols_before.contains(&"valid_from".to_string()),
1094            "valid_from must not exist before v7 migration"
1095        );
1096        assert!(
1097            !cols_before.contains(&"valid_to".to_string()),
1098            "valid_to must not exist before v7 migration"
1099        );
1100
1101        // Run v6→v7.
1102        migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1103
1104        // Columns must now exist.
1105        let cols_after = column_names(&conn, "memories");
1106        assert!(
1107            cols_after.contains(&"valid_from".to_string()),
1108            "valid_from must exist after v7 migration"
1109        );
1110        assert!(
1111            cols_after.contains(&"valid_to".to_string()),
1112            "valid_to must exist after v7 migration"
1113        );
1114
1115        // Index on valid_to must exist.
1116        let idx: i64 = conn
1117            .query_row(
1118                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1119                [],
1120                |r| r.get(0),
1121            )
1122            .expect("query idx_memories_valid_to");
1123        assert_eq!(
1124            idx, 1,
1125            "idx_memories_valid_to must exist after v7 migration"
1126        );
1127    }
1128}