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