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
521/// v2.5.2 consolidation v1: citations learn which QUERY they answered, and a
522/// derived `query_routes` table maps successful-query embeddings to the
523/// memories that answered them (built offline by `brain reinforce`, read at
524/// retrieval time as a bounded boost).
525pub(crate) fn migrate_v9_to_v10(conn: &Connection) -> KimetsuResult<()> {
526    // Guarded: synthetic/partial DBs (migration tests, tooling) may lack the
527    // table entirely; real brains always have it from the baseline.
528    let has_citations: bool = conn
529        .query_row(
530            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_citations'",
531            [],
532            |r| r.get::<_, i64>(0).map(|n| n > 0),
533        )
534        .unwrap_or(false);
535    if has_citations {
536        add_column_if_missing(conn, "memory_citations", "query TEXT")?;
537    }
538    conn.execute_batch(
539        "
540        CREATE TABLE IF NOT EXISTS query_routes (
541            query_norm      TEXT NOT NULL,
542            memory_id       TEXT NOT NULL,
543            cites           INTEGER NOT NULL DEFAULT 0,
544            last_cited_at   TEXT NOT NULL,
545            query_embedding BLOB,
546            embedding_model TEXT,
547            PRIMARY KEY (query_norm, memory_id)
548        );
549        CREATE INDEX IF NOT EXISTS idx_query_routes_memory
550            ON query_routes(memory_id);
551        ",
552    )?;
553    Ok(())
554}
555
556pub fn validate(conn: &Connection) -> KimetsuResult<()> {
557    // Apply performance pragmas on read-only connections too. The helper
558    // skips pragmas that error (journal_mode/mmap_size on some read-only
559    // opens), so this is always safe to call here.
560    apply_pragmas(conn)?;
561    use kimetsu_core::KIMETSU_SCHEMA_VERSION;
562    let current: i64 = conn.query_row(
563        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
564        [],
565        |row| row.get(0),
566    )?;
567    let target = KIMETSU_SCHEMA_VERSION;
568    if current > target {
569        return Err(format!(
570            "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
571        )
572        .into());
573    }
574    if current < target {
575        return Err(Box::new(crate::migrate::SchemaNeedsMigration {
576            from: current,
577            to: target,
578        }));
579    }
580    Ok(())
581}
582
583fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
584    let column_name = column_def
585        .split_whitespace()
586        .next()
587        .ok_or("empty column definition")?;
588    let exists: bool = {
589        let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
590        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
591        let mut found = false;
592        for row in rows {
593            if row? == column_name {
594                found = true;
595                break;
596            }
597        }
598        found
599    };
600    if !exists {
601        conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
602    }
603    Ok(())
604}
605
606fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
607    if table_has_column(conn, "memories_fts", "memory_id")? {
608        return Ok(());
609    }
610    conn.execute_batch(
611        "
612        DROP TABLE IF EXISTS memories_fts;
613        CREATE VIRTUAL TABLE memories_fts
614            USING fts5(memory_id UNINDEXED, text, kind, scope);
615        INSERT INTO memories_fts (memory_id, text, kind, scope)
616            SELECT memory_id, text, kind, scope FROM memories;
617        ",
618    )?;
619    Ok(())
620}
621
622fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
623    if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
624        return Ok(());
625    }
626    conn.execute_batch(
627        "
628        DROP TABLE IF EXISTS repo_manifests_fts;
629        CREATE VIRTUAL TABLE repo_manifests_fts
630            USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
631        INSERT INTO repo_manifests_fts (
632            repo_root, manifest_path, manifest_kind, parsed_summary_json
633        )
634            SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
635            FROM repo_manifests;
636        ",
637    )?;
638    Ok(())
639}
640
641fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
642    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
643    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
644    for row in rows {
645        if row? == column {
646            return Ok(true);
647        }
648    }
649    Ok(false)
650}
651
652// ---------------------------------------------------------------------------
653// Tests
654// ---------------------------------------------------------------------------
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::migrate;
660    use rusqlite::Connection;
661
662    fn column_names(conn: &Connection, table: &str) -> Vec<String> {
663        let mut stmt = conn
664            .prepare(&format!("PRAGMA table_info({table})"))
665            .expect("prepare table_info");
666        stmt.query_map([], |row| row.get::<_, String>(1))
667            .expect("query_map")
668            .map(|r| r.expect("row"))
669            .collect()
670    }
671
672    fn table_exists(conn: &Connection, name: &str) -> bool {
673        let count: i64 = conn
674            .query_row(
675                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
676                [name],
677                |r| r.get(0),
678            )
679            .unwrap_or(0);
680        count > 0
681    }
682
683    // ------------------------------------------------------------------
684    // 1. Fresh init reaches current schema version with full shape
685    // ------------------------------------------------------------------
686    #[test]
687    fn fresh_init_reaches_current_version_with_full_shape() {
688        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
689        let conn = Connection::open_in_memory().expect("open_in_memory");
690        initialize(&conn).expect("initialize");
691
692        // Version must be at target.
693        assert_eq!(
694            migrate::current_version(&conn).expect("current_version"),
695            KIMETSU_SCHEMA_VERSION,
696            "fresh DB must be at current schema version after initialize"
697        );
698
699        // Post-migration columns exist on `memories`.
700        let mem_cols = column_names(&conn, "memories");
701        assert!(
702            mem_cols.contains(&"embedding".to_string()),
703            "memories must have `embedding` column"
704        );
705        assert!(
706            mem_cols.contains(&"embedding_model".to_string()),
707            "memories must have `embedding_model` column"
708        );
709        assert!(
710            mem_cols.contains(&"last_useful_at".to_string()),
711            "memories must have `last_useful_at` column"
712        );
713        // v3: superseded_by column
714        assert!(
715            mem_cols.contains(&"superseded_by".to_string()),
716            "memories must have `superseded_by` column after v3 migration"
717        );
718        // v7: temporal validity columns (Flagship 1 Pass A)
719        assert!(
720            mem_cols.contains(&"valid_from".to_string()),
721            "memories must have `valid_from` column after v7 migration"
722        );
723        assert!(
724            mem_cols.contains(&"valid_to".to_string()),
725            "memories must have `valid_to` column after v7 migration"
726        );
727
728        // Tables added by the migrations exist.
729        assert!(
730            table_exists(&conn, "memory_citations"),
731            "memory_citations table must exist"
732        );
733        assert!(
734            table_exists(&conn, "memory_conflicts"),
735            "memory_conflicts table must exist"
736        );
737        // v4: typed-edge projection table
738        assert!(
739            table_exists(&conn, "memory_edges"),
740            "memory_edges table must exist after v4 migration"
741        );
742        // v5: episodic resume table
743        assert!(
744            table_exists(&conn, "work_episodes"),
745            "work_episodes table must exist after v5 migration"
746        );
747        // v6: skill proposals table (Flagship 2 Memory → Skill synthesis)
748        assert!(
749            table_exists(&conn, "skill_proposals"),
750            "skill_proposals table must exist after v6 migration"
751        );
752    }
753
754    // ------------------------------------------------------------------
755    // 2. Idempotent re-run: run_migrations again after initialize is a no-op
756    // ------------------------------------------------------------------
757    #[test]
758    fn idempotent_rerun_preserves_data() {
759        let conn = Connection::open_in_memory().expect("open_in_memory");
760        initialize(&conn).expect("initialize");
761
762        // Insert a memories row.
763        conn.execute_batch(
764            "INSERT INTO memories (
765                memory_id, scope, kind, text, normalized_text,
766                confidence, provenance_snapshot_json, created_at,
767                use_count, usefulness_score
768             ) VALUES (
769                'mem-1', 'test', 'fact', 'hello world', 'hello world',
770                0.9, '{}', '2024-01-01T00:00:00Z',
771                0, 0.0
772             );",
773        )
774        .expect("insert row");
775
776        // Re-run migrations — must be a no-op at target.
777        let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
778        assert_eq!(
779            outcome.applied,
780            Vec::<i64>::new(),
781            "second run_migrations must apply nothing"
782        );
783        assert_eq!(
784            migrate::current_version(&conn).expect("current_version"),
785            kimetsu_core::KIMETSU_SCHEMA_VERSION,
786            "version must still be at target"
787        );
788
789        // Data must be intact.
790        let text: String = conn
791            .query_row(
792                "SELECT text FROM memories WHERE memory_id = 'mem-1'",
793                [],
794                |r| r.get(0),
795            )
796            .expect("row must survive");
797        assert_eq!(text, "hello world");
798    }
799
800    // ------------------------------------------------------------------
801    // 3. Idempotent initialize: calling initialize twice succeeds, version stays at target
802    // ------------------------------------------------------------------
803    #[test]
804    fn idempotent_initialize_twice() {
805        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
806        let conn = Connection::open_in_memory().expect("open_in_memory");
807        initialize(&conn).expect("first initialize");
808        initialize(&conn).expect("second initialize must not error");
809        assert_eq!(
810            migrate::current_version(&conn).expect("current_version"),
811            KIMETSU_SCHEMA_VERSION,
812            "version must still be at target after double initialize"
813        );
814    }
815
816    // ------------------------------------------------------------------
817    // Fix 1: apply_pragmas sets the tuned cache_size on both RW and RO
818    // ------------------------------------------------------------------
819    #[test]
820    fn apply_pragmas_sets_cache_size_on_rw_connection() {
821        let conn = Connection::open_in_memory().expect("open_in_memory");
822        initialize(&conn).expect("initialize");
823        // After initialize (which calls apply_pragmas), cache_size must be -65536
824        // (the negative-KiB form we set). SQLite may return it as a page count
825        // (positive) or keep the -KiB form; we just assert it's not the default
826        // -2000 pages, which is what SQLite uses without any pragma_update.
827        let cache_size: i64 = conn
828            .pragma_query_value(None, "cache_size", |row| row.get(0))
829            .expect("cache_size query");
830        assert_ne!(
831            cache_size, -2000,
832            "cache_size must have been updated from the 2 MiB default, got {cache_size}"
833        );
834        // The tuned value should be a large negative number (KiB) or a large
835        // positive page count — either way not the stock default.
836        assert!(
837            !(-2000..=2000).contains(&cache_size),
838            "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
839        );
840    }
841
842    /// Fix 1: validate() (the read-only open path) also calls apply_pragmas.
843    /// We can't open a true read-only connection to an in-memory DB via OpenFlags,
844    /// so we exercise the helper directly and verify it doesn't error.
845    #[test]
846    fn apply_pragmas_does_not_error_on_in_memory_conn() {
847        let conn = Connection::open_in_memory().expect("open_in_memory");
848        apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
849        let cache_size: i64 = conn
850            .pragma_query_value(None, "cache_size", |row| row.get(0))
851            .expect("cache_size");
852        assert!(
853            !(-2000..=2000).contains(&cache_size),
854            "apply_pragmas must update cache_size from the default, got {cache_size}"
855        );
856    }
857
858    // Helper: seed an in-memory conn with only schema_info at the given version.
859    fn seed_schema_info(version: i64) -> Connection {
860        let conn = Connection::open_in_memory().expect("open_in_memory");
861        conn.execute_batch(&format!(
862            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
863             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
864        ))
865        .expect("seed schema_info");
866        conn
867    }
868
869    // ------------------------------------------------------------------
870    // A5-1. validate Ok at target version
871    // ------------------------------------------------------------------
872    #[test]
873    fn validate_ok_at_target() {
874        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
875        let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
876        validate(&conn).expect("validate at target must return Ok(())");
877    }
878
879    // ------------------------------------------------------------------
880    // A5-2. validate returns SchemaNeedsMigration for an older DB
881    // ------------------------------------------------------------------
882    #[test]
883    fn validate_returns_needs_migration_for_older_db() {
884        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
885        let conn = seed_schema_info(1);
886        let err = validate(&conn).expect_err("validate on v1 DB must return Err");
887        let snm = err
888            .downcast_ref::<migrate::SchemaNeedsMigration>()
889            .expect("error must downcast to SchemaNeedsMigration");
890        assert_eq!(
891            snm,
892            &migrate::SchemaNeedsMigration {
893                from: 1,
894                to: KIMETSU_SCHEMA_VERSION,
895            },
896            "SchemaNeedsMigration must carry the correct from/to versions"
897        );
898    }
899
900    // ------------------------------------------------------------------
901    // A5-v3. v2→v3 migration adds superseded_by + index
902    // ------------------------------------------------------------------
903    #[test]
904    fn v2_to_v3_migration_adds_superseded_by() {
905        let conn = Connection::open_in_memory().expect("open_in_memory");
906        // Seed a v2 DB manually (baseline + v1→v2 migration, no v2→v3).
907        create_baseline(&conn).expect("create_baseline");
908        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
909        conn.execute(
910            "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
911            [],
912        )
913        .expect("set v2");
914
915        // superseded_by must NOT exist yet.
916        let cols_before = column_names(&conn, "memories");
917        assert!(
918            !cols_before.contains(&"superseded_by".to_string()),
919            "superseded_by must not exist before v3 migration"
920        );
921
922        // Run v2→v3.
923        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
924
925        // Now it must exist.
926        let cols_after = column_names(&conn, "memories");
927        assert!(
928            cols_after.contains(&"superseded_by".to_string()),
929            "superseded_by must exist after v3 migration"
930        );
931
932        // Index must also exist.
933        let idx_count: i64 = conn
934            .query_row(
935                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
936                [],
937                |r| r.get(0),
938            )
939            .expect("query index");
940        assert_eq!(
941            idx_count, 1,
942            "idx_memories_superseded must exist after v3 migration"
943        );
944    }
945
946    // ------------------------------------------------------------------
947    // A5-3. validate hard-errors (non-SchemaNeedsMigration) for a newer DB
948    // ------------------------------------------------------------------
949    #[test]
950    fn validate_hard_errors_for_newer_db() {
951        let conn = seed_schema_info(999);
952        let err = validate(&conn).expect_err("validate on v999 DB must return Err");
953        assert!(
954            err.downcast_ref::<migrate::SchemaNeedsMigration>()
955                .is_none(),
956            "error for a newer DB must NOT downcast to SchemaNeedsMigration"
957        );
958        let msg = err.to_string();
959        assert!(
960            msg.contains("newer"),
961            "error message must contain 'newer', got: {msg}"
962        );
963    }
964
965    // ------------------------------------------------------------------
966    // S5.2-v4. v3→v4 migration adds memory_edges table + indexes
967    // ------------------------------------------------------------------
968    #[test]
969    fn v3_to_v4_migration_adds_memory_edges() {
970        let conn = Connection::open_in_memory().expect("open_in_memory");
971        // Build a v3 DB (baseline + v1→v2 + v2→v3, no v3→v4).
972        create_baseline(&conn).expect("create_baseline");
973        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
974        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
975        conn.execute(
976            "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
977            [],
978        )
979        .expect("set v3");
980
981        // memory_edges must NOT exist yet.
982        assert!(
983            !table_exists(&conn, "memory_edges"),
984            "memory_edges must not exist before v4 migration"
985        );
986
987        // Run v3→v4.
988        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
989
990        // Table must now exist.
991        assert!(
992            table_exists(&conn, "memory_edges"),
993            "memory_edges must exist after v4 migration"
994        );
995
996        // Indexes must exist.
997        let src_idx: i64 = conn
998            .query_row(
999                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
1000                [],
1001                |r| r.get(0),
1002            )
1003            .expect("query idx_memory_edges_src");
1004        assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
1005
1006        let dst_idx: i64 = conn
1007            .query_row(
1008                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
1009                [],
1010                |r| r.get(0),
1011            )
1012            .expect("query idx_memory_edges_dst");
1013        assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
1014    }
1015
1016    // ------------------------------------------------------------------
1017    // F1-v5. v4→v5 migration adds work_episodes table + indexes
1018    // ------------------------------------------------------------------
1019    #[test]
1020    fn v4_to_v5_migration_adds_work_episodes() {
1021        let conn = Connection::open_in_memory().expect("open_in_memory");
1022        // Build a v4 DB (baseline + v1→v2 + v2→v3 + v3→v4, no v4→v5).
1023        create_baseline(&conn).expect("create_baseline");
1024        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1025        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1026        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1027        conn.execute(
1028            "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
1029            [],
1030        )
1031        .expect("set v4");
1032
1033        // work_episodes must NOT exist yet.
1034        assert!(
1035            !table_exists(&conn, "work_episodes"),
1036            "work_episodes must not exist before v5 migration"
1037        );
1038
1039        // Run v4→v5.
1040        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1041
1042        // Table must now exist.
1043        assert!(
1044            table_exists(&conn, "work_episodes"),
1045            "work_episodes must exist after v5 migration"
1046        );
1047
1048        // Repo-live index must exist.
1049        let idx: i64 = conn
1050            .query_row(
1051                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1052                [],
1053                |r| r.get(0),
1054            )
1055            .expect("query idx_episodes_repo_live");
1056        assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1057    }
1058
1059    // ------------------------------------------------------------------
1060    // F2-v6. v5→v6 migration adds skill_proposals table + index
1061    // ------------------------------------------------------------------
1062    #[test]
1063    fn v5_to_v6_migration_adds_skill_proposals() {
1064        let conn = Connection::open_in_memory().expect("open_in_memory");
1065        // Build a v5 DB (all prior migrations, no v5→v6).
1066        create_baseline(&conn).expect("create_baseline");
1067        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1068        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1069        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1070        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1071        conn.execute(
1072            "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1073            [],
1074        )
1075        .expect("set v5");
1076
1077        // skill_proposals must NOT exist yet.
1078        assert!(
1079            !table_exists(&conn, "skill_proposals"),
1080            "skill_proposals must not exist before v6 migration"
1081        );
1082
1083        // Run v5→v6.
1084        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1085
1086        // Table must now exist.
1087        assert!(
1088            table_exists(&conn, "skill_proposals"),
1089            "skill_proposals must exist after v6 migration"
1090        );
1091
1092        // Status index must exist.
1093        let idx: i64 = conn
1094            .query_row(
1095                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1096                [],
1097                |r| r.get(0),
1098            )
1099            .expect("query idx_skill_proposals_status");
1100        assert_eq!(
1101            idx, 1,
1102            "idx_skill_proposals_status must exist after v6 migration"
1103        );
1104    }
1105
1106    // ------------------------------------------------------------------
1107    // F1A-v7. v6→v7 migration adds valid_from + valid_to columns + index
1108    // ------------------------------------------------------------------
1109    #[test]
1110    fn v6_to_v7_migration_adds_temporal_validity_columns() {
1111        let conn = Connection::open_in_memory().expect("open_in_memory");
1112        // Build a v6 DB (all prior migrations, no v6→v7).
1113        create_baseline(&conn).expect("create_baseline");
1114        migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1115        migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1116        migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1117        migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1118        migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1119        conn.execute(
1120            "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1121            [],
1122        )
1123        .expect("set v6");
1124
1125        // valid_from and valid_to must NOT exist yet.
1126        let cols_before = column_names(&conn, "memories");
1127        assert!(
1128            !cols_before.contains(&"valid_from".to_string()),
1129            "valid_from must not exist before v7 migration"
1130        );
1131        assert!(
1132            !cols_before.contains(&"valid_to".to_string()),
1133            "valid_to must not exist before v7 migration"
1134        );
1135
1136        // Run v6→v7.
1137        migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1138
1139        // Columns must now exist.
1140        let cols_after = column_names(&conn, "memories");
1141        assert!(
1142            cols_after.contains(&"valid_from".to_string()),
1143            "valid_from must exist after v7 migration"
1144        );
1145        assert!(
1146            cols_after.contains(&"valid_to".to_string()),
1147            "valid_to must exist after v7 migration"
1148        );
1149
1150        // Index on valid_to must exist.
1151        let idx: i64 = conn
1152            .query_row(
1153                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1154                [],
1155                |r| r.get(0),
1156            )
1157            .expect("query idx_memories_valid_to");
1158        assert_eq!(
1159            idx, 1,
1160            "idx_memories_valid_to must exist after v7 migration"
1161        );
1162    }
1163}