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 -- v3.0 #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 fn validate(conn: &Connection) -> KimetsuResult<()> {
557 apply_pragmas(conn)?;
561 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
562 let current: i64 = conn.query_row(
563 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
564 [],
565 |row| row.get(0),
566 )?;
567 let target = KIMETSU_SCHEMA_VERSION;
568 if current > target {
569 return Err(format!(
570 "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
571 )
572 .into());
573 }
574 if current < target {
575 return Err(Box::new(crate::migrate::SchemaNeedsMigration {
576 from: current,
577 to: target,
578 }));
579 }
580 Ok(())
581}
582
583fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
584 let column_name = column_def
585 .split_whitespace()
586 .next()
587 .ok_or("empty column definition")?;
588 let exists: bool = {
589 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
590 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
591 let mut found = false;
592 for row in rows {
593 if row? == column_name {
594 found = true;
595 break;
596 }
597 }
598 found
599 };
600 if !exists {
601 conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
602 }
603 Ok(())
604}
605
606fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
607 if table_has_column(conn, "memories_fts", "memory_id")? {
608 return Ok(());
609 }
610 conn.execute_batch(
611 "
612 DROP TABLE IF EXISTS memories_fts;
613 CREATE VIRTUAL TABLE memories_fts
614 USING fts5(memory_id UNINDEXED, text, kind, scope);
615 INSERT INTO memories_fts (memory_id, text, kind, scope)
616 SELECT memory_id, text, kind, scope FROM memories;
617 ",
618 )?;
619 Ok(())
620}
621
622fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
623 if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
624 return Ok(());
625 }
626 conn.execute_batch(
627 "
628 DROP TABLE IF EXISTS repo_manifests_fts;
629 CREATE VIRTUAL TABLE repo_manifests_fts
630 USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
631 INSERT INTO repo_manifests_fts (
632 repo_root, manifest_path, manifest_kind, parsed_summary_json
633 )
634 SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
635 FROM repo_manifests;
636 ",
637 )?;
638 Ok(())
639}
640
641fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
642 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
643 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
644 for row in rows {
645 if row? == column {
646 return Ok(true);
647 }
648 }
649 Ok(false)
650}
651
652#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::migrate;
660 use rusqlite::Connection;
661
662 fn column_names(conn: &Connection, table: &str) -> Vec<String> {
663 let mut stmt = conn
664 .prepare(&format!("PRAGMA table_info({table})"))
665 .expect("prepare table_info");
666 stmt.query_map([], |row| row.get::<_, String>(1))
667 .expect("query_map")
668 .map(|r| r.expect("row"))
669 .collect()
670 }
671
672 fn table_exists(conn: &Connection, name: &str) -> bool {
673 let count: i64 = conn
674 .query_row(
675 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
676 [name],
677 |r| r.get(0),
678 )
679 .unwrap_or(0);
680 count > 0
681 }
682
683 #[test]
687 fn fresh_init_reaches_current_version_with_full_shape() {
688 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
689 let conn = Connection::open_in_memory().expect("open_in_memory");
690 initialize(&conn).expect("initialize");
691
692 assert_eq!(
694 migrate::current_version(&conn).expect("current_version"),
695 KIMETSU_SCHEMA_VERSION,
696 "fresh DB must be at current schema version after initialize"
697 );
698
699 let mem_cols = column_names(&conn, "memories");
701 assert!(
702 mem_cols.contains(&"embedding".to_string()),
703 "memories must have `embedding` column"
704 );
705 assert!(
706 mem_cols.contains(&"embedding_model".to_string()),
707 "memories must have `embedding_model` column"
708 );
709 assert!(
710 mem_cols.contains(&"last_useful_at".to_string()),
711 "memories must have `last_useful_at` column"
712 );
713 assert!(
715 mem_cols.contains(&"superseded_by".to_string()),
716 "memories must have `superseded_by` column after v3 migration"
717 );
718 assert!(
720 mem_cols.contains(&"valid_from".to_string()),
721 "memories must have `valid_from` column after v7 migration"
722 );
723 assert!(
724 mem_cols.contains(&"valid_to".to_string()),
725 "memories must have `valid_to` column after v7 migration"
726 );
727
728 assert!(
730 table_exists(&conn, "memory_citations"),
731 "memory_citations table must exist"
732 );
733 assert!(
734 table_exists(&conn, "memory_conflicts"),
735 "memory_conflicts table must exist"
736 );
737 assert!(
739 table_exists(&conn, "memory_edges"),
740 "memory_edges table must exist after v4 migration"
741 );
742 assert!(
744 table_exists(&conn, "work_episodes"),
745 "work_episodes table must exist after v5 migration"
746 );
747 assert!(
749 table_exists(&conn, "skill_proposals"),
750 "skill_proposals table must exist after v6 migration"
751 );
752 }
753
754 #[test]
758 fn idempotent_rerun_preserves_data() {
759 let conn = Connection::open_in_memory().expect("open_in_memory");
760 initialize(&conn).expect("initialize");
761
762 conn.execute_batch(
764 "INSERT INTO memories (
765 memory_id, scope, kind, text, normalized_text,
766 confidence, provenance_snapshot_json, created_at,
767 use_count, usefulness_score
768 ) VALUES (
769 'mem-1', 'test', 'fact', 'hello world', 'hello world',
770 0.9, '{}', '2024-01-01T00:00:00Z',
771 0, 0.0
772 );",
773 )
774 .expect("insert row");
775
776 let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
778 assert_eq!(
779 outcome.applied,
780 Vec::<i64>::new(),
781 "second run_migrations must apply nothing"
782 );
783 assert_eq!(
784 migrate::current_version(&conn).expect("current_version"),
785 kimetsu_core::KIMETSU_SCHEMA_VERSION,
786 "version must still be at target"
787 );
788
789 let text: String = conn
791 .query_row(
792 "SELECT text FROM memories WHERE memory_id = 'mem-1'",
793 [],
794 |r| r.get(0),
795 )
796 .expect("row must survive");
797 assert_eq!(text, "hello world");
798 }
799
800 #[test]
804 fn idempotent_initialize_twice() {
805 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
806 let conn = Connection::open_in_memory().expect("open_in_memory");
807 initialize(&conn).expect("first initialize");
808 initialize(&conn).expect("second initialize must not error");
809 assert_eq!(
810 migrate::current_version(&conn).expect("current_version"),
811 KIMETSU_SCHEMA_VERSION,
812 "version must still be at target after double initialize"
813 );
814 }
815
816 #[test]
820 fn apply_pragmas_sets_cache_size_on_rw_connection() {
821 let conn = Connection::open_in_memory().expect("open_in_memory");
822 initialize(&conn).expect("initialize");
823 let cache_size: i64 = conn
828 .pragma_query_value(None, "cache_size", |row| row.get(0))
829 .expect("cache_size query");
830 assert_ne!(
831 cache_size, -2000,
832 "cache_size must have been updated from the 2 MiB default, got {cache_size}"
833 );
834 assert!(
837 !(-2000..=2000).contains(&cache_size),
838 "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
839 );
840 }
841
842 #[test]
846 fn apply_pragmas_does_not_error_on_in_memory_conn() {
847 let conn = Connection::open_in_memory().expect("open_in_memory");
848 apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
849 let cache_size: i64 = conn
850 .pragma_query_value(None, "cache_size", |row| row.get(0))
851 .expect("cache_size");
852 assert!(
853 !(-2000..=2000).contains(&cache_size),
854 "apply_pragmas must update cache_size from the default, got {cache_size}"
855 );
856 }
857
858 fn seed_schema_info(version: i64) -> Connection {
860 let conn = Connection::open_in_memory().expect("open_in_memory");
861 conn.execute_batch(&format!(
862 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
863 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
864 ))
865 .expect("seed schema_info");
866 conn
867 }
868
869 #[test]
873 fn validate_ok_at_target() {
874 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
875 let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
876 validate(&conn).expect("validate at target must return Ok(())");
877 }
878
879 #[test]
883 fn validate_returns_needs_migration_for_older_db() {
884 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
885 let conn = seed_schema_info(1);
886 let err = validate(&conn).expect_err("validate on v1 DB must return Err");
887 let snm = err
888 .downcast_ref::<migrate::SchemaNeedsMigration>()
889 .expect("error must downcast to SchemaNeedsMigration");
890 assert_eq!(
891 snm,
892 &migrate::SchemaNeedsMigration {
893 from: 1,
894 to: KIMETSU_SCHEMA_VERSION,
895 },
896 "SchemaNeedsMigration must carry the correct from/to versions"
897 );
898 }
899
900 #[test]
904 fn v2_to_v3_migration_adds_superseded_by() {
905 let conn = Connection::open_in_memory().expect("open_in_memory");
906 create_baseline(&conn).expect("create_baseline");
908 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
909 conn.execute(
910 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
911 [],
912 )
913 .expect("set v2");
914
915 let cols_before = column_names(&conn, "memories");
917 assert!(
918 !cols_before.contains(&"superseded_by".to_string()),
919 "superseded_by must not exist before v3 migration"
920 );
921
922 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
924
925 let cols_after = column_names(&conn, "memories");
927 assert!(
928 cols_after.contains(&"superseded_by".to_string()),
929 "superseded_by must exist after v3 migration"
930 );
931
932 let idx_count: i64 = conn
934 .query_row(
935 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
936 [],
937 |r| r.get(0),
938 )
939 .expect("query index");
940 assert_eq!(
941 idx_count, 1,
942 "idx_memories_superseded must exist after v3 migration"
943 );
944 }
945
946 #[test]
950 fn validate_hard_errors_for_newer_db() {
951 let conn = seed_schema_info(999);
952 let err = validate(&conn).expect_err("validate on v999 DB must return Err");
953 assert!(
954 err.downcast_ref::<migrate::SchemaNeedsMigration>()
955 .is_none(),
956 "error for a newer DB must NOT downcast to SchemaNeedsMigration"
957 );
958 let msg = err.to_string();
959 assert!(
960 msg.contains("newer"),
961 "error message must contain 'newer', got: {msg}"
962 );
963 }
964
965 #[test]
969 fn v3_to_v4_migration_adds_memory_edges() {
970 let conn = Connection::open_in_memory().expect("open_in_memory");
971 create_baseline(&conn).expect("create_baseline");
973 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
974 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
975 conn.execute(
976 "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
977 [],
978 )
979 .expect("set v3");
980
981 assert!(
983 !table_exists(&conn, "memory_edges"),
984 "memory_edges must not exist before v4 migration"
985 );
986
987 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
989
990 assert!(
992 table_exists(&conn, "memory_edges"),
993 "memory_edges must exist after v4 migration"
994 );
995
996 let src_idx: i64 = conn
998 .query_row(
999 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
1000 [],
1001 |r| r.get(0),
1002 )
1003 .expect("query idx_memory_edges_src");
1004 assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
1005
1006 let dst_idx: i64 = conn
1007 .query_row(
1008 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
1009 [],
1010 |r| r.get(0),
1011 )
1012 .expect("query idx_memory_edges_dst");
1013 assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
1014 }
1015
1016 #[test]
1020 fn v4_to_v5_migration_adds_work_episodes() {
1021 let conn = Connection::open_in_memory().expect("open_in_memory");
1022 create_baseline(&conn).expect("create_baseline");
1024 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1025 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1026 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1027 conn.execute(
1028 "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
1029 [],
1030 )
1031 .expect("set v4");
1032
1033 assert!(
1035 !table_exists(&conn, "work_episodes"),
1036 "work_episodes must not exist before v5 migration"
1037 );
1038
1039 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1041
1042 assert!(
1044 table_exists(&conn, "work_episodes"),
1045 "work_episodes must exist after v5 migration"
1046 );
1047
1048 let idx: i64 = conn
1050 .query_row(
1051 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1052 [],
1053 |r| r.get(0),
1054 )
1055 .expect("query idx_episodes_repo_live");
1056 assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1057 }
1058
1059 #[test]
1063 fn v5_to_v6_migration_adds_skill_proposals() {
1064 let conn = Connection::open_in_memory().expect("open_in_memory");
1065 create_baseline(&conn).expect("create_baseline");
1067 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1068 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1069 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1070 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1071 conn.execute(
1072 "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1073 [],
1074 )
1075 .expect("set v5");
1076
1077 assert!(
1079 !table_exists(&conn, "skill_proposals"),
1080 "skill_proposals must not exist before v6 migration"
1081 );
1082
1083 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1085
1086 assert!(
1088 table_exists(&conn, "skill_proposals"),
1089 "skill_proposals must exist after v6 migration"
1090 );
1091
1092 let idx: i64 = conn
1094 .query_row(
1095 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1096 [],
1097 |r| r.get(0),
1098 )
1099 .expect("query idx_skill_proposals_status");
1100 assert_eq!(
1101 idx, 1,
1102 "idx_skill_proposals_status must exist after v6 migration"
1103 );
1104 }
1105
1106 #[test]
1110 fn v6_to_v7_migration_adds_temporal_validity_columns() {
1111 let conn = Connection::open_in_memory().expect("open_in_memory");
1112 create_baseline(&conn).expect("create_baseline");
1114 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1115 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1116 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1117 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1118 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1119 conn.execute(
1120 "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1121 [],
1122 )
1123 .expect("set v6");
1124
1125 let cols_before = column_names(&conn, "memories");
1127 assert!(
1128 !cols_before.contains(&"valid_from".to_string()),
1129 "valid_from must not exist before v7 migration"
1130 );
1131 assert!(
1132 !cols_before.contains(&"valid_to".to_string()),
1133 "valid_to must not exist before v7 migration"
1134 );
1135
1136 migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1138
1139 let cols_after = column_names(&conn, "memories");
1141 assert!(
1142 cols_after.contains(&"valid_from".to_string()),
1143 "valid_from must exist after v7 migration"
1144 );
1145 assert!(
1146 cols_after.contains(&"valid_to".to_string()),
1147 "valid_to must exist after v7 migration"
1148 );
1149
1150 let idx: i64 = conn
1152 .query_row(
1153 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1154 [],
1155 |r| r.get(0),
1156 )
1157 .expect("query idx_memories_valid_to");
1158 assert_eq!(
1159 idx, 1,
1160 "idx_memories_valid_to must exist after v7 migration"
1161 );
1162 }
1163}