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