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