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
692pub fn migrate_v11_to_v12(conn: &Connection) -> KimetsuResult<()> {
694 conn.execute_batch("CREATE TABLE IF NOT EXISTS memory_revisions (
695 revision_id INTEGER PRIMARY KEY, memory_id TEXT NOT NULL,
696 event_id TEXT NOT NULL UNIQUE, text TEXT NOT NULL, kind TEXT NOT NULL,
697 known_at TEXT NOT NULL, effective_at TEXT NOT NULL,
698 confidence REAL NOT NULL, use_count INTEGER NOT NULL, usefulness_score REAL NOT NULL);
699 CREATE INDEX IF NOT EXISTS idx_memory_revisions_time ON memory_revisions(memory_id, known_at, effective_at);
700 CREATE TABLE IF NOT EXISTS corpus_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
701 INSERT OR IGNORE INTO corpus_revision VALUES (1,0);
702 CREATE TRIGGER IF NOT EXISTS corpus_insert AFTER INSERT ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;
703 CREATE TRIGGER IF NOT EXISTS corpus_delete AFTER DELETE ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;
704 CREATE TRIGGER IF NOT EXISTS corpus_update AFTER UPDATE OF embedding, embedding_model, text, invalidated_at, superseded_by ON memories BEGIN UPDATE corpus_revision SET revision=revision+1 WHERE id=1; END;")?;
705 Ok(())
706}
707
708pub fn migrate_v12_to_v13(conn: &Connection) -> KimetsuResult<()> {
710 if !table_has_column(conn, "memory_proposals", "proposal_id")? {
712 return Ok(());
713 }
714 add_column_if_missing(conn, "memory_proposals", "valid_from TEXT")?;
715 add_column_if_missing(conn, "memory_proposals", "valid_to TEXT")?;
716 Ok(())
717}
718
719pub(crate) fn migrate_v13_to_v14(conn: &Connection) -> KimetsuResult<()> {
721 crate::episode::create_work_episodes_table(conn)?;
722 add_column_if_missing(conn, "work_episodes", "identity TEXT NOT NULL DEFAULT ''")?;
723 conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_episodes_identity ON work_episodes(repo_root, identity, superseded_by)")?;
724 Ok(())
725}
726
727pub(crate) fn migrate_v14_to_v15(conn: &Connection) -> KimetsuResult<()> {
729 conn.execute_batch(
730 "CREATE TABLE IF NOT EXISTS memory_facts (
731 memory_id TEXT NOT NULL, claim_revision TEXT NOT NULL, ordinal INTEGER NOT NULL,
732 source_event_id TEXT NOT NULL, source_digest TEXT NOT NULL, claim_json TEXT NOT NULL,
733 PRIMARY KEY(memory_id,claim_revision,ordinal));",
734 )?;
735 for column in ["memory_id", "text", "source_event_id"] {
737 if !table_has_column(conn, "memories", column)? {
738 return Ok(());
739 }
740 }
741 if !table_has_column(conn, "memory_revisions", "revision_id")? {
742 return Ok(());
743 }
744 crate::fact_store::backfill(conn)
745}
746
747#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::migrate;
755 use rusqlite::Connection;
756
757 fn column_names(conn: &Connection, table: &str) -> Vec<String> {
758 let mut stmt = conn
759 .prepare(&format!("PRAGMA table_info({table})"))
760 .expect("prepare table_info");
761 stmt.query_map([], |row| row.get::<_, String>(1))
762 .expect("query_map")
763 .map(|r| r.expect("row"))
764 .collect()
765 }
766
767 fn table_exists(conn: &Connection, name: &str) -> bool {
768 let count: i64 = conn
769 .query_row(
770 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
771 [name],
772 |r| r.get(0),
773 )
774 .unwrap_or(0);
775 count > 0
776 }
777
778 #[test]
782 fn fresh_init_reaches_current_version_with_full_shape() {
783 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
784 let conn = Connection::open_in_memory().expect("open_in_memory");
785 initialize(&conn).expect("initialize");
786
787 assert_eq!(
789 migrate::current_version(&conn).expect("current_version"),
790 KIMETSU_SCHEMA_VERSION,
791 "fresh DB must be at current schema version after initialize"
792 );
793
794 let mem_cols = column_names(&conn, "memories");
796 assert!(
797 mem_cols.contains(&"embedding".to_string()),
798 "memories must have `embedding` column"
799 );
800 assert!(
801 mem_cols.contains(&"embedding_model".to_string()),
802 "memories must have `embedding_model` column"
803 );
804 assert!(
805 mem_cols.contains(&"last_useful_at".to_string()),
806 "memories must have `last_useful_at` column"
807 );
808 assert!(
810 mem_cols.contains(&"superseded_by".to_string()),
811 "memories must have `superseded_by` column after v3 migration"
812 );
813 assert!(
815 mem_cols.contains(&"valid_from".to_string()),
816 "memories must have `valid_from` column after v7 migration"
817 );
818 assert!(
819 mem_cols.contains(&"valid_to".to_string()),
820 "memories must have `valid_to` column after v7 migration"
821 );
822
823 assert!(
825 table_exists(&conn, "memory_citations"),
826 "memory_citations table must exist"
827 );
828 assert!(
829 table_exists(&conn, "memory_conflicts"),
830 "memory_conflicts table must exist"
831 );
832 assert!(
834 table_exists(&conn, "memory_edges"),
835 "memory_edges table must exist after v4 migration"
836 );
837 assert!(
839 table_exists(&conn, "work_episodes"),
840 "work_episodes table must exist after v5 migration"
841 );
842 assert!(
844 table_exists(&conn, "skill_proposals"),
845 "skill_proposals table must exist after v6 migration"
846 );
847 }
848
849 #[test]
853 fn idempotent_rerun_preserves_data() {
854 let conn = Connection::open_in_memory().expect("open_in_memory");
855 initialize(&conn).expect("initialize");
856
857 conn.execute_batch(
859 "INSERT INTO memories (
860 memory_id, scope, kind, text, normalized_text,
861 confidence, provenance_snapshot_json, created_at,
862 use_count, usefulness_score
863 ) VALUES (
864 'mem-1', 'test', 'fact', 'hello world', 'hello world',
865 0.9, '{}', '2024-01-01T00:00:00Z',
866 0, 0.0
867 );",
868 )
869 .expect("insert row");
870
871 let outcome = migrate::run_migrations(&conn).expect("second run_migrations");
873 assert_eq!(
874 outcome.applied,
875 Vec::<i64>::new(),
876 "second run_migrations must apply nothing"
877 );
878 assert_eq!(
879 migrate::current_version(&conn).expect("current_version"),
880 kimetsu_core::KIMETSU_SCHEMA_VERSION,
881 "version must still be at target"
882 );
883
884 let text: String = conn
886 .query_row(
887 "SELECT text FROM memories WHERE memory_id = 'mem-1'",
888 [],
889 |r| r.get(0),
890 )
891 .expect("row must survive");
892 assert_eq!(text, "hello world");
893 }
894
895 #[test]
899 fn idempotent_initialize_twice() {
900 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
901 let conn = Connection::open_in_memory().expect("open_in_memory");
902 initialize(&conn).expect("first initialize");
903 initialize(&conn).expect("second initialize must not error");
904 assert_eq!(
905 migrate::current_version(&conn).expect("current_version"),
906 KIMETSU_SCHEMA_VERSION,
907 "version must still be at target after double initialize"
908 );
909 }
910
911 #[test]
915 fn apply_pragmas_sets_cache_size_on_rw_connection() {
916 let conn = Connection::open_in_memory().expect("open_in_memory");
917 initialize(&conn).expect("initialize");
918 let cache_size: i64 = conn
923 .pragma_query_value(None, "cache_size", |row| row.get(0))
924 .expect("cache_size query");
925 assert_ne!(
926 cache_size, -2000,
927 "cache_size must have been updated from the 2 MiB default, got {cache_size}"
928 );
929 assert!(
932 !(-2000..=2000).contains(&cache_size),
933 "cache_size should reflect the 64 MiB tuning (not default -2000), got {cache_size}"
934 );
935 }
936
937 #[test]
941 fn apply_pragmas_does_not_error_on_in_memory_conn() {
942 let conn = Connection::open_in_memory().expect("open_in_memory");
943 apply_pragmas(&conn).expect("apply_pragmas must not error on a fresh in-memory conn");
944 let cache_size: i64 = conn
945 .pragma_query_value(None, "cache_size", |row| row.get(0))
946 .expect("cache_size");
947 assert!(
948 !(-2000..=2000).contains(&cache_size),
949 "apply_pragmas must update cache_size from the default, got {cache_size}"
950 );
951 }
952
953 fn seed_schema_info(version: i64) -> Connection {
955 let conn = Connection::open_in_memory().expect("open_in_memory");
956 conn.execute_batch(&format!(
957 "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
958 INSERT INTO schema_info VALUES ('kimetsu_schema_version', {version});"
959 ))
960 .expect("seed schema_info");
961 conn
962 }
963
964 #[test]
968 fn validate_ok_at_target() {
969 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
970 let conn = seed_schema_info(KIMETSU_SCHEMA_VERSION);
971 validate(&conn).expect("validate at target must return Ok(())");
972 }
973
974 #[test]
978 fn validate_returns_needs_migration_for_older_db() {
979 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
980 let conn = seed_schema_info(1);
981 let err = validate(&conn).expect_err("validate on v1 DB must return Err");
982 let snm = err
983 .downcast_ref::<migrate::SchemaNeedsMigration>()
984 .expect("error must downcast to SchemaNeedsMigration");
985 assert_eq!(
986 snm,
987 &migrate::SchemaNeedsMigration {
988 from: 1,
989 to: KIMETSU_SCHEMA_VERSION,
990 },
991 "SchemaNeedsMigration must carry the correct from/to versions"
992 );
993 }
994
995 #[test]
999 fn v2_to_v3_migration_adds_superseded_by() {
1000 let conn = Connection::open_in_memory().expect("open_in_memory");
1001 create_baseline(&conn).expect("create_baseline");
1003 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1004 conn.execute(
1005 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
1006 [],
1007 )
1008 .expect("set v2");
1009
1010 let cols_before = column_names(&conn, "memories");
1012 assert!(
1013 !cols_before.contains(&"superseded_by".to_string()),
1014 "superseded_by must not exist before v3 migration"
1015 );
1016
1017 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1019
1020 let cols_after = column_names(&conn, "memories");
1022 assert!(
1023 cols_after.contains(&"superseded_by".to_string()),
1024 "superseded_by must exist after v3 migration"
1025 );
1026
1027 let idx_count: i64 = conn
1029 .query_row(
1030 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_superseded'",
1031 [],
1032 |r| r.get(0),
1033 )
1034 .expect("query index");
1035 assert_eq!(
1036 idx_count, 1,
1037 "idx_memories_superseded must exist after v3 migration"
1038 );
1039 }
1040
1041 #[test]
1045 fn validate_hard_errors_for_newer_db() {
1046 let conn = seed_schema_info(999);
1047 let err = validate(&conn).expect_err("validate on v999 DB must return Err");
1048 assert!(
1049 err.downcast_ref::<migrate::SchemaNeedsMigration>()
1050 .is_none(),
1051 "error for a newer DB must NOT downcast to SchemaNeedsMigration"
1052 );
1053 let msg = err.to_string();
1054 assert!(
1055 msg.contains("newer"),
1056 "error message must contain 'newer', got: {msg}"
1057 );
1058 }
1059
1060 #[test]
1064 fn v3_to_v4_migration_adds_memory_edges() {
1065 let conn = Connection::open_in_memory().expect("open_in_memory");
1066 create_baseline(&conn).expect("create_baseline");
1068 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1069 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1070 conn.execute(
1071 "UPDATE schema_info SET value = 3 WHERE key = 'kimetsu_schema_version'",
1072 [],
1073 )
1074 .expect("set v3");
1075
1076 assert!(
1078 !table_exists(&conn, "memory_edges"),
1079 "memory_edges must not exist before v4 migration"
1080 );
1081
1082 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1084
1085 assert!(
1087 table_exists(&conn, "memory_edges"),
1088 "memory_edges must exist after v4 migration"
1089 );
1090
1091 let src_idx: i64 = conn
1093 .query_row(
1094 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_src'",
1095 [],
1096 |r| r.get(0),
1097 )
1098 .expect("query idx_memory_edges_src");
1099 assert_eq!(src_idx, 1, "idx_memory_edges_src must exist");
1100
1101 let dst_idx: i64 = conn
1102 .query_row(
1103 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memory_edges_dst'",
1104 [],
1105 |r| r.get(0),
1106 )
1107 .expect("query idx_memory_edges_dst");
1108 assert_eq!(dst_idx, 1, "idx_memory_edges_dst must exist");
1109 }
1110
1111 #[test]
1115 fn v4_to_v5_migration_adds_work_episodes() {
1116 let conn = Connection::open_in_memory().expect("open_in_memory");
1117 create_baseline(&conn).expect("create_baseline");
1119 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1120 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1121 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1122 conn.execute(
1123 "UPDATE schema_info SET value = 4 WHERE key = 'kimetsu_schema_version'",
1124 [],
1125 )
1126 .expect("set v4");
1127
1128 assert!(
1130 !table_exists(&conn, "work_episodes"),
1131 "work_episodes must not exist before v5 migration"
1132 );
1133
1134 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1136
1137 assert!(
1139 table_exists(&conn, "work_episodes"),
1140 "work_episodes must exist after v5 migration"
1141 );
1142
1143 let idx: i64 = conn
1145 .query_row(
1146 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_episodes_repo_live'",
1147 [],
1148 |r| r.get(0),
1149 )
1150 .expect("query idx_episodes_repo_live");
1151 assert_eq!(idx, 1, "idx_episodes_repo_live must exist");
1152 }
1153
1154 #[test]
1158 fn v5_to_v6_migration_adds_skill_proposals() {
1159 let conn = Connection::open_in_memory().expect("open_in_memory");
1160 create_baseline(&conn).expect("create_baseline");
1162 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1163 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1164 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1165 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1166 conn.execute(
1167 "UPDATE schema_info SET value = 5 WHERE key = 'kimetsu_schema_version'",
1168 [],
1169 )
1170 .expect("set v5");
1171
1172 assert!(
1174 !table_exists(&conn, "skill_proposals"),
1175 "skill_proposals must not exist before v6 migration"
1176 );
1177
1178 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1180
1181 assert!(
1183 table_exists(&conn, "skill_proposals"),
1184 "skill_proposals must exist after v6 migration"
1185 );
1186
1187 let idx: i64 = conn
1189 .query_row(
1190 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_status'",
1191 [],
1192 |r| r.get(0),
1193 )
1194 .expect("query idx_skill_proposals_status");
1195 assert_eq!(
1196 idx, 1,
1197 "idx_skill_proposals_status must exist after v6 migration"
1198 );
1199 }
1200
1201 #[test]
1205 fn v6_to_v7_migration_adds_temporal_validity_columns() {
1206 let conn = Connection::open_in_memory().expect("open_in_memory");
1207 create_baseline(&conn).expect("create_baseline");
1209 migrate_v1_to_v2(&conn).expect("migrate_v1_to_v2");
1210 migrate_v2_to_v3(&conn).expect("migrate_v2_to_v3");
1211 migrate_v3_to_v4(&conn).expect("migrate_v3_to_v4");
1212 migrate_v4_to_v5(&conn).expect("migrate_v4_to_v5");
1213 migrate_v5_to_v6(&conn).expect("migrate_v5_to_v6");
1214 conn.execute(
1215 "UPDATE schema_info SET value = 6 WHERE key = 'kimetsu_schema_version'",
1216 [],
1217 )
1218 .expect("set v6");
1219
1220 let cols_before = column_names(&conn, "memories");
1222 assert!(
1223 !cols_before.contains(&"valid_from".to_string()),
1224 "valid_from must not exist before v7 migration"
1225 );
1226 assert!(
1227 !cols_before.contains(&"valid_to".to_string()),
1228 "valid_to must not exist before v7 migration"
1229 );
1230
1231 migrate_v6_to_v7(&conn).expect("migrate_v6_to_v7");
1233
1234 let cols_after = column_names(&conn, "memories");
1236 assert!(
1237 cols_after.contains(&"valid_from".to_string()),
1238 "valid_from must exist after v7 migration"
1239 );
1240 assert!(
1241 cols_after.contains(&"valid_to".to_string()),
1242 "valid_to must exist after v7 migration"
1243 );
1244
1245 let idx: i64 = conn
1247 .query_row(
1248 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_valid_to'",
1249 [],
1250 |r| r.get(0),
1251 )
1252 .expect("query idx_memories_valid_to");
1253 assert_eq!(
1254 idx, 1,
1255 "idx_memories_valid_to must exist after v7 migration"
1256 );
1257 }
1258}