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#[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
30pub 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 pub backup_path: Option<PathBuf>,
48}
49
50fn 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
100pub fn target_version() -> i64 {
102 KIMETSU_SCHEMA_VERSION
103}
104
105pub 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
114pub fn run_migrations(conn: &Connection) -> KimetsuResult<MigrationOutcome> {
116 run_with(conn, migrations(), target_version())
117}
118
119fn 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
132fn 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
159fn 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), };
171
172 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 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 let mut dest = Connection::open(&dest_path)?;
195 let backup = rusqlite::backup::Backup::new(conn, &mut dest)?;
196 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
199 drop(backup);
200
201 Ok(Some(dest_path))
202}
203
204fn 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 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
255pub(crate) fn run_with(
266 conn: &Connection,
267 migs: &[Migration],
268 target: i64,
269) -> KimetsuResult<MigrationOutcome> {
270 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 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 conn.execute_batch("BEGIN IMMEDIATE")?;
314
315 let result = (|| -> KimetsuResult<bool> {
316 if m.version <= current_version(conn)? {
319 return Ok(false); }
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 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 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
370pub 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 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 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#[cfg(test)]
433mod tests {
434 use super::*;
435 use rusqlite::Connection;
436
437 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 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 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 #[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 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 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 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 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 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 #[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 assert_eq!(current_version(&conn).unwrap(), 7);
599 }
600
601 #[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 assert_eq!(current_version(&conn).unwrap(), 999);
615 }
616
617 #[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 assert!(outcome.backup_path.is_none());
634 assert_eq!(current_version(&conn).unwrap(), 2);
636 assert!(table_exists(&conn, "m2"), "m2 table should exist");
638 }
639
640 #[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 run_with(&conn, &migs, 2).expect("first run");
653 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 #[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 assert_eq!(
681 current_version(&conn).unwrap(),
682 1,
683 "version must be unchanged after rollback"
684 );
685 assert!(
687 !table_exists(&conn, "partial_table"),
688 "partial_table must not exist after rollback"
689 );
690 }
691
692 #[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 #[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 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 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 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 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 assert_eq!(current_version(&conn).unwrap(), 2);
779 }
780
781 let _ = std::fs::remove_dir_all(&tmp_dir);
782 }
783
784 #[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 assert_eq!(current_version(&conn).unwrap(), 2);
802 assert!(table_exists(&conn, "t"), "table t should exist");
803 }
804
805 #[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 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 #[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 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 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 assert!(
897 !tmp_dir.join("brain.db.bak-1-2-1000").exists(),
898 "oldest backup (ts=1000) should have been pruned"
899 );
900 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 fn make_full_brain_db(path: &Path) -> Connection {
924 let conn = Connection::open(path).expect("open brain db");
925 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 } let (dest, size) = backup_brain(&db_path, None).expect("backup_brain");
957
958 assert!(dest.exists(), "backup file should exist at {dest:?}");
960 assert!(size > 0, "backup size should be > 0, got {size}");
962 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 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 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}