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