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 (v2.6 #3 fleet write-safety / provenance)",
90 up: crate::schema::migrate_v7_to_v8,
91 },
92 Migration {
93 version: 9,
94 description: "add per-event HLC column + backfill (v2.6 #3 Slice B convergent team sync)",
95 up: crate::schema::migrate_v8_to_v9,
96 },
97 Migration {
98 version: 10,
99 description: "add memory_citations.query + query_routes table (v2.5.2 consolidation v1)",
100 up: crate::schema::migrate_v9_to_v10,
101 },
102 Migration {
103 version: 11,
104 description: "add memory_entities projection (v2.6 first-class tags + ingest-time edges)",
105 up: crate::schema::migrate_v10_to_v11,
106 },
107 ]
108}
109
110pub fn target_version() -> i64 {
112 KIMETSU_SCHEMA_VERSION
113}
114
115pub fn current_version(conn: &Connection) -> KimetsuResult<i64> {
117 Ok(conn.query_row(
118 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
119 [],
120 |row| row.get(0),
121 )?)
122}
123
124pub fn run_migrations(conn: &Connection) -> KimetsuResult<MigrationOutcome> {
126 run_with(conn, migrations(), target_version())
127}
128
129fn db_file_path(conn: &Connection) -> Option<PathBuf> {
136 match conn.path() {
137 Some(p) if !p.is_empty() && p != ":memory:" => Some(PathBuf::from(p)),
138 _ => None,
139 }
140}
141
142fn durable_row_count(conn: &Connection) -> i64 {
146 conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get::<_, i64>(0))
147 .unwrap_or(0)
148}
149
150fn unique_default_backup_path(candidate: PathBuf) -> PathBuf {
151 if !candidate.exists() {
152 return candidate;
153 }
154 let (Some(parent), Some(file_name)) = (
155 candidate.parent(),
156 candidate.file_name().and_then(|n| n.to_str()),
157 ) else {
158 return candidate;
159 };
160 for suffix in 1..1000 {
161 let next = parent.join(format!("{file_name}-{suffix}"));
162 if !next.exists() {
163 return next;
164 }
165 }
166 candidate
167}
168
169fn backup_before_migrate(conn: &Connection, from: i64, to: i64) -> KimetsuResult<Option<PathBuf>> {
177 let db_path = match db_file_path(conn) {
178 Some(p) => p,
179 None => return Ok(None), };
181
182 if durable_row_count(conn) == 0 {
185 return Ok(None);
186 }
187
188 let ts = SystemTime::now()
189 .duration_since(UNIX_EPOCH)
190 .map(|d| d.as_nanos())
191 .unwrap_or(0);
192
193 let file_name = format!(
195 "{}.bak-{from}-{to}-{ts}",
196 db_path
197 .file_name()
198 .and_then(|n| n.to_str())
199 .unwrap_or("brain.db")
200 );
201 let dest_path = unique_default_backup_path(db_path.with_file_name(file_name));
202
203 let mut dest = Connection::open(&dest_path)?;
205 let backup = rusqlite::backup::Backup::new(conn, &mut dest)?;
206 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
209 drop(backup);
210
211 Ok(Some(dest_path))
212}
213
214fn prune_backups(db_path: &Path, keep: usize) {
222 let (Some(dir), Some(stem)) = (
223 db_path.parent(),
224 db_path.file_name().and_then(|n| n.to_str()),
225 ) else {
226 return;
227 };
228
229 let prefix = format!("{stem}.bak-");
230
231 let mut backups: Vec<PathBuf> = match std::fs::read_dir(dir) {
232 Ok(rd) => rd
233 .filter_map(|e| e.ok().map(|e| e.path()))
234 .filter(|p| {
235 p.file_name()
236 .and_then(|n| n.to_str())
237 .map(|n| n.starts_with(&prefix))
238 .unwrap_or(false)
239 })
240 .collect(),
241 Err(_) => return,
242 };
243
244 if backups.len() <= keep {
245 return;
246 }
247
248 backups.sort_by_key(|p| {
251 Reverse(
252 p.file_name()
253 .and_then(|n| n.to_str())
254 .and_then(|n| n.rsplit('-').next())
255 .and_then(|ts| ts.parse::<u64>().ok())
256 .unwrap_or(0),
257 )
258 });
259
260 for old in backups.into_iter().skip(keep) {
261 let _ = std::fs::remove_file(old);
262 }
263}
264
265pub(crate) fn run_with(
276 conn: &Connection,
277 migs: &[Migration],
278 target: i64,
279) -> KimetsuResult<MigrationOutcome> {
280 debug_assert!(
282 migs.windows(2).all(|w| w[1].version == w[0].version + 1),
283 "migrations must be strictly ascending and contiguous"
284 );
285 debug_assert!(
286 migs.iter().all(|m| m.version <= target),
287 "no migration may exceed the target version"
288 );
289
290 let current = current_version(conn)?;
291
292 if current == target {
293 return Ok(MigrationOutcome {
294 from: current,
295 to: current,
296 applied: Vec::new(),
297 backup_path: None,
298 });
299 }
300
301 if current > target {
302 return Err(format!(
303 "brain.db schema version {current} was written by a newer Kimetsu \
304 (this binary expects {target}); upgrade Kimetsu"
305 )
306 .into());
307 }
308
309 let backup_path = backup_before_migrate(conn, current, target)?;
311
312 let mut applied = Vec::new();
313
314 for m in migs
315 .iter()
316 .filter(|m| m.version > current && m.version <= target)
317 {
318 conn.execute_batch("BEGIN IMMEDIATE")?;
324
325 let result = (|| -> KimetsuResult<bool> {
326 if m.version <= current_version(conn)? {
329 return Ok(false); }
331 (m.up)(conn)?;
332 conn.execute(
333 "UPDATE schema_info SET value = ?1 WHERE key = 'kimetsu_schema_version'",
334 [m.version],
335 )?;
336 Ok(true)
337 })();
338
339 match result {
340 Ok(did_apply) => {
341 conn.execute_batch("COMMIT")?;
342 if did_apply {
343 applied.push(m.version);
344 }
345 }
346 Err(e) => {
347 let _ = conn.execute_batch("ROLLBACK");
348 return Err(e);
349 }
350 }
351 }
352
353 if let Some(ref bp) = backup_path {
355 if let Some(parent) = bp.parent() {
356 let db_ref = db_file_path(conn).unwrap_or_else(|| parent.join("brain.db"));
357 prune_backups(&db_ref, 3);
358 }
359 }
360
361 if !applied.is_empty() {
364 tracing::info!(
365 from = current,
366 to = target,
367 backup = ?backup_path,
368 "migrated brain.db schema"
369 );
370 }
371
372 Ok(MigrationOutcome {
373 from: current,
374 to: target,
375 applied,
376 backup_path,
377 })
378}
379
380pub fn backup_brain(
397 brain_db_path: &std::path::Path,
398 dest: Option<&std::path::Path>,
399) -> KimetsuResult<(std::path::PathBuf, u64)> {
400 let ts = SystemTime::now()
401 .duration_since(UNIX_EPOCH)
402 .map(|d| d.as_nanos())
403 .unwrap_or(0);
404
405 let dest_path = match dest {
406 Some(p) => p.to_path_buf(),
407 None => {
408 let file_name = format!(
409 "{}.backup-{ts}",
410 brain_db_path
411 .file_name()
412 .and_then(|n| n.to_str())
413 .unwrap_or("brain.db")
414 );
415 unique_default_backup_path(brain_db_path.with_file_name(file_name))
416 }
417 };
418
419 let src = Connection::open_with_flags(
421 brain_db_path,
422 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
423 )?;
424
425 let mut dst = Connection::open(&dest_path)?;
427 let backup = rusqlite::backup::Backup::new(&src, &mut dst)?;
428 backup.run_to_completion(64, std::time::Duration::from_millis(0), None)?;
429 drop(backup);
430 drop(dst);
431 drop(src);
432
433 let size = std::fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
434
435 Ok((dest_path, size))
436}
437
438#[cfg(test)]
443mod tests {
444 use super::*;
445 use rusqlite::Connection;
446
447 fn make_db(version: i64) -> Connection {
451 let conn = Connection::open_in_memory().expect("open_in_memory");
452 conn.execute_batch(&format!(
453 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
454 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
455 ))
456 .expect("seed schema_info");
457 conn
458 }
459
460 fn make_file_db(path: &Path, version: i64) -> Connection {
462 let conn = Connection::open(path).expect("open file db");
463 conn.execute_batch(&format!(
464 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
465 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
466 ))
467 .expect("seed schema_info");
468 conn
469 }
470
471 fn make_file_db_with_memory(path: &Path, version: i64) -> Connection {
475 let conn = make_file_db(path, version);
476 conn.execute_batch(
477 "CREATE TABLE memories (
478 memory_id TEXT PRIMARY KEY,
479 scope TEXT NOT NULL,
480 kind TEXT NOT NULL,
481 text TEXT NOT NULL
482 );
483 INSERT INTO memories VALUES ('test-mem-id', 'repo', 'preference', 'test memory');",
484 )
485 .expect("seed memories table");
486 conn
487 }
488
489 #[test]
494 fn migrate_v7_forward_adds_origin_and_hlc() {
495 let conn = Connection::open_in_memory().expect("open");
496 conn.execute_batch(
497 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
498 INSERT INTO schema_info VALUES ('kimetsu_schema_version', 7);
499 CREATE TABLE events (
500 event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, ts TEXT NOT NULL,
501 kind TEXT NOT NULL, schema_version INTEGER NOT NULL, payload_json TEXT NOT NULL);
502 INSERT INTO events VALUES
503 ('e1','r1','2024-01-01T00:00:00Z','memory.accepted',1,'{}'),
504 ('e2','r1','2024-01-02T00:00:00Z','memory.cited',1,'{}');",
505 )
506 .expect("seed v7 events");
507
508 let target = target_version();
509 let outcome = run_with(&conn, migrations(), target).expect("migrate v7->current");
510 assert!(outcome.applied.contains(&8), "v8 migration must apply");
511 assert!(outcome.applied.contains(&9), "v9 migration must apply");
512
513 let cols: Vec<String> = {
514 let mut stmt = conn.prepare("PRAGMA table_info(events)").unwrap();
515 stmt.query_map([], |r| r.get::<_, String>(1))
516 .unwrap()
517 .filter_map(Result::ok)
518 .collect()
519 };
520 assert!(
521 cols.iter().any(|c| c == "origin"),
522 "events.origin must exist"
523 );
524 assert!(cols.iter().any(|c| c == "hlc"), "events.hlc must exist");
525
526 let origin: Option<String> = conn
528 .query_row("SELECT origin FROM events WHERE event_id='e1'", [], |r| {
529 r.get(0)
530 })
531 .expect("read origin");
532 assert_eq!(origin, None, "old event rows must read origin = NULL");
533
534 let hlc1: String = conn
536 .query_row("SELECT hlc FROM events WHERE event_id='e1'", [], |r| {
537 r.get(0)
538 })
539 .expect("read hlc1");
540 let hlc2: String = conn
541 .query_row("SELECT hlc FROM events WHERE event_id='e2'", [], |r| {
542 r.get(0)
543 })
544 .expect("read hlc2");
545 assert!(
546 hlc1.starts_with("0000000000000."),
547 "backfilled wall=0: {hlc1}"
548 );
549 assert!(hlc1 < hlc2, "backfilled HLC preserves insertion order");
550 }
551
552 fn table_exists(conn: &Connection, name: &str) -> bool {
554 let count: i64 = conn
555 .query_row(
556 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
557 [name],
558 |r| r.get(0),
559 )
560 .unwrap_or(0);
561 count > 0
562 }
563
564 fn up_create_m2(conn: &Connection) -> KimetsuResult<()> {
570 conn.execute_batch("CREATE TABLE IF NOT EXISTS m2 (x INTEGER);")?;
571 Ok(())
572 }
573
574 fn up_create_m3(conn: &Connection) -> KimetsuResult<()> {
575 conn.execute_batch("CREATE TABLE IF NOT EXISTS m3 (x INTEGER);")?;
576 Ok(())
577 }
578
579 fn up_fail_partial(conn: &Connection) -> KimetsuResult<()> {
580 conn.execute_batch("CREATE TABLE IF NOT EXISTS partial_table (x INTEGER);")?;
583 Err("intentional migration failure".into())
584 }
585
586 fn up_create_t(conn: &Connection) -> KimetsuResult<()> {
587 conn.execute_batch("CREATE TABLE IF NOT EXISTS t (x INTEGER);")?;
588 Ok(())
589 }
590
591 #[test]
595 fn noop_when_at_target() {
596 let conn = make_db(7);
597 let outcome = run_with(&conn, &[], 7).expect("run_with");
598 assert_eq!(
599 outcome,
600 MigrationOutcome {
601 from: 7,
602 to: 7,
603 applied: vec![],
604 backup_path: None,
605 }
606 );
607 assert_eq!(current_version(&conn).unwrap(), 7);
609 }
610
611 #[test]
615 fn rejects_newer_db() {
616 let conn = make_db(999);
617 let err = run_with(&conn, &[], 1).expect_err("should error on newer DB");
618 let msg = err.to_string();
619 assert!(
620 msg.contains("newer"),
621 "error message should mention 'newer', got: {msg}"
622 );
623 assert_eq!(current_version(&conn).unwrap(), 999);
625 }
626
627 #[test]
631 fn applies_single_migration() {
632 let conn = make_db(1);
633 let migs = [Migration {
634 version: 2,
635 description: "create m2",
636 up: up_create_m2,
637 }];
638 let outcome = run_with(&conn, &migs, 2).expect("run_with");
639 assert_eq!(outcome.from, 1);
640 assert_eq!(outcome.to, 2);
641 assert_eq!(outcome.applied, vec![2]);
642 assert!(outcome.backup_path.is_none());
644 assert_eq!(current_version(&conn).unwrap(), 2);
646 assert!(table_exists(&conn, "m2"), "m2 table should exist");
648 }
649
650 #[test]
654 fn idempotent_rerun() {
655 let conn = make_db(1);
656 let migs = [Migration {
657 version: 2,
658 description: "create m2",
659 up: up_create_m2,
660 }];
661 run_with(&conn, &migs, 2).expect("first run");
663 let outcome = run_with(&conn, &migs, 2).expect("second run");
665 assert_eq!(
666 outcome.applied,
667 Vec::<i64>::new(),
668 "second run must apply nothing"
669 );
670 assert_eq!(current_version(&conn).unwrap(), 2);
671 }
672
673 #[test]
677 fn rollback_on_failing_migration() {
678 let conn = make_db(1);
679 let migs = [Migration {
680 version: 2,
681 description: "fail",
682 up: up_fail_partial,
683 }];
684 let err = run_with(&conn, &migs, 2).expect_err("should propagate migration error");
685 assert!(
686 err.to_string().contains("intentional"),
687 "propagated error should contain original message, got: {err}"
688 );
689 assert_eq!(
691 current_version(&conn).unwrap(),
692 1,
693 "version must be unchanged after rollback"
694 );
695 assert!(
697 !table_exists(&conn, "partial_table"),
698 "partial_table must not exist after rollback"
699 );
700 }
701
702 #[test]
706 fn multi_step_chain() {
707 let conn = make_db(1);
708 let migs = [
709 Migration {
710 version: 2,
711 description: "create m2",
712 up: up_create_m2,
713 },
714 Migration {
715 version: 3,
716 description: "create m3",
717 up: up_create_m3,
718 },
719 ];
720 let outcome = run_with(&conn, &migs, 3).expect("run_with");
721 assert_eq!(outcome.from, 1);
722 assert_eq!(outcome.to, 3);
723 assert_eq!(outcome.applied, vec![2, 3]);
724 assert_eq!(current_version(&conn).unwrap(), 3);
725 assert!(table_exists(&conn, "m2"), "m2 should exist");
726 assert!(table_exists(&conn, "m3"), "m3 should exist");
727 }
728
729 #[test]
733 fn backup_created_for_file_db() {
734 let tmp_id = std::time::SystemTime::now()
735 .duration_since(std::time::UNIX_EPOCH)
736 .map(|d| d.as_nanos())
737 .unwrap_or(0);
738 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-{tmp_id}"));
739 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
740
741 let db_path = tmp_dir.join("brain.db");
742 {
743 let conn = make_file_db_with_memory(&db_path, 1);
745
746 let migs = [Migration {
747 version: 2,
748 description: "create t",
749 up: up_create_t,
750 }];
751
752 let outcome = run_with(&conn, &migs, 2).expect("run_with");
753
754 let bak_path = outcome
756 .backup_path
757 .expect("backup_path should be Some for file DB");
758 assert!(
759 bak_path.exists(),
760 "backup file should exist at {bak_path:?}"
761 );
762
763 let bak_name = bak_path
765 .file_name()
766 .and_then(|n| n.to_str())
767 .expect("backup has a filename");
768 assert!(
769 bak_name.starts_with("brain.db.bak-1-2-"),
770 "backup name should be brain.db.bak-1-2-<ts>, got: {bak_name}"
771 );
772
773 let bak_conn = Connection::open(&bak_path).expect("open backup db");
775 let bak_version: i64 = bak_conn
776 .query_row(
777 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
778 [],
779 |r| r.get(0),
780 )
781 .expect("read backup version");
782 assert_eq!(
783 bak_version, 1,
784 "backup should capture pre-migration version 1"
785 );
786
787 assert_eq!(current_version(&conn).unwrap(), 2);
789 }
790
791 let _ = std::fs::remove_dir_all(&tmp_dir);
792 }
793
794 #[test]
798 fn no_backup_for_in_memory_db() {
799 let conn = make_db(1);
800 let migs = [Migration {
801 version: 2,
802 description: "create t",
803 up: up_create_t,
804 }];
805 let outcome = run_with(&conn, &migs, 2).expect("run_with");
806 assert!(
807 outcome.backup_path.is_none(),
808 "in-memory DB must not produce a backup"
809 );
810 assert_eq!(current_version(&conn).unwrap(), 2);
812 assert!(table_exists(&conn, "t"), "table t should exist");
813 }
814
815 #[test]
819 fn no_backup_for_noop() {
820 let tmp_id = std::time::SystemTime::now()
821 .duration_since(std::time::UNIX_EPOCH)
822 .map(|d| d.as_nanos())
823 .unwrap_or(0);
824 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-noop-{tmp_id}"));
825 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
826
827 let db_path = tmp_dir.join("brain.db");
828 {
829 let conn = make_file_db(&db_path, 2);
830 let outcome = run_with(&conn, &[], 2).expect("run_with");
831
832 assert!(
833 outcome.backup_path.is_none(),
834 "no-op run must not produce a backup"
835 );
836
837 let bak_files: Vec<_> = std::fs::read_dir(&tmp_dir)
839 .expect("read_dir")
840 .filter_map(|e| e.ok())
841 .filter(|e| {
842 e.file_name()
843 .to_str()
844 .map(|n| n.contains(".bak-"))
845 .unwrap_or(false)
846 })
847 .collect();
848 assert!(
849 bak_files.is_empty(),
850 "no backup files should exist after no-op, found: {bak_files:?}"
851 );
852 }
853
854 let _ = std::fs::remove_dir_all(&tmp_dir);
855 }
856
857 #[test]
861 fn retention_keep_3() {
862 let tmp_id = std::time::SystemTime::now()
863 .duration_since(std::time::UNIX_EPOCH)
864 .map(|d| d.as_nanos())
865 .unwrap_or(0);
866 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-retention-{tmp_id}"));
867 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
868
869 let sidecar_names = [
873 "brain.db.bak-1-2-1000",
874 "brain.db.bak-1-2-2000",
875 "brain.db.bak-1-2-3000",
876 "brain.db.bak-1-2-4000",
877 ];
878 for name in &sidecar_names {
879 let p = tmp_dir.join(name);
880 std::fs::write(&p, b"fake backup").expect("write fake sidecar");
881 }
882
883 let db_path = tmp_dir.join("brain.db");
884 prune_backups(&db_path, 3);
885
886 let remaining: Vec<_> = std::fs::read_dir(&tmp_dir)
888 .expect("read_dir")
889 .filter_map(|e| e.ok())
890 .filter(|e| {
891 e.file_name()
892 .to_str()
893 .map(|n| n.starts_with("brain.db.bak-"))
894 .unwrap_or(false)
895 })
896 .map(|e| e.file_name().to_str().unwrap_or("").to_owned())
897 .collect();
898
899 assert_eq!(
900 remaining.len(),
901 3,
902 "exactly 3 backups should remain after pruning, found: {remaining:?}"
903 );
904
905 assert!(
907 !tmp_dir.join("brain.db.bak-1-2-1000").exists(),
908 "oldest backup (ts=1000) should have been pruned"
909 );
910 assert!(
912 tmp_dir.join("brain.db.bak-1-2-2000").exists(),
913 "backup ts=2000 should survive"
914 );
915 assert!(
916 tmp_dir.join("brain.db.bak-1-2-3000").exists(),
917 "backup ts=3000 should survive"
918 );
919 assert!(
920 tmp_dir.join("brain.db.bak-1-2-4000").exists(),
921 "backup ts=4000 should survive"
922 );
923
924 let _ = std::fs::remove_dir_all(&tmp_dir);
925 }
926
927 fn make_full_brain_db(path: &Path) -> Connection {
934 let conn = Connection::open(path).expect("open brain db");
935 conn.execute_batch(&format!(
937 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
938 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {});
939 CREATE TABLE memories (
940 memory_id TEXT PRIMARY KEY,
941 scope TEXT NOT NULL,
942 kind TEXT NOT NULL,
943 text TEXT NOT NULL
944 );
945 INSERT INTO memories VALUES ('bk-mem-1', 'repo', 'fact', 'backup test memory');",
946 target_version(),
947 ))
948 .expect("seed brain db");
949 conn
950 }
951
952 #[test]
953 fn backup_brain_default_path_exists_and_valid() {
954 let tmp_id = std::time::SystemTime::now()
955 .duration_since(std::time::UNIX_EPOCH)
956 .map(|d| d.as_nanos())
957 .unwrap_or(0);
958 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain-{tmp_id}"));
959 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
960
961 let db_path = tmp_dir.join("brain.db");
962 {
963 let _conn = make_full_brain_db(&db_path);
964 } let (dest, size) = backup_brain(&db_path, None).expect("backup_brain");
967
968 assert!(dest.exists(), "backup file should exist at {dest:?}");
970 assert!(size > 0, "backup size should be > 0, got {size}");
972 let name = dest
974 .file_name()
975 .and_then(|n| n.to_str())
976 .expect("backup has a filename");
977 assert!(
978 name.starts_with("brain.db.backup-"),
979 "backup name should start with 'brain.db.backup-', got: {name}"
980 );
981
982 let bak_conn = Connection::open(&dest).expect("open backup");
984 let count: i64 = bak_conn
985 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
986 .expect("count memories in backup");
987 assert_eq!(count, 1, "backup should contain 1 memory row");
988
989 let _ = std::fs::remove_dir_all(&tmp_dir);
990 }
991
992 #[test]
993 fn backup_brain_default_path_does_not_overwrite_existing_backup() {
994 let tmp_id = std::time::SystemTime::now()
995 .duration_since(std::time::UNIX_EPOCH)
996 .map(|d| d.as_nanos())
997 .unwrap_or(0);
998 let tmp_dir =
999 std::env::temp_dir().join(format!("kimetsu-test-backup-brain-unique-{tmp_id}"));
1000 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1001
1002 let db_path = tmp_dir.join("brain.db");
1003 {
1004 let _conn = make_full_brain_db(&db_path);
1005 }
1006
1007 let (first, _) = backup_brain(&db_path, None).expect("first backup");
1008 let (second, _) = backup_brain(&db_path, None).expect("second backup");
1009
1010 assert_ne!(first, second, "default backups must not overwrite");
1011 assert!(first.exists(), "first backup should still exist");
1012 assert!(second.exists(), "second backup should exist");
1013
1014 let _ = std::fs::remove_dir_all(&tmp_dir);
1015 }
1016
1017 #[test]
1018 fn backup_brain_custom_path() {
1019 let tmp_id = std::time::SystemTime::now()
1020 .duration_since(std::time::UNIX_EPOCH)
1021 .map(|d| d.as_nanos())
1022 .unwrap_or(0);
1023 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-backup-brain2-{tmp_id}"));
1024 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1025
1026 let db_path = tmp_dir.join("brain.db");
1027 let custom = tmp_dir.join("my-custom-backup.db");
1028 {
1029 let _conn = make_full_brain_db(&db_path);
1030 }
1031
1032 let (dest, size) = backup_brain(&db_path, Some(&custom)).expect("backup_brain custom");
1033
1034 assert_eq!(dest, custom, "dest should be the custom path");
1035 assert!(custom.exists(), "custom backup file should exist");
1036 assert!(size > 0);
1037
1038 let bak_conn = Connection::open(&custom).expect("open custom backup");
1040 let count: i64 = bak_conn
1041 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
1042 .expect("count memories in custom backup");
1043 assert_eq!(count, 1, "custom backup should contain 1 memory row");
1044
1045 let _ = std::fs::remove_dir_all(&tmp_dir);
1046 }
1047}