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 fn validate(conn: &Connection) -> KimetsuResult<()> {
522 apply_pragmas(conn)?;
526 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
527 let current: i64 = conn.query_row(
528 "SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
529 [],
530 |row| row.get(0),
531 )?;
532 let target = KIMETSU_SCHEMA_VERSION;
533 if current > target {
534 return Err(format!(
535 "brain.db schema version {current} was written by a newer Kimetsu (this binary expects {target}); upgrade Kimetsu"
536 )
537 .into());
538 }
539 if current < target {
540 return Err(Box::new(crate::migrate::SchemaNeedsMigration {
541 from: current,
542 to: target,
543 }));
544 }
545 Ok(())
546}
547
548fn add_column_if_missing(conn: &Connection, table: &str, column_def: &str) -> KimetsuResult<()> {
549 let column_name = column_def
550 .split_whitespace()
551 .next()
552 .ok_or("empty column definition")?;
553 let exists: bool = {
554 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
555 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
556 let mut found = false;
557 for row in rows {
558 if row? == column_name {
559 found = true;
560 break;
561 }
562 }
563 found
564 };
565 if !exists {
566 conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column_def};"))?;
567 }
568 Ok(())
569}
570
571fn ensure_memories_fts_shape(conn: &Connection) -> KimetsuResult<()> {
572 if table_has_column(conn, "memories_fts", "memory_id")? {
573 return Ok(());
574 }
575 conn.execute_batch(
576 "
577 DROP TABLE IF EXISTS memories_fts;
578 CREATE VIRTUAL TABLE memories_fts
579 USING fts5(memory_id UNINDEXED, text, kind, scope);
580 INSERT INTO memories_fts (memory_id, text, kind, scope)
581 SELECT memory_id, text, kind, scope FROM memories;
582 ",
583 )?;
584 Ok(())
585}
586
587fn ensure_repo_manifests_fts_shape(conn: &Connection) -> KimetsuResult<()> {
588 if table_has_column(conn, "repo_manifests_fts", "parsed_summary_json")? {
589 return Ok(());
590 }
591 conn.execute_batch(
592 "
593 DROP TABLE IF EXISTS repo_manifests_fts;
594 CREATE VIRTUAL TABLE repo_manifests_fts
595 USING fts5(repo_root UNINDEXED, manifest_path, manifest_kind, parsed_summary_json);
596 INSERT INTO repo_manifests_fts (
597 repo_root, manifest_path, manifest_kind, parsed_summary_json
598 )
599 SELECT repo_root, manifest_path, manifest_kind, parsed_summary_json
600 FROM repo_manifests;
601 ",
602 )?;
603 Ok(())
604}
605
606fn table_has_column(conn: &Connection, table: &str, column: &str) -> KimetsuResult<bool> {
607 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
608 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
609 for row in rows {
610 if row? == column {
611 return Ok(true);
612 }
613 }
614 Ok(false)
615}
616
617#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::migrate;
625 use rusqlite::Connection;
626
627 fn column_names(conn: &Connection, table: &str) -> Vec<String> {
628 let mut stmt = conn
629 .prepare(&format!("PRAGMA table_info({table})"))
630 .expect("prepare table_info");
631 stmt.query_map([], |row| row.get::<_, String>(1))
632 .expect("query_map")
633 .map(|r| r.expect("row"))
634 .collect()
635 }
636
637 fn table_exists(conn: &Connection, name: &str) -> bool {
638 let count: i64 = conn
639 .query_row(
640 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
641 [name],
642 |r| r.get(0),
643 )
644 .unwrap_or(0);
645 count > 0
646 }
647
648 #[test]
652 fn fresh_init_reaches_current_version_with_full_shape() {
653 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
654 let conn = Connection::open_in_memory().expect("open_in_memory");
655 initialize(&conn).expect("initialize");
656
657 assert_eq!(
659 migrate::current_version(&conn).expect("current_version"),
660 KIMETSU_SCHEMA_VERSION,
661 "fresh DB must be at current schema version after initialize"
662 );
663
664 let mem_cols = column_names(&conn, "memories");
666 assert!(
667 mem_cols.contains(&"embedding".to_string()),
668 "memories must have `embedding` column"
669 );
670 assert!(
671 mem_cols.contains(&"embedding_model".to_string()),
672 "memories must have `embedding_model` column"
673 );
674 assert!(
675 mem_cols.contains(&"last_useful_at".to_string()),
676 "memories must have `last_useful_at` column"
677 );
678 assert!(
680 mem_cols.contains(&"superseded_by".to_string()),
681 "memories must have `superseded_by` column after v3 migration"
682 );
683 assert!(
685 mem_cols.contains(&"valid_from".to_string()),
686 "memories must have `valid_from` column after v7 migration"
687 );
688 assert!(
689 mem_cols.contains(&"valid_to".to_string()),
690 "memories must have `valid_to` column after v7 migration"
691 );
692
693 assert!(
695 table_exists(&conn, "memory_citations"),
696 "memory_citations table must exist"
697 );
698 assert!(
699 table_exists(&conn, "memory_conflicts"),
700 "memory_conflicts table must exist"
701 );
702 assert!(
704 table_exists(&conn, "memory_edges"),
705 "memory_edges table must exist after v4 migration"
706 );
707 assert!(
709 table_exists(&conn, "work_episodes"),
710 "work_episodes table must exist after v5 migration"
711 );
712 assert!(
714 table_exists(&conn, "skill_proposals"),
715 "skill_proposals table must exist after v6 migration"
716 );
717 }
718
719 #[test]
723 fn idempotent_rerun_preserves_data() {
724 let conn = Connection::open_in_memory().expect("open_in_memory");
725 initialize(&conn).expect("initialize");
726
727 conn.execute_batch(
729 "INSERT INTO memories (
730 memory_id, scope, kind, text, normalized_text,
731 confidence, provenance_snapshot_json, created_at,
732 use_count, usefulness_score
733 ) VALUES (
734 'mem-1', 'test', 'fact', 'hello world', 'hello world',
735 0.9, '{}', '2024-01-01T00:00:00Z',
736 0, 0.0
737 );",
738 )
739 .expect("insert row");
740
741 let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
743 assert_eq!(
744 outcome.applied,
745 Vec::<i64>::new(),
746 "second run_migrations must apply nothing"
747 );
748 assert_eq!(
749 migrate::current_version(&conn).expect("current_version"),
750 kimetsu_core::KIMETSU_SCHEMA_VERSION,
751 "version must still be at target"
752 );
753
754 let text: String = conn
756 .query_row(
757 "SELECT text FROM memories WHERE memory_id = 'mem-1'",
758 [],
759 |r| r.get(0),
760 )
761 .expect("row must survive");
762 assert_eq!(text, "hello world");
763 }
764
765 #[test]
769 fn idempotent_initialize_twice() {
770 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
771 let conn = Connection::open_in_memory().expect("open_in_memory");
772 initialize(&conn).expect("first initialize");
773 initialize(&conn).expect("second initialize must not error");
774 assert_eq!(
775 migrate::current_version(&conn).expect("current_version"),
776 KIMETSU_SCHEMA_VERSION,
777 "version must still be at target after double initialize"
778 );
779 }
780
781 #[test]
785 fn apply_pragmas_sets_cache_size_on_rw_connection() {
786 let conn = Connection::open_in_memory().expect("open_in_memory");
787 initialize(&conn).expect("initialize");
788 let cache_size: i64 = conn
793 .pragma_query_value(None, "cache_size", |row| row.get(0))
794 .expect("cache_size query");
795 assert_ne!(
796 cache_size, -2000,
797 "cache_size must have been updated from the 2 MiB default, got {cache_size}"
798 );
799 assert!(
802 !(-2000..=2000).contains(&cache_size),
803 "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
804 );
805 }
806
807 #[test]
811 fn apply_pragmas_does_not_error_on_in_memory_conn() {
812 let conn = Connection::open_in_memory().expect("open_in_memory");
813 apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
814 let cache_size: i64 = conn
815 .pragma_query_value(None, "cache_size", |row| row.get(0))
816 .expect("cache_size");
817 assert!(
818 !(-2000..=2000).contains(&cache_size),
819 "apply_pragmas must update cache_size from the default, got {cache_size}"
820 );
821 }
822
823 fn seed_schema_info(version: i64) -> Connection {
825 let conn = Connection::open_in_memory().expect("open_in_memory");
826 conn.execute_batch(&format!(
827 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
828 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
829 ))
830 .expect("seed schema_info");
831 conn
832 }
833
834 #[test]
838 fn validate_ok_at_target() {
839 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
840 let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
841 validate(&conn).expect("validate at target must return Ok(())");
842 }
843
844 #[test]
848 fn validate_returns_needs_migration_for_older_db() {
849 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
850 let conn = seed_schema_info(1);
851 let err = validate(&conn).expect_err("validate on v1 DB must return Err");
852 let snm = err
853 .downcast_ref::<migrate::SchemaNeedsMigration>()
854 .expect("error must downcast to SchemaNeedsMigration");
855 assert_eq!(
856 snm,
857 &migrate::SchemaNeedsMigration {
858 from: 1,
859 to: KIMETSU_SCHEMA_VERSION,
860 },
861 "SchemaNeedsMigration must carry the correct from/to versions"
862 );
863 }
864
865 #[test]
869 fn v2_to_v3_migration_adds_superseded_by() {
870 let conn = Connection::open_in_memory().expect("open_in_memory");
871 create_baseline(&conn).expect("create_baseline");
873 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
874 conn.execute(
875 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
876 [],
877 )
878 .expect("set v2");
879
880 let cols_before = column_names(&conn, "memories");
882 assert!(
883 !cols_before.contains(&"superseded_by".to_string()),
884 "superseded_by must not exist before v3 migration"
885 );
886
887 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
889
890 let cols_after = column_names(&conn, "memories");
892 assert!(
893 cols_after.contains(&"superseded_by".to_string()),
894 "superseded_by must exist after v3 migration"
895 );
896
897 let idx_count: i64 = conn
899 .query_row(
900 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
901 [],
902 |r| r.get(0),
903 )
904 .expect("query index");
905 assert_eq!(
906 idx_count, 1,
907 "idx_memories_superseded must exist after v3 migration"
908 );
909 }
910
911 #[test]
915 fn validate_hard_errors_for_newer_db() {
916 let conn = seed_schema_info(999);
917 let err = validate(&conn).expect_err("validate on v999 DB must return Err");
918 assert!(
919 err.downcast_ref::<migrate::SchemaNeedsMigration>()
920 .is_none(),
921 "error for a newer DB must NOT downcast to SchemaNeedsMigration"
922 );
923 let msg = err.to_string();
924 assert!(
925 msg.contains("newer"),
926 "error message must contain 'newer', got: {msg}"
927 );
928 }
929
930 #[test]
934 fn v3_to_v4_migration_adds_memory_edges() {
935 let conn = Connection::open_in_memory().expect("open_in_memory");
936 create_baseline(&conn).expect("create_baseline");
938 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
939 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
940 conn.execute(
941 "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
942 [],
943 )
944 .expect("set v3");
945
946 assert!(
948 !table_exists(&conn, "memory_edges"),
949 "memory_edges must not exist before v4 migration"
950 );
951
952 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
954
955 assert!(
957 table_exists(&conn, "memory_edges"),
958 "memory_edges must exist after v4 migration"
959 );
960
961 let src_idx: i64 = conn
963 .query_row(
964 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
965 [],
966 |r| r.get(0),
967 )
968 .expect("query idx_memory_edges_src");
969 assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
970
971 let dst_idx: i64 = conn
972 .query_row(
973 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
974 [],
975 |r| r.get(0),
976 )
977 .expect("query idx_memory_edges_dst");
978 assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
979 }
980
981 #[test]
985 fn v4_to_v5_migration_adds_work_episodes() {
986 let conn = Connection::open_in_memory().expect("open_in_memory");
987 create_baseline(&conn).expect("create_baseline");
989 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
990 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
991 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
992 conn.execute(
993 "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
994 [],
995 )
996 .expect("set v4");
997
998 assert!(
1000 !table_exists(&conn, "work_episodes"),
1001 "work_episodes must not exist before v5 migration"
1002 );
1003
1004 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1006
1007 assert!(
1009 table_exists(&conn, "work_episodes"),
1010 "work_episodes must exist after v5 migration"
1011 );
1012
1013 let idx: i64 = conn
1015 .query_row(
1016 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1017 [],
1018 |r| r.get(0),
1019 )
1020 .expect("query idx_episodes_repo_live");
1021 assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1022 }
1023
1024 #[test]
1028 fn v5_to_v6_migration_adds_skill_proposals() {
1029 let conn = Connection::open_in_memory().expect("open_in_memory");
1030 create_baseline(&conn).expect("create_baseline");
1032 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1033 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1034 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1035 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1036 conn.execute(
1037 "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1038 [],
1039 )
1040 .expect("set v5");
1041
1042 assert!(
1044 !table_exists(&conn, "skill_proposals"),
1045 "skill_proposals must not exist before v6 migration"
1046 );
1047
1048 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1050
1051 assert!(
1053 table_exists(&conn, "skill_proposals"),
1054 "skill_proposals must exist after v6 migration"
1055 );
1056
1057 let idx: i64 = conn
1059 .query_row(
1060 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1061 [],
1062 |r| r.get(0),
1063 )
1064 .expect("query idx_skill_proposals_status");
1065 assert_eq!(
1066 idx, 1,
1067 "idx_skill_proposals_status must exist after v6 migration"
1068 );
1069 }
1070
1071 #[test]
1075 fn v6_to_v7_migration_adds_temporal_validity_columns() {
1076 let conn = Connection::open_in_memory().expect("open_in_memory");
1077 create_baseline(&conn).expect("create_baseline");
1079 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1080 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1081 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1082 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1083 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1084 conn.execute(
1085 "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1086 [],
1087 )
1088 .expect("set v6");
1089
1090 let cols_before = column_names(&conn, "memories");
1092 assert!(
1093 !cols_before.contains(&"valid_from".to_string()),
1094 "valid_from must not exist before v7 migration"
1095 );
1096 assert!(
1097 !cols_before.contains(&"valid_to".to_string()),
1098 "valid_to must not exist before v7 migration"
1099 );
1100
1101 migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1103
1104 let cols_after = column_names(&conn, "memories");
1106 assert!(
1107 cols_after.contains(&"valid_from".to_string()),
1108 "valid_from must exist after v7 migration"
1109 );
1110 assert!(
1111 cols_after.contains(&"valid_to".to_string()),
1112 "valid_to must exist after v7 migration"
1113 );
1114
1115 let idx: i64 = conn
1117 .query_row(
1118 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1119 [],
1120 |r| r.get(0),
1121 )
1122 .expect("query idx_memories_valid_to");
1123 assert_eq!(
1124 idx, 1,
1125 "idx_memories_valid_to must exist after v7 migration"
1126 );
1127 }
1128}