Skip to main content

kimetsu_brain/
migrate.rs

1use std::cmp::Reverse;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use kimetsu_core::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
6use rusqlite::Connection;
7
8/// Returned by `schema::validate` when a read-only connection observes a DB
9/// older than the binary's target version. Read-only connections cannot run
10/// DDL, so the caller must decide: the user brain treats it as "unavailable
11/// this call" (`Ok(None)`) and the next read-write open migrates it.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SchemaNeedsMigration {
14    pub from: i64,
15    pub to: i64,
16}
17
18impl std::fmt::Display for SchemaNeedsMigration {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(
21            f,
22            "brain.db schema version {} is older than this binary's {}; open it read-write once to migrate",
23            self.from, self.to
24        )
25    }
26}
27
28impl std::error::Error for SchemaNeedsMigration {}
29
30/// One forward-only schema migration. `version` is the value the DB is
31/// stamped with AFTER `up` succeeds (i.e. `migrations()[i].version` is the
32/// post-migration version). `up` MUST be idempotent (it may be re-run after
33/// a crash mid-batch).
34pub struct Migration {
35    pub version: i64,
36    pub description: &'static str,
37    pub up: fn(&Connection) -> KimetsuResult<()>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct MigrationOutcome {
42    pub from: i64,
43    pub to: i64,
44    pub applied: Vec<i64>,
45    /// Path of the pre-migration sidecar backup, when one was created.
46    /// `None` for in-memory DBs, no-op opens, and the `current > target` error path.
47    pub backup_path: Option<PathBuf>,
48}
49
50/// The ordered migration set.
51///
52/// Invariant (debug-asserted in `run_with`): versions strictly ascending and
53/// contiguous starting at 2 (version 1 is the baseline `CREATE`, not a
54/// migration step).
55fn migrations() -> &'static [Migration] {
56    &[
57        Migration {
58            version: 2,
59            description: "fold additive columns, citations/conflicts tables, and FTS reshapes",
60            up: crate::schema::migrate_v1_to_v2,
61        },
62        Migration {
63            version: 3,
64            description: "add superseded_by column + index for near-duplicate merge (Story 3.1)",
65            up: crate::schema::migrate_v2_to_v3,
66        },
67        Migration {
68            version: 4,
69            description: "add memory_edges typed-edge projection table (S5.2 graph-lite backend)",
70            up: crate::schema::migrate_v3_to_v4,
71        },
72        Migration {
73            version: 5,
74            description: "add work_episodes projection table (Flagship 1 episodic resume, Story 1.3)",
75            up: crate::schema::migrate_v4_to_v5,
76        },
77        Migration {
78            version: 6,
79            description: "add skill_proposals table (Flagship 2 Memory → Skill synthesis)",
80            up: crate::schema::migrate_v5_to_v6,
81        },
82        Migration {
83            version: 7,
84            description: "add valid_from + valid_to columns for temporal validity (Flagship 1 Pass A)",
85            up: crate::schema::migrate_v6_to_v7,
86        },
87        Migration {
88            version: 8,
89            description: "add per-event origin column (v2.6 #3 fleet write-safety / provenance)",
90            up: crate::schema::migrate_v7_to_v8,
91        },
92        Migration {
93            version: 9,
94            description: "add per-event HLC column + backfill (v2.6 #3 Slice B convergent team sync)",
95            up: crate::schema::migrate_v8_to_v9,
96        },
97        Migration {
98            version: 10,
99            description: "add memory_citations.query + query_routes table (v2.5.2 consolidation v1)",
100            up: crate::schema::migrate_v9_to_v10,
101        },
102        Migration {
103            version: 11,
104            description: "add memory_entities projection (v2.6 first-class tags + ingest-time edges)",
105            up: crate::schema::migrate_v10_to_v11,
106        },
107    ]
108}
109
110/// Return the code's compile-time target schema version.
111pub fn target_version() -> i64 {
112    KIMETSU_SCHEMA_VERSION
113}
114
115/// Read the current schema version stored in `schema_info`.
116pub fn current_version(conn: &Connection) -> KimetsuResult<i64> {
117    Ok(conn.query_row(
118        "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
119        [],
120        |row| row.get(0),
121    )?)
122}
123
124/// Public entrypoint: migrate `conn` up to the binary's target version.
125pub fn run_migrations(conn: &Connection) -> KimetsuResult<MigrationOutcome> {
126    run_with(conn, migrations(), target_version())
127}
128
129// ---------------------------------------------------------------------------
130// Helpers
131// ---------------------------------------------------------------------------
132
133/// Resolve the filesystem path of `conn`'s main database file, if any.
134/// Returns `None` for in-memory (`:memory:`) and anonymous temp DBs.
135fn db_file_path(conn: &Connection) -> Option<PathBuf> {
136    match conn.path() {
137        Some(p) if !p.is_empty() && p != ":memory:" => Some(PathBuf::from(p)),
138        _ => None,
139    }
140}
141
142/// Return the number of rows in the `memories` table, or 0 if the table does
143/// not yet exist (e.g. a synthetic / partially-initialized DB).  Defensive:
144/// never panics; query errors silently map to 0.
145fn durable_row_count(conn: &Connection) -> i64 {
146    conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get::<_, i64>(0))
147        .unwrap_or(0)
148}
149
150fn unique_default_backup_path(candidate: PathBuf) -> PathBuf {
151    if !candidate.exists() {
152        return candidate;
153    }
154    let (Some(parent), Some(file_name)) = (
155        candidate.parent(),
156        candidate.file_name().and_then(|n| n.to_str()),
157    ) else {
158        return candidate;
159    };
160    for suffix in 1..1000 {
161        let next = parent.join(format!("{file_name}-{suffix}"));
162        if !next.exists() {
163            return next;
164        }
165    }
166    candidate
167}
168
169/// Snapshot the live DB before a version-advancing migration.  Returns the
170/// sidecar path, or `None` for an in-memory DB (nothing to back up) or when
171/// the DB contains zero memories (fresh install — nothing worth protecting).
172///
173/// Uses SQLite's online backup API for a consistent copy that respects WAL.
174/// The sidecar is placed next to the source DB and named:
175///   `<db-filename>.bak-<from>-<to>-<unix_nanos>`
176fn backup_before_migrate(conn: &Connection, from: i64, to: i64) -> KimetsuResult<Option<PathBuf>> {
177    let db_path = match db_file_path(conn) {
178        Some(p) => p,
179        None => return Ok(None), // in-memory or anonymous temp DB — nothing to back up
180    };
181
182    // Skip the backup when the DB is empty — a fresh/empty brain has nothing
183    // to lose; an upgraded brain with real memories gets protected.
184    if durable_row_count(conn) == 0 {
185        return Ok(None);
186    }
187
188    let ts = SystemTime::now()
189        .duration_since(UNIX_EPOCH)
190        .map(|d| d.as_nanos())
191        .unwrap_or(0);
192
193    // Sidecar next to the DB: brain.db.bak-<from>-<to>-<unix_nanos>
194    let file_name = format!(
195        "{}.bak-{from}-{to}-{ts}",
196        db_path
197            .file_name()
198            .and_then(|n| n.to_str())
199            .unwrap_or("brain.db")
200    );
201    let dest_path = unique_default_backup_path(db_path.with_file_name(file_name));
202
203    // Online backup: open dest, copy main DB into it to completion.
204    let mut dest = Connection::open(&dest_path)?;
205    let backup = rusqlite::backup::Backup::new(conn, &mut dest)?;
206    // pages_per_step must be > 0 (asserted by rusqlite); use 64.
207    // pause_between_pages = 0ms since we want a fast single-shot backup.
208    backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
209    drop(backup);
210
211    Ok(Some(dest_path))
212}
213
214/// Keep the newest `keep` `<stem>.bak-*` sidecars next to `db_path`; delete
215/// older ones.  Sorts candidates by the trailing `<ts>` integer parsed from
216/// the filename (not mtime), which is both deterministic in tests and
217/// monotonic in production since `<ts>` is the creation unix time.
218///
219/// Best-effort: filesystem errors while pruning are swallowed (we never fail
220/// a migration over cleanup).
221fn prune_backups(db_path: &Path, keep: usize) {
222    let (Some(dir), Some(stem)) = (
223        db_path.parent(),
224        db_path.file_name().and_then(|n| n.to_str()),
225    ) else {
226        return;
227    };
228
229    let prefix = format!("{stem}.bak-");
230
231    let mut backups: Vec<PathBuf> = match std::fs::read_dir(dir) {
232        Ok(rd) => rd
233            .filter_map(|e| e.ok().map(|e| e.path()))
234            .filter(|p| {
235                p.file_name()
236                    .and_then(|n| n.to_str())
237                    .map(|n| n.starts_with(&prefix))
238                    .unwrap_or(false)
239            })
240            .collect(),
241        Err(_) => return,
242    };
243
244    if backups.len() <= keep {
245        return;
246    }
247
248    // Sort newest-first by the trailing numeric `<ts>` parsed from the
249    // filename.  This is deterministic in tests and monotonic in production.
250    backups.sort_by_key(|p| {
251        Reverse(
252            p.file_name()
253                .and_then(|n| n.to_str())
254                .and_then(|n| n.rsplit('-').next())
255                .and_then(|ts| ts.parse::<u64>().ok())
256                .unwrap_or(0),
257        )
258    });
259
260    for old in backups.into_iter().skip(keep) {
261        let _ = std::fs::remove_file(old);
262    }
263}
264
265// ---------------------------------------------------------------------------
266// Core runner
267// ---------------------------------------------------------------------------
268
269/// Injectable core (test seam): apply `migs` to advance `conn` to `target`.
270///
271/// Each migration runs inside its own transaction; the `schema_info` version
272/// bump is committed in the SAME transaction as the migration DDL, so a
273/// crash between migrations leaves the DB at a cleanly-stamped intermediate
274/// version rather than an ambiguous half-applied state.
275pub(crate) fn run_with(
276    conn: &Connection,
277    migs: &[Migration],
278    target: i64,
279) -> KimetsuResult<MigrationOutcome> {
280    // Invariant: each step advances exactly one version and every step is ≤ target.
281    debug_assert!(
282        migs.windows(2).all(|w| w[1].version == w[0].version + 1),
283        "migrations must be strictly ascending and contiguous"
284    );
285    debug_assert!(
286        migs.iter().all(|m| m.version <= target),
287        "no migration may exceed the target version"
288    );
289
290    let current = current_version(conn)?;
291
292    if current == target {
293        return Ok(MigrationOutcome {
294            from: current,
295            to: current,
296            applied: Vec::new(),
297            backup_path: None,
298        });
299    }
300
301    if current > target {
302        return Err(format!(
303            "brain.db schema version {current} was written by a newer Kimetsu \
304             (this binary expects {target}); upgrade Kimetsu"
305        )
306        .into());
307    }
308
309    // current < target — snapshot before we touch anything.
310    let backup_path = backup_before_migrate(conn, current, target)?;
311
312    let mut applied = Vec::new();
313
314    for m in migs
315        .iter()
316        .filter(|m| m.version > current && m.version <= target)
317    {
318        // Run the migration DDL and the version bump inside one IMMEDIATE
319        // transaction: the write lock is taken at BEGIN so a crash mid-step is
320        // fully rolled back, AND two processes opening the same stale brain.db
321        // at once cannot double-apply a step (the second waits, then the
322        // under-lock re-check below sees the bumped version and skips).
323        conn.execute_batch("BEGIN IMMEDIATE")?;
324
325        let result = (|| -> KimetsuResult<bool> {
326            // Re-check under the write lock — a concurrent migrator may have
327            // already applied this step while we waited for the lock.
328            if m.version <= current_version(conn)? {
329                return Ok(false); // already applied; skip
330            }
331            (m.up)(conn)?;
332            conn.execute(
333                "UPDATE schema_info SET value = ?1 WHERE key = 'kimetsu_schema_version'",
334                [m.version],
335            )?;
336            Ok(true)
337        })();
338
339        match result {
340            Ok(did_apply) => {
341                conn.execute_batch("COMMIT")?;
342                if did_apply {
343                    applied.push(m.version);
344                }
345            }
346            Err(e) => {
347                let _ = conn.execute_batch("ROLLBACK");
348                return Err(e);
349            }
350        }
351    }
352
353    // Prune old backups (best-effort; swallows errors).
354    if let Some(ref bp) = backup_path {
355        if let Some(parent) = bp.parent() {
356            let db_ref = db_file_path(conn).unwrap_or_else(|| parent.join("brain.db"));
357            prune_backups(&db_ref, 3);
358        }
359    }
360
361    // Emit a structured trace event so operators using RUST_LOG can see
362    // when a migration ran without spamming every fresh-install stdout.
363    if !applied.is_empty() {
364        tracing::info!(
365            from = current,
366            to = target,
367            backup = ?backup_path,
368            "migrated brain.db schema"
369        );
370    }
371
372    Ok(MigrationOutcome {
373        from: current,
374        to: target,
375        applied,
376        backup_path,
377    })
378}
379
380// ---------------------------------------------------------------------------
381// Public convenience: kimetsu brain backup
382// ---------------------------------------------------------------------------
383
384/// Write a consistent full-DB snapshot of the brain at `brain_db_path` to
385/// `dest`.  When `dest` is `None`, the snapshot is placed next to the source
386/// DB and named `<brain.db>.backup-<unix_nanos>`.
387///
388/// Uses the SQLite online backup API (same as `backup_before_migrate`) so the
389/// copy is WAL-aware and consistent even if another writer is active.
390///
391/// Returns the absolute path of the snapshot and its size in bytes.
392///
393/// # Errors
394/// Propagates IO and SQLite errors.  Does **not** swallow errors — callers
395/// should surface them to the user.
396pub fn backup_brain(
397    brain_db_path: &std::path::Path,
398    dest: Option<&std::path::Path>,
399) -> KimetsuResult<(std::path::PathBuf, u64)> {
400    let ts = SystemTime::now()
401        .duration_since(UNIX_EPOCH)
402        .map(|d| d.as_nanos())
403        .unwrap_or(0);
404
405    let dest_path = match dest {
406        Some(p) => p.to_path_buf(),
407        None => {
408            let file_name = format!(
409                "{}.backup-{ts}",
410                brain_db_path
411                    .file_name()
412                    .and_then(|n| n.to_str())
413                    .unwrap_or("brain.db")
414            );
415            unique_default_backup_path(brain_db_path.with_file_name(file_name))
416        }
417    };
418
419    // Open the source in read-only mode so we don't disturb a running brain.
420    let src = Connection::open_with_flags(
421        brain_db_path,
422        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
423    )?;
424
425    // Online backup to the destination (created or overwritten).
426    let mut dst = Connection::open(&dest_path)?;
427    let backup = rusqlite::backup::Backup::new(&src, &mut dst)?;
428    backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
429    drop(backup);
430    drop(dst);
431    drop(src);
432
433    let size = std::fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
434
435    Ok((dest_path, size))
436}
437
438// ---------------------------------------------------------------------------
439// Tests
440// ---------------------------------------------------------------------------
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use rusqlite::Connection;
446
447    /// Create an in-memory SQLite DB seeded with `schema_info` at `version`.
448    /// Deliberately does NOT call `schema::initialize` — the runner must work
449    /// against just the `schema_info` table.
450    fn make_db(version: i64) -> Connection {
451        let conn = Connection::open_in_memory().expect("open_in_memory");
452        conn.execute_batch(&format!(
453            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
454             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
455        ))
456        .expect("seed schema_info");
457        conn
458    }
459
460    /// Seed a file-based DB at `path` with `schema_info` at `version`.
461    fn make_file_db(path: &Path, version: i64) -> Connection {
462        let conn = Connection::open(path).expect("open file db");
463        conn.execute_batch(&format!(
464            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
465             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
466        ))
467        .expect("seed schema_info");
468        conn
469    }
470
471    /// Seed a file-based DB with schema_info at `version` AND one row in a
472    /// minimal `memories` table, so `durable_row_count` returns 1 and the
473    /// backup guard fires.
474    fn make_file_db_with_memory(path: &Path, version: i64) -> Connection {
475        let conn = make_file_db(path, version);
476        conn.execute_batch(
477            "CREATE TABLE memories (
478                 memory_id TEXT PRIMARY KEY,
479                 scope TEXT NOT NULL,
480                 kind TEXT NOT NULL,
481                 text TEXT NOT NULL
482             );
483             INSERT INTO memories VALUES ('test-mem-id', 'repo', 'preference', 'test memory');",
484        )
485        .expect("seed memories table");
486        conn
487    }
488
489    /// v2.6 #3: migrating a v7 brain forward adds a nullable `events.origin`
490    /// (v8) and an `events.hlc` (v9) column. Pre-existing event rows read back
491    /// with `origin = NULL` and an `hlc` backfilled from rowid so they keep their
492    /// original order (and sort before any new HLC event).
493    #[test]
494    fn migrate_v7_forward_adds_origin_and_hlc() {
495        let conn = Connection::open_in_memory().expect("open");
496        conn.execute_batch(
497            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
498             INSERT INTO schema_info VALUES ('kimetsu_schema_version', 7);
499             CREATE TABLE events (
500                 event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, ts TEXT NOT NULL,
501                 kind TEXT NOT NULL, schema_version INTEGER NOT NULL, payload_json TEXT NOT NULL);
502             INSERT INTO events VALUES
503                 ('e1','r1','2024-01-01T00:00:00Z','memory.accepted',1,'{}'),
504                 ('e2','r1','2024-01-02T00:00:00Z','memory.cited',1,'{}');",
505        )
506        .expect("seed v7 events");
507
508        let target = target_version();
509        let outcome = run_with(&conn, migrations(), target).expect("migrate v7->current");
510        assert!(outcome.applied.contains(&8), "v8 migration must apply");
511        assert!(outcome.applied.contains(&9), "v9 migration must apply");
512
513        let cols: Vec<String> = {
514            let mut stmt = conn.prepare("PRAGMA table_info(events)").unwrap();
515            stmt.query_map([], |r| r.get::<_, String>(1))
516                .unwrap()
517                .filter_map(Result::ok)
518                .collect()
519        };
520        assert!(
521            cols.iter().any(|c| c == "origin"),
522            "events.origin must exist"
523        );
524        assert!(cols.iter().any(|c| c == "hlc"), "events.hlc must exist");
525
526        // Pre-v8 rows read origin = NULL.
527        let origin: Option<String> = conn
528            .query_row("SELECT origin FROM events WHERE event_id='e1'", [], |r| {
529                r.get(0)
530            })
531            .expect("read origin");
532        assert_eq!(origin, None, "old event rows must read origin = NULL");
533
534        // HLC backfilled (wall=0 prefix) and preserves rowid order (e1 < e2).
535        let hlc1: String = conn
536            .query_row("SELECT hlc FROM events WHERE event_id='e1'", [], |r| {
537                r.get(0)
538            })
539            .expect("read hlc1");
540        let hlc2: String = conn
541            .query_row("SELECT hlc FROM events WHERE event_id='e2'", [], |r| {
542                r.get(0)
543            })
544            .expect("read hlc2");
545        assert!(
546            hlc1.starts_with("0000000000000."),
547            "backfilled wall=0: {hlc1}"
548        );
549        assert!(hlc1 < hlc2, "backfilled HLC preserves insertion order");
550    }
551
552    /// Check whether a table exists in `sqlite_master`.
553    fn table_exists(conn: &Connection, name: &str) -> bool {
554        let count: i64 = conn
555            .query_row(
556                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
557                [name],
558                |r| r.get(0),
559            )
560            .unwrap_or(0);
561        count > 0
562    }
563
564    // ------------------------------------------------------------------
565    // Test helpers: plain `fn` pointers (not closures) to satisfy
566    // `up: fn(&Connection) -> KimetsuResult<()>`.
567    // ------------------------------------------------------------------
568
569    fn up_create_m2(conn: &Connection) -> KimetsuResult<()> {
570        conn.execute_batch("CREATE TABLE IF NOT EXISTS m2 (x INTEGER);")?;
571        Ok(())
572    }
573
574    fn up_create_m3(conn: &Connection) -> KimetsuResult<()> {
575        conn.execute_batch("CREATE TABLE IF NOT EXISTS m3 (x INTEGER);")?;
576        Ok(())
577    }
578
579    fn up_fail_partial(conn: &Connection) -> KimetsuResult<()> {
580        // Creates a table then returns an error — the table creation must be
581        // rolled back together with the version bump.
582        conn.execute_batch("CREATE TABLE IF NOT EXISTS partial_table (x INTEGER);")?;
583        Err("intentional migration failure".into())
584    }
585
586    fn up_create_t(conn: &Connection) -> KimetsuResult<()> {
587        conn.execute_batch("CREATE TABLE IF NOT EXISTS t (x INTEGER);")?;
588        Ok(())
589    }
590
591    // ------------------------------------------------------------------
592    // 1. No-op at target
593    // ------------------------------------------------------------------
594    #[test]
595    fn noop_when_at_target() {
596        let conn = make_db(7);
597        let outcome = run_with(&conn, &[], 7).expect("run_with");
598        assert_eq!(
599            outcome,
600            MigrationOutcome {
601                from: 7,
602                to: 7,
603                applied: vec![],
604                backup_path: None,
605            }
606        );
607        // Version unchanged.
608        assert_eq!(current_version(&conn).unwrap(), 7);
609    }
610
611    // ------------------------------------------------------------------
612    // 2. Forward-only guard: stored > target → Err, version unchanged
613    // ------------------------------------------------------------------
614    #[test]
615    fn rejects_newer_db() {
616        let conn = make_db(999);
617        let err = run_with(&conn, &[], 1).expect_err("should error on newer DB");
618        let msg = err.to_string();
619        assert!(
620            msg.contains("newer"),
621            "error message should mention 'newer', got: {msg}"
622        );
623        // DB version must be untouched.
624        assert_eq!(current_version(&conn).unwrap(), 999);
625    }
626
627    // ------------------------------------------------------------------
628    // 3. Apply migration: advances version, runs DDL in-txn
629    // ------------------------------------------------------------------
630    #[test]
631    fn applies_single_migration() {
632        let conn = make_db(1);
633        let migs = [Migration {
634            version: 2,
635            description: "create m2",
636            up: up_create_m2,
637        }];
638        let outcome = run_with(&conn, &migs, 2).expect("run_with");
639        assert_eq!(outcome.from, 1);
640        assert_eq!(outcome.to, 2);
641        assert_eq!(outcome.applied, vec![2]);
642        // in-memory — no backup
643        assert!(outcome.backup_path.is_none());
644        // Version bumped in DB.
645        assert_eq!(current_version(&conn).unwrap(), 2);
646        // DDL applied.
647        assert!(table_exists(&conn, "m2"), "m2 table should exist");
648    }
649
650    // ------------------------------------------------------------------
651    // 4. Idempotent re-run (current == target → no-op)
652    // ------------------------------------------------------------------
653    #[test]
654    fn idempotent_rerun() {
655        let conn = make_db(1);
656        let migs = [Migration {
657            version: 2,
658            description: "create m2",
659            up: up_create_m2,
660        }];
661        // First run.
662        run_with(&conn, &migs, 2).expect("first run");
663        // Second run — must be a no-op.
664        let outcome = run_with(&conn, &migs, 2).expect("second run");
665        assert_eq!(
666            outcome.applied,
667            Vec::<i64>::new(),
668            "second run must apply nothing"
669        );
670        assert_eq!(current_version(&conn).unwrap(), 2);
671    }
672
673    // ------------------------------------------------------------------
674    // 5. Rollback on failing up: version and DDL both rolled back
675    // ------------------------------------------------------------------
676    #[test]
677    fn rollback_on_failing_migration() {
678        let conn = make_db(1);
679        let migs = [Migration {
680            version: 2,
681            description: "fail",
682            up: up_fail_partial,
683        }];
684        let err = run_with(&conn, &migs, 2).expect_err("should propagate migration error");
685        assert!(
686            err.to_string().contains("intentional"),
687            "propagated error should contain original message, got: {err}"
688        );
689        // Version must still be 1.
690        assert_eq!(
691            current_version(&conn).unwrap(),
692            1,
693            "version must be unchanged after rollback"
694        );
695        // The partial DDL (partial_table) must NOT exist — the txn was rolled back.
696        assert!(
697            !table_exists(&conn, "partial_table"),
698            "partial_table must not exist after rollback"
699        );
700    }
701
702    // ------------------------------------------------------------------
703    // 6. Multi-step chain: applies all steps in order
704    // ------------------------------------------------------------------
705    #[test]
706    fn multi_step_chain() {
707        let conn = make_db(1);
708        let migs = [
709            Migration {
710                version: 2,
711                description: "create m2",
712                up: up_create_m2,
713            },
714            Migration {
715                version: 3,
716                description: "create m3",
717                up: up_create_m3,
718            },
719        ];
720        let outcome = run_with(&conn, &migs, 3).expect("run_with");
721        assert_eq!(outcome.from, 1);
722        assert_eq!(outcome.to, 3);
723        assert_eq!(outcome.applied, vec![2, 3]);
724        assert_eq!(current_version(&conn).unwrap(), 3);
725        assert!(table_exists(&conn, "m2"), "m2 should exist");
726        assert!(table_exists(&conn, "m3"), "m3 should exist");
727    }
728
729    // ------------------------------------------------------------------
730    // A4-1. Backup created + stamped at pre-migration version (file DB)
731    // ------------------------------------------------------------------
732    #[test]
733    fn backup_created_for_file_db() {
734        let tmp_id = std::time::SystemTime::now()
735            .duration_since(std::time::UNIX_EPOCH)
736            .map(|d| d.as_nanos())
737            .unwrap_or(0);
738        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-{tmp_id}"));
739        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
740
741        let db_path = tmp_dir.join("brain.db");
742        {
743            // Seed a memories row so durable_row_count > 0 and the backup fires.
744            let conn = make_file_db_with_memory(&db_path, 1);
745
746            let migs = [Migration {
747                version: 2,
748                description: "create t",
749                up: up_create_t,
750            }];
751
752            let outcome = run_with(&conn, &migs, 2).expect("run_with");
753
754            // Backup must be Some and the file must exist on disk.
755            let bak_path = outcome
756                .backup_path
757                .expect("backup_path should be Some for file DB");
758            assert!(
759                bak_path.exists(),
760                "backup file should exist at {bak_path:?}"
761            );
762
763            // Filename must match pattern brain.db.bak-1-2-*
764            let bak_name = bak_path
765                .file_name()
766                .and_then(|n| n.to_str())
767                .expect("backup has a filename");
768            assert!(
769                bak_name.starts_with("brain.db.bak-1-2-"),
770                "backup name should be brain.db.bak-1-2-<ts>, got: {bak_name}"
771            );
772
773            // The backup must reflect PRE-migration state (version = 1).
774            let bak_conn = Connection::open(&bak_path).expect("open backup db");
775            let bak_version: i64 = bak_conn
776                .query_row(
777                    "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
778                    [],
779                    |r| r.get(0),
780                )
781                .expect("read backup version");
782            assert_eq!(
783                bak_version, 1,
784                "backup should capture pre-migration version 1"
785            );
786
787            // Live DB must now be at version 2.
788            assert_eq!(current_version(&conn).unwrap(), 2);
789        }
790
791        let _ = std::fs::remove_dir_all(&tmp_dir);
792    }
793
794    // ------------------------------------------------------------------
795    // A4-2. In-memory DB → no backup
796    // ------------------------------------------------------------------
797    #[test]
798    fn no_backup_for_in_memory_db() {
799        let conn = make_db(1);
800        let migs = [Migration {
801            version: 2,
802            description: "create t",
803            up: up_create_t,
804        }];
805        let outcome = run_with(&conn, &migs, 2).expect("run_with");
806        assert!(
807            outcome.backup_path.is_none(),
808            "in-memory DB must not produce a backup"
809        );
810        // Migration must still have been applied.
811        assert_eq!(current_version(&conn).unwrap(), 2);
812        assert!(table_exists(&conn, "t"), "table t should exist");
813    }
814
815    // ------------------------------------------------------------------
816    // A4-3. No-op at target → no backup created
817    // ------------------------------------------------------------------
818    #[test]
819    fn no_backup_for_noop() {
820        let tmp_id = std::time::SystemTime::now()
821            .duration_since(std::time::UNIX_EPOCH)
822            .map(|d| d.as_nanos())
823            .unwrap_or(0);
824        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-noop-{tmp_id}"));
825        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
826
827        let db_path = tmp_dir.join("brain.db");
828        {
829            let conn = make_file_db(&db_path, 2);
830            let outcome = run_with(&conn, &[], 2).expect("run_with");
831
832            assert!(
833                outcome.backup_path.is_none(),
834                "no-op run must not produce a backup"
835            );
836
837            // No .bak-* files should exist in the directory.
838            let bak_files: Vec<_> = std::fs::read_dir(&tmp_dir)
839                .expect("read_dir")
840                .filter_map(|e| e.ok())
841                .filter(|e| {
842                    e.file_name()
843                        .to_str()
844                        .map(|n| n.contains(".bak-"))
845                        .unwrap_or(false)
846                })
847                .collect();
848            assert!(
849                bak_files.is_empty(),
850                "no backup files should exist after no-op, found: {bak_files:?}"
851            );
852        }
853
854        let _ = std::fs::remove_dir_all(&tmp_dir);
855    }
856
857    // ------------------------------------------------------------------
858    // A4-4. Retention keep-3: prune_backups removes oldest, keeps 3 newest
859    // ------------------------------------------------------------------
860    #[test]
861    fn retention_keep_3() {
862        let tmp_id = std::time::SystemTime::now()
863            .duration_since(std::time::UNIX_EPOCH)
864            .map(|d| d.as_nanos())
865            .unwrap_or(0);
866        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-retention-{tmp_id}"));
867        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
868
869        // Create 4 fake sidecar files with distinct trailing timestamps.
870        // prune_backups sorts by the trailing <ts> integer, so the timestamps
871        // in the filenames drive the ordering — mtime is irrelevant.
872        let sidecar_names = [
873            "brain.db.bak-1-2-1000",
874            "brain.db.bak-1-2-2000",
875            "brain.db.bak-1-2-3000",
876            "brain.db.bak-1-2-4000",
877        ];
878        for name in &sidecar_names {
879            let p = tmp_dir.join(name);
880            std::fs::write(&p, b"fake backup").expect("write fake sidecar");
881        }
882
883        let db_path = tmp_dir.join("brain.db");
884        prune_backups(&db_path, 3);
885
886        // Count surviving .bak-* files.
887        let remaining: Vec<_> = std::fs::read_dir(&tmp_dir)
888            .expect("read_dir")
889            .filter_map(|e| e.ok())
890            .filter(|e| {
891                e.file_name()
892                    .to_str()
893                    .map(|n| n.starts_with("brain.db.bak-"))
894                    .unwrap_or(false)
895            })
896            .map(|e| e.file_name().to_str().unwrap_or("").to_owned())
897            .collect();
898
899        assert_eq!(
900            remaining.len(),
901            3,
902            "exactly 3 backups should remain after pruning, found: {remaining:?}"
903        );
904
905        // The oldest one (ts=1000) must have been deleted.
906        assert!(
907            !tmp_dir.join("brain.db.bak-1-2-1000").exists(),
908            "oldest backup (ts=1000) should have been pruned"
909        );
910        // The 3 newest must survive.
911        assert!(
912            tmp_dir.join("brain.db.bak-1-2-2000").exists(),
913            "backup ts=2000 should survive"
914        );
915        assert!(
916            tmp_dir.join("brain.db.bak-1-2-3000").exists(),
917            "backup ts=3000 should survive"
918        );
919        assert!(
920            tmp_dir.join("brain.db.bak-1-2-4000").exists(),
921            "backup ts=4000 should survive"
922        );
923
924        let _ = std::fs::remove_dir_all(&tmp_dir);
925    }
926
927    // ------------------------------------------------------------------
928    // backup_brain tests
929    // ------------------------------------------------------------------
930
931    /// Seed a minimal fully-initialized brain DB (schema_info + memories table
932    /// with one row) at `path` and return its connection.
933    fn make_full_brain_db(path: &Path) -> Connection {
934        let conn = Connection::open(path).expect("open brain db");
935        // Minimal schema enough for backup_brain to copy.
936        conn.execute_batch(&format!(
937            "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
938             INSERT INTO schema_info VALUES ('kimetsu_schema_version', {});
939             CREATE TABLE memories (
940                 memory_id TEXT PRIMARY KEY,
941                 scope TEXT NOT NULL,
942                 kind TEXT NOT NULL,
943                 text TEXT NOT NULL
944             );
945             INSERT INTO memories VALUES ('bk-mem-1', 'repo', 'fact', 'backup test memory');",
946            target_version(),
947        ))
948        .expect("seed brain db");
949        conn
950    }
951
952    #[test]
953    fn backup_brain_default_path_exists_and_valid() {
954        let tmp_id = std::time::SystemTime::now()
955            .duration_since(std::time::UNIX_EPOCH)
956            .map(|d| d.as_nanos())
957            .unwrap_or(0);
958        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain-{tmp_id}"));
959        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
960
961        let db_path = tmp_dir.join("brain.db");
962        {
963            let _conn = make_full_brain_db(&db_path);
964        } // close connection before backup_brain opens it read-only
965
966        let (dest, size) = backup_brain(&db_path, None).expect("backup_brain");
967
968        // Path must exist.
969        assert!(dest.exists(), "backup file should exist at {dest:?}");
970        // Must be non-empty.
971        assert!(size > 0, "backup size should be > 0, got {size}");
972        // Name must follow the pattern brain.db.backup-<ts>.
973        let name = dest
974            .file_name()
975            .and_then(|n| n.to_str())
976            .expect("backup has a filename");
977        assert!(
978            name.starts_with("brain.db.backup-"),
979            "backup name should start with 'brain.db.backup-', got: {name}"
980        );
981
982        // Must be a valid SQLite DB with the expected memory count.
983        let bak_conn = Connection::open(&dest).expect("open backup");
984        let count: i64 = bak_conn
985            .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
986            .expect("count memories in backup");
987        assert_eq!(count, 1, "backup should contain 1 memory row");
988
989        let _ = std::fs::remove_dir_all(&tmp_dir);
990    }
991
992    #[test]
993    fn backup_brain_default_path_does_not_overwrite_existing_backup() {
994        let tmp_id = std::time::SystemTime::now()
995            .duration_since(std::time::UNIX_EPOCH)
996            .map(|d| d.as_nanos())
997            .unwrap_or(0);
998        let tmp_dir =
999            std::env::temp_dir().join(format!("kimetsu-test-backup-brain-unique-{tmp_id}"));
1000        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1001
1002        let db_path = tmp_dir.join("brain.db");
1003        {
1004            let _conn = make_full_brain_db(&db_path);
1005        }
1006
1007        let (first, _) = backup_brain(&db_path, None).expect("first backup");
1008        let (second, _) = backup_brain(&db_path, None).expect("second backup");
1009
1010        assert_ne!(first, second, "default backups must not overwrite");
1011        assert!(first.exists(), "first backup should still exist");
1012        assert!(second.exists(), "second backup should exist");
1013
1014        let _ = std::fs::remove_dir_all(&tmp_dir);
1015    }
1016
1017    #[test]
1018    fn backup_brain_custom_path() {
1019        let tmp_id = std::time::SystemTime::now()
1020            .duration_since(std::time::UNIX_EPOCH)
1021            .map(|d| d.as_nanos())
1022            .unwrap_or(0);
1023        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain2-{tmp_id}"));
1024        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1025
1026        let db_path = tmp_dir.join("brain.db");
1027        let custom = tmp_dir.join("my-custom-backup.db");
1028        {
1029            let _conn = make_full_brain_db(&db_path);
1030        }
1031
1032        let (dest, size) = backup_brain(&db_path, Some(&custom)).expect("backup_brain custom");
1033
1034        assert_eq!(dest, custom, "dest should be the custom path");
1035        assert!(custom.exists(), "custom backup file should exist");
1036        assert!(size > 0);
1037
1038        // Valid SQLite with the expected row.
1039        let bak_conn = Connection::open(&custom).expect("open custom backup");
1040        let count: i64 = bak_conn
1041            .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1042            .expect("count memories in custom backup");
1043        assert_eq!(count, 1, "custom backup should contain 1 memory row");
1044
1045        let _ = std::fs::remove_dir_all(&tmp_dir);
1046    }
1047}