1use rusqlite::Connection;
2
3use kimetsu_core::KimetsuResult;
4
5pub fn apply_pragmas(conn: &Connection) -> KimetsuResult<()> {
22 conn.pragma_update(None, "cache_size", -65536_i64)?;
24 conn.pragma_update(None, "temp_store", "MEMORY")?;
25
26 let _ = conn.pragma_update(None, "mmap_size", 268_435_456_i64);
30 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
31
32 Ok(())
33}
34
35pub fn initialize(conn: &Connection) -> KimetsuResult<()> {
36 apply_pragmas(conn)?;
37 create_baseline(conn)?;
38 crate::migrate::run_migrations(conn)?;
39
40 let _ = conn.execute_batch("DROP TABLE IF EXISTS memory_vec;");
51
52 Ok(())
53}
54
55#[cfg(test)]
59pub fn create_baseline_for_test(conn: &Connection) -> KimetsuResult<()> {
60 create_baseline(conn)
61}
62
63fn create_baseline(conn: &Connection) -> KimetsuResult<()> {
68 conn.pragma_update(None, "journal_mode", "WAL")?;
69 conn.pragma_update(None, "busy_timeout", 15_000)?;
70
71 conn.execute_batch(
72 "
73 CREATE TABLE IF NOT EXISTS schema_info (
74 key TEXT PRIMARY KEY,
75 value INTEGER NOT NULL
76 );
77
78 INSERT OR IGNORE INTO schema_info (key, value)
79 VALUES ('kimetsu_schema_version', 1);
80
81 CREATE TABLE IF NOT EXISTS runs (
82 run_id TEXT PRIMARY KEY,
83 project_id TEXT NOT NULL,
84 task TEXT NOT NULL,
85 started_at TEXT NOT NULL,
86 ended_at TEXT,
87 terminal_kind TEXT,
88 model TEXT,
89 total_cost_usd REAL NOT NULL DEFAULT 0
90 );
91
92 CREATE TABLE IF NOT EXISTS events (
93 event_id TEXT PRIMARY KEY,
94 run_id TEXT NOT NULL,
95 ts TEXT NOT NULL,
96 kind TEXT NOT NULL,
97 schema_version INTEGER NOT NULL,
98 payload_json TEXT NOT NULL,
99 origin TEXT,
100 hlc TEXT
101 );
102
103 CREATE INDEX IF NOT EXISTS idx_events_run_ts ON events (run_id, ts);
104 CREATE INDEX IF NOT EXISTS idx_events_kind_ts ON events (kind, ts);
105
106 CREATE TABLE IF NOT EXISTS sources (
107 source_id TEXT PRIMARY KEY,
108 kind TEXT NOT NULL,
109 ref TEXT NOT NULL,
110 hash TEXT,
111 added_at TEXT NOT NULL
112 );
113
114 CREATE TABLE IF NOT EXISTS memories (
115 memory_id TEXT PRIMARY KEY,
116 scope TEXT NOT NULL,
117 kind TEXT NOT NULL,
118 text TEXT NOT NULL,
119 normalized_text TEXT NOT NULL,
120 confidence REAL NOT NULL,
121 source_event_id TEXT,
122 provenance_snapshot_json TEXT NOT NULL,
123 created_at TEXT NOT NULL,
124 last_used_at TEXT,
125 use_count INTEGER NOT NULL DEFAULT 0,
126 usefulness_score REAL NOT NULL DEFAULT 0.0,
127 invalidated_at TEXT,
128 invalidated_reason TEXT
129 );
130
131 CREATE INDEX IF NOT EXISTS idx_memories_scope_kind_norm
132 ON memories (scope, kind, normalized_text);
133 CREATE TABLE IF NOT EXISTS memory_proposals (
134 proposal_id TEXT PRIMARY KEY,
135 run_id TEXT NOT NULL,
136 scope TEXT NOT NULL,
137 kind TEXT NOT NULL,
138 text TEXT NOT NULL,
139 rationale TEXT NOT NULL,
140 proposed_confidence REAL NOT NULL,
141 source_event_ids_json TEXT NOT NULL,
142 status TEXT NOT NULL,
143 decided_at TEXT,
144 decided_by TEXT,
145 decided_reason TEXT
146 );
147
148 CREATE INDEX IF NOT EXISTS idx_memory_proposals_status_run
149 ON memory_proposals (status, run_id);
150
151 CREATE TABLE IF NOT EXISTS repo_files (
152 repo_root TEXT NOT NULL,
153 path TEXT NOT NULL,
154 hash TEXT NOT NULL,
155 size INTEGER NOT NULL,
156 mtime TEXT NOT NULL,
157 language_guess TEXT NOT NULL,
158 snippet TEXT NOT NULL,
159 PRIMARY KEY (repo_root, path)
160 );
161
162 CREATE INDEX IF NOT EXISTS idx_repo_files_language
163 ON repo_files (repo_root, language_guess);
164
165 CREATE TABLE IF NOT EXISTS repo_manifests (
166 repo_root TEXT NOT NULL,
167 manifest_path TEXT NOT NULL,
168 manifest_kind TEXT NOT NULL,
169 parsed_summary_json TEXT NOT NULL,
170 hash TEXT NOT NULL,
171 mtime TEXT NOT NULL,
172 PRIMARY KEY (repo_root, manifest_path)
173 );
174
175 CREATE VIRTUAL TABLE IF NOT EXISTS repo_files_fts
176 USING fts5(repo_root, path, snippet, language_guess);
177
178 CREATE VIRTUAL TABLE IF NOT EXISTS repo_manifests_fts
179 USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
180
181 CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
182 USING fts5(memory_id UNINDEXED, text, kind, scope);
183 ",
184 )?;
185 Ok(())
186}
187
188pub(crate) fn migrate_v1_to_v2(conn: &Connection) -> KimetsuResult<()> {
196 add_column_if_missing(conn, "memory_proposals", "decided_reason TEXT")?;
201 add_column_if_missing(
207 conn,
208 "memories",
209 "usefulness_score REAL NOT NULL DEFAULT 0.0",
210 )?;
211 add_column_if_missing(conn, "memories", "invalidated_at TEXT")?;
215 add_column_if_missing(conn, "memories", "invalidated_reason TEXT")?;
216 add_column_if_missing(conn, "memories", "embedding BLOB")?;
226 add_column_if_missing(conn, "memories", "embedding_model TEXT")?;
227 add_column_if_missing(conn, "memories", "last_useful_at TEXT")?;
242 conn.execute_batch(
243 "
244 CREATE INDEX IF NOT EXISTS idx_memories_active_created
245 ON memories (invalidated_at, created_at);
246 ",
247 )?;
248 conn.execute_batch(
267 "
268 CREATE TABLE IF NOT EXISTS memory_citations (
269 run_id TEXT NOT NULL,
270 memory_id TEXT NOT NULL,
271 turn INTEGER NOT NULL,
272 cited_at TEXT NOT NULL,
273 rationale TEXT,
274 PRIMARY KEY (run_id, memory_id, turn)
275 );
276 CREATE INDEX IF NOT EXISTS idx_citations_run
277 ON memory_citations (run_id);
278 CREATE INDEX IF NOT EXISTS idx_citations_memory
279 ON memory_citations (memory_id);
280 ",
281 )?;
282 conn.execute_batch(
300 "
301 CREATE TABLE IF NOT EXISTS memory_conflicts (
302 conflict_id TEXT PRIMARY KEY,
303 new_memory_id TEXT NOT NULL,
304 existing_memory_id TEXT NOT NULL,
305 scope TEXT NOT NULL,
306 kind TEXT NOT NULL,
307 similarity REAL NOT NULL,
308 detected_at TEXT NOT NULL,
309 resolved_at TEXT,
310 resolution TEXT,
311 UNIQUE (new_memory_id, existing_memory_id)
312 );
313 CREATE INDEX IF NOT EXISTS idx_conflicts_unresolved
314 ON memory_conflicts (resolved_at, detected_at);
315 CREATE INDEX IF NOT EXISTS idx_conflicts_new_memory
316 ON memory_conflicts (new_memory_id);
317
318 -- v2.6 #3 Slice B: concurrent-supersede conflicts surfaced during team
319 -- sync (a member superseded to two DIFFERENT survivors by concurrent
320 -- edits). HLC replay still picks a deterministic winner; this records the
321 -- collision for human review. A PROJECTION — cleared + repopulated by
322 -- rebuild. survivor_a < survivor_b (canonicalized) so it records once.
323 CREATE TABLE IF NOT EXISTS sync_conflicts (
324 member_id TEXT NOT NULL,
325 survivor_a TEXT NOT NULL,
326 survivor_b TEXT NOT NULL,
327 detected_at TEXT NOT NULL,
328 PRIMARY KEY (member_id, survivor_a, survivor_b)
329 );
330 ",
331 )?;
332 ensure_memories_fts_shape(conn)?;
333 ensure_repo_manifests_fts_shape(conn)?;
334
335 conn.execute_batch(
339 "CREATE INDEX IF NOT EXISTS idx_memories_scope_model_active
340 ON memories (scope, embedding_model, invalidated_at);",
341 )?;
342
343 Ok(())
344}
345
346pub(crate) fn migrate_v2_to_v3(conn: &Connection) -> KimetsuResult<()> {
358 add_column_if_missing(conn, "memories", "superseded_by TEXT")?;
359 conn.execute_batch(
360 "CREATE INDEX IF NOT EXISTS idx_memories_superseded
361 ON memories (superseded_by);",
362 )?;
363 Ok(())
364}
365
366pub(crate) fn migrate_v3_to_v4(conn: &Connection) -> KimetsuResult<()> {
387 conn.execute_batch(
388 "
389 CREATE TABLE IF NOT EXISTS memory_edges (
390 src_id TEXT NOT NULL,
391 dst_id TEXT NOT NULL,
392 edge_type TEXT NOT NULL,
393 created_at TEXT NOT NULL,
394 PRIMARY KEY (src_id, dst_id, edge_type)
395 );
396
397 CREATE INDEX IF NOT EXISTS idx_memory_edges_src
398 ON memory_edges (src_id, edge_type);
399
400 CREATE INDEX IF NOT EXISTS idx_memory_edges_dst
401 ON memory_edges (dst_id, edge_type);
402 ",
403 )?;
404 Ok(())
405}
406
407pub(crate) fn migrate_v4_to_v5(conn: &Connection) -> KimetsuResult<()> {
416 crate::episode::create_work_episodes_table(conn)
417}
418
419pub(crate) fn migrate_v5_to_v6(conn: &Connection) -> KimetsuResult<()> {
440 conn.execute_batch(
441 "
442 CREATE TABLE IF NOT EXISTS skill_proposals (
443 proposal_id TEXT PRIMARY KEY,
444 skill_name TEXT NOT NULL,
445 description TEXT NOT NULL,
446 draft_content TEXT,
447 source_memory_ids_json TEXT NOT NULL DEFAULT '[]',
448 trigger_kind TEXT NOT NULL,
449 trigger_count INTEGER NOT NULL DEFAULT 0,
450 status TEXT NOT NULL DEFAULT 'pending',
451 decided_at TEXT,
452 installed_path TEXT,
453 created_at TEXT NOT NULL
454 );
455 CREATE INDEX IF NOT EXISTS idx_skill_proposals_status
456 ON skill_proposals (status, created_at);
457 ",
458 )?;
459 Ok(())
460}
461
462pub(crate) fn migrate_v6_to_v7(conn: &Connection) -> KimetsuResult<()> {
486 add_column_if_missing(conn, "memories", "valid_from TEXT")?;
487 add_column_if_missing(conn, "memories", "valid_to TEXT")?;
488 conn.execute_batch(
489 "CREATE INDEX IF NOT EXISTS idx_memories_valid_to
490 ON memories (valid_to);",
491 )?;
492 Ok(())
493}
494
495pub(crate) fn migrate_v7_to_v8(conn: &Connection) -> KimetsuResult<()> {
500 add_column_if_missing(conn, "events", "origin TEXT")?;
501 Ok(())
502}
503
504pub(crate) fn migrate_v8_to_v9(conn: &Connection) -> KimetsuResult<()> {
512 add_column_if_missing(conn, "events", "hlc TEXT")?;
513 conn.execute_batch(
514 "UPDATE events
515 SET hlc = printf('%013d.%010d.local', 0, rowid)
516 WHERE hlc IS NULL;",
517 )?;
518 Ok(())
519}
520
521pub(crate) fn migrate_v9_to_v10(conn: &Connection) -> KimetsuResult<()> {
526 let has_citations: bool = conn
529 .query_row(
530 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_citations'",
531 [],
532 |r| r.get::<_, i64>(0).map(|n| n > 0),
533 )
534 .unwrap_or(false);
535 if has_citations {
536 add_column_if_missing(conn, "memory_citations", "query TEXT")?;
537 }
538 conn.execute_batch(
539 "
540 CREATE TABLE IF NOT EXISTS query_routes (
541 query_norm TEXT NOT NULL,
542 memory_id TEXT NOT NULL,
543 cites INTEGER NOT NULL DEFAULT 0,
544 last_cited_at TEXT NOT NULL,
545 query_embedding BLOB,
546 embedding_model TEXT,
547 PRIMARY KEY (query_norm, memory_id)
548 );
549 CREATE INDEX IF NOT EXISTS idx_query_routes_memory
550 ON query_routes(memory_id);
551 ",
552 )?;
553 Ok(())
554}
555
556pub(crate) fn migrate_v10_to_v11(conn: &Connection) -> KimetsuResult<()> {
573 conn.execute_batch(
574 "
575 CREATE TABLE IF NOT EXISTS memory_entities (
576 memory_id TEXT NOT NULL,
577 entity TEXT NOT NULL,
578 source TEXT NOT NULL DEFAULT 'term',
579 PRIMARY KEY (memory_id, entity)
580 );
581 CREATE INDEX IF NOT EXISTS idx_memory_entities_entity
582 ON memory_entities(entity);
583 CREATE INDEX IF NOT EXISTS idx_memory_entities_memory
584 ON memory_entities(memory_id);
585 ",
586 )?;
587 let _ = crate::graph::reproject_all_entities(conn);
593 Ok(())
594}
595
596pub fn validate(conn: &Connection) -> KimetsuResult<()> {
597 apply_pragmas(conn)?;
601 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
602 let current: i64 = conn.query_row(
603 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
604 [],
605 |row| row.get(0),
606 )?;
607 let target = KIMETSU_SCHEMA_VERSION;
608 if current > target {
609 return Err(format!(
610 "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
611 )
612 .into());
613 }
614 if current < target {
615 return Err(Box::new(crate::migrate::SchemaNeedsMigration {
616 from: current,
617 to: target,
618 }));
619 }
620 Ok(())
621}
622
623fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
624 let column_name = column_def
625 .split_whitespace()
626 .next()
627 .ok_or("empty column definition")?;
628 let exists: bool = {
629 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
630 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
631 let mut found = false;
632 for row in rows {
633 if row? == column_name {
634 found = true;
635 break;
636 }
637 }
638 found
639 };
640 if !exists {
641 conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
642 }
643 Ok(())
644}
645
646fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
647 if table_has_column(conn, "memories_fts", "memory_id")? {
648 return Ok(());
649 }
650 conn.execute_batch(
651 "
652 DROP TABLE IF EXISTS memories_fts;
653 CREATE VIRTUAL TABLE memories_fts
654 USING fts5(memory_id UNINDEXED, text, kind, scope);
655 INSERT INTO memories_fts (memory_id, text, kind, scope)
656 SELECT memory_id, text, kind, scope FROM memories;
657 ",
658 )?;
659 Ok(())
660}
661
662fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
663 if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
664 return Ok(());
665 }
666 conn.execute_batch(
667 "
668 DROP TABLE IF EXISTS repo_manifests_fts;
669 CREATE VIRTUAL TABLE repo_manifests_fts
670 USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
671 INSERT INTO repo_manifests_fts (
672 repo_root, manifest_path, manifest_kind, parsed_summary_json
673 )
674 SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
675 FROM repo_manifests;
676 ",
677 )?;
678 Ok(())
679}
680
681fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
682 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
683 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
684 for row in rows {
685 if row? == column {
686 return Ok(true);
687 }
688 }
689 Ok(false)
690}
691
692#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::migrate;
700 use rusqlite::Connection;
701
702 fn column_names(conn: &Connection, table: &str) -> Vec<String> {
703 let mut stmt = conn
704 .prepare(&format!("PRAGMA table_info({table})"))
705 .expect("prepare table_info");
706 stmt.query_map([], |row| row.get::<_, String>(1))
707 .expect("query_map")
708 .map(|r| r.expect("row"))
709 .collect()
710 }
711
712 fn table_exists(conn: &Connection, name: &str) -> bool {
713 let count: i64 = conn
714 .query_row(
715 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
716 [name],
717 |r| r.get(0),
718 )
719 .unwrap_or(0);
720 count > 0
721 }
722
723 #[test]
727 fn fresh_init_reaches_current_version_with_full_shape() {
728 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
729 let conn = Connection::open_in_memory().expect("open_in_memory");
730 initialize(&conn).expect("initialize");
731
732 assert_eq!(
734 migrate::current_version(&conn).expect("current_version"),
735 KIMETSU_SCHEMA_VERSION,
736 "fresh DB must be at current schema version after initialize"
737 );
738
739 let mem_cols = column_names(&conn, "memories");
741 assert!(
742 mem_cols.contains(&"embedding".to_string()),
743 "memories must have `embedding` column"
744 );
745 assert!(
746 mem_cols.contains(&"embedding_model".to_string()),
747 "memories must have `embedding_model` column"
748 );
749 assert!(
750 mem_cols.contains(&"last_useful_at".to_string()),
751 "memories must have `last_useful_at` column"
752 );
753 assert!(
755 mem_cols.contains(&"superseded_by".to_string()),
756 "memories must have `superseded_by` column after v3 migration"
757 );
758 assert!(
760 mem_cols.contains(&"valid_from".to_string()),
761 "memories must have `valid_from` column after v7 migration"
762 );
763 assert!(
764 mem_cols.contains(&"valid_to".to_string()),
765 "memories must have `valid_to` column after v7 migration"
766 );
767
768 assert!(
770 table_exists(&conn, "memory_citations"),
771 "memory_citations table must exist"
772 );
773 assert!(
774 table_exists(&conn, "memory_conflicts"),
775 "memory_conflicts table must exist"
776 );
777 assert!(
779 table_exists(&conn, "memory_edges"),
780 "memory_edges table must exist after v4 migration"
781 );
782 assert!(
784 table_exists(&conn, "work_episodes"),
785 "work_episodes table must exist after v5 migration"
786 );
787 assert!(
789 table_exists(&conn, "skill_proposals"),
790 "skill_proposals table must exist after v6 migration"
791 );
792 }
793
794 #[test]
798 fn idempotent_rerun_preserves_data() {
799 let conn = Connection::open_in_memory().expect("open_in_memory");
800 initialize(&conn).expect("initialize");
801
802 conn.execute_batch(
804 "INSERT INTO memories (
805 memory_id, scope, kind, text, normalized_text,
806 confidence, provenance_snapshot_json, created_at,
807 use_count, usefulness_score
808 ) VALUES (
809 'mem-1', 'test', 'fact', 'hello world', 'hello world',
810 0.9, '{}', '2024-01-01T00:00:00Z',
811 0, 0.0
812 );",
813 )
814 .expect("insert row");
815
816 let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
818 assert_eq!(
819 outcome.applied,
820 Vec::<i64>::new(),
821 "second run_migrations must apply nothing"
822 );
823 assert_eq!(
824 migrate::current_version(&conn).expect("current_version"),
825 kimetsu_core::KIMETSU_SCHEMA_VERSION,
826 "version must still be at target"
827 );
828
829 let text: String = conn
831 .query_row(
832 "SELECT text FROM memories WHERE memory_id = 'mem-1'",
833 [],
834 |r| r.get(0),
835 )
836 .expect("row must survive");
837 assert_eq!(text, "hello world");
838 }
839
840 #[test]
844 fn idempotent_initialize_twice() {
845 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
846 let conn = Connection::open_in_memory().expect("open_in_memory");
847 initialize(&conn).expect("first initialize");
848 initialize(&conn).expect("second initialize must not error");
849 assert_eq!(
850 migrate::current_version(&conn).expect("current_version"),
851 KIMETSU_SCHEMA_VERSION,
852 "version must still be at target after double initialize"
853 );
854 }
855
856 #[test]
860 fn apply_pragmas_sets_cache_size_on_rw_connection() {
861 let conn = Connection::open_in_memory().expect("open_in_memory");
862 initialize(&conn).expect("initialize");
863 let cache_size: i64 = conn
868 .pragma_query_value(None, "cache_size", |row| row.get(0))
869 .expect("cache_size query");
870 assert_ne!(
871 cache_size, -2000,
872 "cache_size must have been updated from the 2 MiB default, got {cache_size}"
873 );
874 assert!(
877 !(-2000..=2000).contains(&cache_size),
878 "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
879 );
880 }
881
882 #[test]
886 fn apply_pragmas_does_not_error_on_in_memory_conn() {
887 let conn = Connection::open_in_memory().expect("open_in_memory");
888 apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
889 let cache_size: i64 = conn
890 .pragma_query_value(None, "cache_size", |row| row.get(0))
891 .expect("cache_size");
892 assert!(
893 !(-2000..=2000).contains(&cache_size),
894 "apply_pragmas must update cache_size from the default, got {cache_size}"
895 );
896 }
897
898 fn seed_schema_info(version: i64) -> Connection {
900 let conn = Connection::open_in_memory().expect("open_in_memory");
901 conn.execute_batch(&format!(
902 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
903 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
904 ))
905 .expect("seed schema_info");
906 conn
907 }
908
909 #[test]
913 fn validate_ok_at_target() {
914 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
915 let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
916 validate(&conn).expect("validate at target must return Ok(())");
917 }
918
919 #[test]
923 fn validate_returns_needs_migration_for_older_db() {
924 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
925 let conn = seed_schema_info(1);
926 let err = validate(&conn).expect_err("validate on v1 DB must return Err");
927 let snm = err
928 .downcast_ref::<migrate::SchemaNeedsMigration>()
929 .expect("error must downcast to SchemaNeedsMigration");
930 assert_eq!(
931 snm,
932 &migrate::SchemaNeedsMigration {
933 from: 1,
934 to: KIMETSU_SCHEMA_VERSION,
935 },
936 "SchemaNeedsMigration must carry the correct from/to versions"
937 );
938 }
939
940 #[test]
944 fn v2_to_v3_migration_adds_superseded_by() {
945 let conn = Connection::open_in_memory().expect("open_in_memory");
946 create_baseline(&conn).expect("create_baseline");
948 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
949 conn.execute(
950 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
951 [],
952 )
953 .expect("set v2");
954
955 let cols_before = column_names(&conn, "memories");
957 assert!(
958 !cols_before.contains(&"superseded_by".to_string()),
959 "superseded_by must not exist before v3 migration"
960 );
961
962 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
964
965 let cols_after = column_names(&conn, "memories");
967 assert!(
968 cols_after.contains(&"superseded_by".to_string()),
969 "superseded_by must exist after v3 migration"
970 );
971
972 let idx_count: i64 = conn
974 .query_row(
975 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
976 [],
977 |r| r.get(0),
978 )
979 .expect("query index");
980 assert_eq!(
981 idx_count, 1,
982 "idx_memories_superseded must exist after v3 migration"
983 );
984 }
985
986 #[test]
990 fn validate_hard_errors_for_newer_db() {
991 let conn = seed_schema_info(999);
992 let err = validate(&conn).expect_err("validate on v999 DB must return Err");
993 assert!(
994 err.downcast_ref::<migrate::SchemaNeedsMigration>()
995 .is_none(),
996 "error for a newer DB must NOT downcast to SchemaNeedsMigration"
997 );
998 let msg = err.to_string();
999 assert!(
1000 msg.contains("newer"),
1001 "error message must contain 'newer', got: {msg}"
1002 );
1003 }
1004
1005 #[test]
1009 fn v3_to_v4_migration_adds_memory_edges() {
1010 let conn = Connection::open_in_memory().expect("open_in_memory");
1011 create_baseline(&conn).expect("create_baseline");
1013 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1014 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1015 conn.execute(
1016 "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
1017 [],
1018 )
1019 .expect("set v3");
1020
1021 assert!(
1023 !table_exists(&conn, "memory_edges"),
1024 "memory_edges must not exist before v4 migration"
1025 );
1026
1027 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1029
1030 assert!(
1032 table_exists(&conn, "memory_edges"),
1033 "memory_edges must exist after v4 migration"
1034 );
1035
1036 let src_idx: i64 = conn
1038 .query_row(
1039 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
1040 [],
1041 |r| r.get(0),
1042 )
1043 .expect("query idx_memory_edges_src");
1044 assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
1045
1046 let dst_idx: i64 = conn
1047 .query_row(
1048 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
1049 [],
1050 |r| r.get(0),
1051 )
1052 .expect("query idx_memory_edges_dst");
1053 assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
1054 }
1055
1056 #[test]
1060 fn v4_to_v5_migration_adds_work_episodes() {
1061 let conn = Connection::open_in_memory().expect("open_in_memory");
1062 create_baseline(&conn).expect("create_baseline");
1064 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1065 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1066 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1067 conn.execute(
1068 "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
1069 [],
1070 )
1071 .expect("set v4");
1072
1073 assert!(
1075 !table_exists(&conn, "work_episodes"),
1076 "work_episodes must not exist before v5 migration"
1077 );
1078
1079 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1081
1082 assert!(
1084 table_exists(&conn, "work_episodes"),
1085 "work_episodes must exist after v5 migration"
1086 );
1087
1088 let idx: i64 = conn
1090 .query_row(
1091 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1092 [],
1093 |r| r.get(0),
1094 )
1095 .expect("query idx_episodes_repo_live");
1096 assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1097 }
1098
1099 #[test]
1103 fn v5_to_v6_migration_adds_skill_proposals() {
1104 let conn = Connection::open_in_memory().expect("open_in_memory");
1105 create_baseline(&conn).expect("create_baseline");
1107 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1108 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1109 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1110 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1111 conn.execute(
1112 "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1113 [],
1114 )
1115 .expect("set v5");
1116
1117 assert!(
1119 !table_exists(&conn, "skill_proposals"),
1120 "skill_proposals must not exist before v6 migration"
1121 );
1122
1123 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1125
1126 assert!(
1128 table_exists(&conn, "skill_proposals"),
1129 "skill_proposals must exist after v6 migration"
1130 );
1131
1132 let idx: i64 = conn
1134 .query_row(
1135 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1136 [],
1137 |r| r.get(0),
1138 )
1139 .expect("query idx_skill_proposals_status");
1140 assert_eq!(
1141 idx, 1,
1142 "idx_skill_proposals_status must exist after v6 migration"
1143 );
1144 }
1145
1146 #[test]
1150 fn v6_to_v7_migration_adds_temporal_validity_columns() {
1151 let conn = Connection::open_in_memory().expect("open_in_memory");
1152 create_baseline(&conn).expect("create_baseline");
1154 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1155 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1156 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1157 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1158 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1159 conn.execute(
1160 "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1161 [],
1162 )
1163 .expect("set v6");
1164
1165 let cols_before = column_names(&conn, "memories");
1167 assert!(
1168 !cols_before.contains(&"valid_from".to_string()),
1169 "valid_from must not exist before v7 migration"
1170 );
1171 assert!(
1172 !cols_before.contains(&"valid_to".to_string()),
1173 "valid_to must not exist before v7 migration"
1174 );
1175
1176 migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1178
1179 let cols_after = column_names(&conn, "memories");
1181 assert!(
1182 cols_after.contains(&"valid_from".to_string()),
1183 "valid_from must exist after v7 migration"
1184 );
1185 assert!(
1186 cols_after.contains(&"valid_to".to_string()),
1187 "valid_to must exist after v7 migration"
1188 );
1189
1190 let idx: i64 = conn
1192 .query_row(
1193 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1194 [],
1195 |r| r.get(0),
1196 )
1197 .expect("query idx_memories_valid_to");
1198 assert_eq!(
1199 idx, 1,
1200 "idx_memories_valid_to must exist after v7 migration"
1201 );
1202 }
1203}