1use rusqlite::Connection;
2
3use crate::error::SqliteError;
4
5pub struct Migration {
10 pub id: &'static str,
11 pub up_sql: &'static str,
12 pub down_sql: Option<&'static str>,
13 pub is_already_applied: Option<fn(&Connection) -> bool>,
14}
15
16pub struct ServiceSchemaPlan {
17 pub service: &'static str,
18 pub sqlite: &'static [Migration],
19 pub postgres: &'static [Migration],
20}
21
22const SCHEMA_VERSION_TABLE: &str = "\
23 CREATE TABLE IF NOT EXISTS _schema_versions (\
24 service TEXT NOT NULL,\
25 migration_id TEXT NOT NULL,\
26 applied_at INTEGER NOT NULL,\
27 PRIMARY KEY (service, migration_id)\
28 );\
29";
30
31pub fn apply_schema_plan(conn: &Connection, plan: &ServiceSchemaPlan) -> Result<(), SqliteError> {
32 conn.execute_batch(SCHEMA_VERSION_TABLE)?;
33
34 for migration in plan.sqlite {
35 if let Some(check) = migration.is_already_applied {
37 if check(conn) {
38 continue;
39 }
40 }
41
42 let already: bool = conn.query_row(
44 "SELECT COUNT(*) > 0 FROM _schema_versions WHERE service = ?1 AND migration_id = ?2",
45 rusqlite::params![plan.service, migration.id],
46 |row| row.get(0),
47 )?;
48
49 if already {
50 continue;
51 }
52
53 conn.execute_batch(migration.up_sql)?;
55
56 conn.execute(
58 "INSERT INTO _schema_versions (service, migration_id, applied_at) VALUES (?1, ?2, ?3)",
59 rusqlite::params![
60 plan.service,
61 migration.id,
62 chrono::Utc::now().timestamp_micros(),
63 ],
64 )?;
65 }
66
67 Ok(())
68}
69
70pub struct VersionedMigration {
80 pub version: u32,
82 pub name: &'static str,
84 pub up: &'static str,
87}
88
89const V1_UP: &str = "\
91 CREATE TABLE IF NOT EXISTS entities (\
92 id TEXT PRIMARY KEY,\
93 namespace TEXT NOT NULL,\
94 kind TEXT NOT NULL,\
95 name TEXT NOT NULL,\
96 description TEXT,\
97 properties TEXT,\
98 tags TEXT NOT NULL DEFAULT '[]',\
99 created_at INTEGER NOT NULL,\
100 updated_at INTEGER NOT NULL,\
101 deleted_at INTEGER\
102 );\
103 CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace);\
104 CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(namespace, kind);\
105 CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(namespace, name);\
106 CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at DESC);\
107 CREATE TABLE IF NOT EXISTS graph_edges (\
108 namespace TEXT NOT NULL,\
109 id TEXT NOT NULL,\
110 source_id TEXT NOT NULL,\
111 target_id TEXT NOT NULL,\
112 relation TEXT NOT NULL,\
113 weight REAL NOT NULL DEFAULT 1.0,\
114 created_at INTEGER NOT NULL,\
115 metadata TEXT,\
116 PRIMARY KEY (namespace, id)\
117 );\
118 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_source ON graph_edges(namespace, source_id);\
119 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_target ON graph_edges(namespace, target_id);\
120 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_relation ON graph_edges(namespace, relation);\
121 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_src_rel ON graph_edges(namespace, source_id, relation);\
122 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_tgt_rel ON graph_edges(namespace, target_id, relation);\
123 CREATE TABLE IF NOT EXISTS notes (\
124 id TEXT PRIMARY KEY,\
125 namespace TEXT NOT NULL,\
126 kind TEXT NOT NULL,\
127 content TEXT NOT NULL DEFAULT '',\
128 salience REAL NOT NULL DEFAULT 0.5,\
129 decay_factor REAL NOT NULL DEFAULT 0.0,\
130 expires_at INTEGER,\
131 properties TEXT,\
132 created_at INTEGER NOT NULL,\
133 updated_at INTEGER NOT NULL,\
134 deleted_at INTEGER\
135 );\
136 CREATE INDEX IF NOT EXISTS idx_notes_namespace ON notes(namespace);\
137 CREATE INDEX IF NOT EXISTS idx_notes_kind ON notes(namespace, kind);\
138 CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);\
139 CREATE TABLE IF NOT EXISTS events (\
140 id TEXT PRIMARY KEY,\
141 namespace TEXT NOT NULL,\
142 verb TEXT NOT NULL,\
143 substrate TEXT NOT NULL,\
144 actor TEXT NOT NULL,\
145 outcome TEXT NOT NULL,\
146 data TEXT,\
147 duration_us INTEGER NOT NULL DEFAULT 0,\
148 target_id TEXT,\
149 created_at INTEGER NOT NULL\
150 );\
151 CREATE INDEX IF NOT EXISTS idx_events_namespace ON events(namespace);\
152 CREATE INDEX IF NOT EXISTS idx_events_verb ON events(verb);\
153 CREATE INDEX IF NOT EXISTS idx_events_substrate ON events(substrate);\
154 CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);\
155";
156
157const V4_DEDUPE_GRAPH_EDGE_TRIPLES: &str = "\
192 DELETE FROM graph_edges \
193 WHERE rowid NOT IN (\
194 SELECT MIN(rowid) \
195 FROM graph_edges \
196 GROUP BY namespace, source_id, target_id, relation\
197 );\
198 CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique_triple \
199 ON graph_edges(namespace, source_id, target_id, relation);\
200";
201
202const V5_ADD_ENTITY_TYPE_TO_ENTITIES: &str = "\
203 ALTER TABLE entities ADD COLUMN entity_type TEXT NULL;\
204 CREATE INDEX IF NOT EXISTS idx_entities_kind_entity_type \
205 ON entities(namespace, kind, entity_type);\
206";
207
208const V9_EDGE_LIFECYCLE_AND_TARGET_BACKEND: &str = "\
209 DROP INDEX IF EXISTS idx_graph_edges_unique_triple;\
210 DROP INDEX IF EXISTS idx_graph_edges_ns_source;\
211 DROP INDEX IF EXISTS idx_graph_edges_ns_target;\
212 DROP INDEX IF EXISTS idx_graph_edges_ns_relation;\
213 DROP INDEX IF EXISTS idx_graph_edges_ns_src_rel;\
214 DROP INDEX IF EXISTS idx_graph_edges_ns_tgt_rel;\
215 CREATE TABLE graph_edges_new (\
216 namespace TEXT NOT NULL,\
217 id TEXT NOT NULL,\
218 source_id TEXT NOT NULL,\
219 target_id TEXT NOT NULL,\
220 relation TEXT NOT NULL,\
221 weight REAL NOT NULL DEFAULT 1.0,\
222 created_at INTEGER NOT NULL,\
223 updated_at INTEGER NOT NULL,\
224 deleted_at INTEGER,\
225 metadata TEXT,\
226 target_backend TEXT,\
227 PRIMARY KEY (namespace, id)\
228 );\
229 INSERT INTO graph_edges_new \
230 (namespace, id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, metadata, target_backend) \
231 SELECT namespace, id, source_id, target_id, relation, weight, created_at, created_at, NULL, metadata, NULL \
232 FROM graph_edges;\
233 DROP TABLE graph_edges;\
234 ALTER TABLE graph_edges_new RENAME TO graph_edges;\
235 CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique_triple ON graph_edges(namespace, source_id, target_id, relation);\
236 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_source ON graph_edges(namespace, source_id);\
237 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_target ON graph_edges(namespace, target_id);\
238 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_relation ON graph_edges(namespace, relation);\
239 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_src_rel ON graph_edges(namespace, source_id, relation);\
240 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_tgt_rel ON graph_edges(namespace, target_id, relation);\
241 CREATE INDEX IF NOT EXISTS idx_graph_edges_target_backend ON graph_edges(target_backend) WHERE target_backend IS NOT NULL;\
242";
243
244const V10_NOTE_STATUS_AND_NULLABLE_METRICS: &str = "\
260 ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';\
261";
262
263const V11_ENTITY_TOMBSTONE_COLUMNS: &str = "\
275 ALTER TABLE entities ADD COLUMN merged_into TEXT;\
276 ALTER TABLE entities ADD COLUMN merge_event_id TEXT;\
277 CREATE INDEX IF NOT EXISTS idx_entities_merged_into ON entities(namespace, merged_into);\
278";
279
280const V12_NULLABLE_NOTE_METRICS: &str = "\
292 CREATE TABLE notes_new (\
293 id TEXT PRIMARY KEY,\
294 namespace TEXT NOT NULL,\
295 kind TEXT NOT NULL,\
296 status TEXT NOT NULL DEFAULT 'active',\
297 name TEXT,\
298 content TEXT NOT NULL DEFAULT '',\
299 salience REAL,\
300 decay_factor REAL,\
301 expires_at INTEGER,\
302 properties TEXT,\
303 created_at INTEGER NOT NULL,\
304 updated_at INTEGER NOT NULL,\
305 deleted_at INTEGER\
306 );\
307 INSERT INTO notes_new \
308 (id, namespace, kind, status, name, content, salience, decay_factor, \
309 expires_at, properties, created_at, updated_at, deleted_at) \
310 SELECT \
311 id, namespace, kind, status, name, content, salience, decay_factor, \
312 expires_at, properties, created_at, updated_at, deleted_at \
313 FROM notes;\
314 DROP TABLE notes;\
315 ALTER TABLE notes_new RENAME TO notes;\
316 CREATE INDEX IF NOT EXISTS idx_notes_namespace ON notes(namespace);\
317 CREATE INDEX IF NOT EXISTS idx_notes_kind ON notes(namespace, kind);\
318 CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);\
319";
320
321const V13_EVENT_OBSERVABILITY_PROVENANCE: &str = "__v13_computed_at_runtime__";
326
327pub const EMBEDDING_MODELS_DDL: &str = "\
334 CREATE TABLE IF NOT EXISTS _embedding_models (\
335 id BLOB PRIMARY KEY,\
336 engine_name TEXT NOT NULL,\
337 model_id TEXT NOT NULL,\
338 key_version TEXT NOT NULL,\
339 dim INTEGER NOT NULL,\
340 output_dim INTEGER,\
341 status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'superseded', 'archived')),\
342 activated_at INTEGER,\
343 superseded_at INTEGER,\
344 superseded_by BLOB,\
345 canonical_key BLOB NOT NULL UNIQUE,\
346 created_at INTEGER NOT NULL\
347 );\
348 CREATE UNIQUE INDEX IF NOT EXISTS idx_embed_models_one_active \
349 ON _embedding_models(engine_name) WHERE status = 'active';\
350 CREATE INDEX IF NOT EXISTS idx_embed_models_engine_status \
351 ON _embedding_models(engine_name, status);";
352
353const V14_EMBEDDING_MODEL_REGISTRY: &str = "__v14_computed_at_runtime__";
373
374const V16_VECTOR_EMBEDDING_MODEL_TAG: &str = "__v16_computed_at_runtime__";
382
383const V17_VECTOR_EMBEDDING_MODEL_TAG_PRESERVING_REBUILD: &str = "__v17_computed_at_runtime__";
412
413const V15_PROPOSALS_OPEN: &str = "\
420 CREATE TABLE IF NOT EXISTS proposals_open (\
421 proposal_id TEXT PRIMARY KEY,\
422 namespace TEXT NOT NULL,\
423 proposer TEXT NOT NULL,\
424 title TEXT NOT NULL,\
425 status TEXT NOT NULL CHECK (status IN ('open', 'changes_requested', 'approved', 'rejected', 'applied', 'withdrawn')),\
426 created_at INTEGER NOT NULL,\
427 updated_at INTEGER NOT NULL,\
428 expiry INTEGER,\
429 last_decision TEXT,\
430 review_count INTEGER NOT NULL DEFAULT 0,\
431 approve_count INTEGER NOT NULL DEFAULT 0,\
432 reject_count INTEGER NOT NULL DEFAULT 0\
433 );\
434 CREATE INDEX IF NOT EXISTS idx_proposals_open_ns_status ON proposals_open(namespace, status);\
435 CREATE INDEX IF NOT EXISTS idx_proposals_open_proposer ON proposals_open(namespace, proposer);\
436 CREATE INDEX IF NOT EXISTS idx_proposals_open_updated ON proposals_open(namespace, updated_at DESC);\
437";
438
439const V19_KNOWLEDGE_ATOMS_AND_DOMAINS: &str = "\
445 CREATE TABLE IF NOT EXISTS knowledge_atoms (\
446 id TEXT PRIMARY KEY,\
447 namespace TEXT NOT NULL,\
448 slug TEXT NOT NULL,\
449 name TEXT NOT NULL,\
450 description TEXT,\
451 content TEXT NOT NULL DEFAULT '',\
452 tags TEXT NOT NULL DEFAULT '[]',\
453 properties TEXT,\
454 finalized INTEGER NOT NULL DEFAULT 0,\
455 created_at INTEGER NOT NULL,\
456 updated_at INTEGER NOT NULL,\
457 deleted_at INTEGER\
458 );\
459 CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_slug \
460 ON knowledge_atoms(namespace, slug);\
461 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns \
462 ON knowledge_atoms(namespace);\
463 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_created \
464 ON knowledge_atoms(namespace, created_at DESC);\
465 CREATE TABLE IF NOT EXISTS knowledge_domains (\
466 id TEXT PRIMARY KEY,\
467 namespace TEXT NOT NULL,\
468 slug TEXT NOT NULL,\
469 name TEXT NOT NULL,\
470 description TEXT,\
471 tags TEXT NOT NULL DEFAULT '[]',\
472 members TEXT NOT NULL DEFAULT '[]',\
473 created_at INTEGER NOT NULL,\
474 updated_at INTEGER NOT NULL,\
475 deleted_at INTEGER\
476 );\
477 CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_domains_ns_slug \
478 ON knowledge_domains(namespace, slug);\
479 CREATE INDEX IF NOT EXISTS idx_knowledge_domains_ns \
480 ON knowledge_domains(namespace);\
481 CREATE VIRTUAL TABLE IF NOT EXISTS fts_knowledge \
482 USING fts5(\
483 id UNINDEXED,\
484 namespace UNINDEXED,\
485 slug,\
486 name,\
487 description,\
488 content,\
489 content=knowledge_atoms,\
490 content_rowid=rowid,\
491 tokenize='trigram case_sensitive 0'\
492 );\
493 CREATE TRIGGER IF NOT EXISTS fts_knowledge_ai \
494 AFTER INSERT ON knowledge_atoms \
495 WHEN new.deleted_at IS NULL BEGIN \
496 INSERT INTO fts_knowledge(rowid, id, namespace, slug, name, description, content) \
497 VALUES(new.rowid, new.id, new.namespace, new.slug, new.name, new.description, new.content); \
498 END; \
499 CREATE TRIGGER IF NOT EXISTS fts_knowledge_ad \
500 AFTER DELETE ON knowledge_atoms BEGIN \
501 INSERT INTO fts_knowledge(fts_knowledge, rowid, id, namespace, slug, name, description, content) \
502 VALUES('delete', old.rowid, old.id, old.namespace, old.slug, old.name, old.description, old.content); \
503 END; \
504 CREATE TRIGGER IF NOT EXISTS fts_knowledge_au \
505 AFTER UPDATE ON knowledge_atoms BEGIN \
506 INSERT INTO fts_knowledge(fts_knowledge, rowid, id, namespace, slug, name, description, content) \
507 VALUES('delete', old.rowid, old.id, old.namespace, old.slug, old.name, old.description, old.content); \
508 INSERT INTO fts_knowledge(rowid, id, namespace, slug, name, description, content) \
509 SELECT new.rowid, new.id, new.namespace, new.slug, new.name, new.description, new.content \
510 WHERE new.deleted_at IS NULL; \
511 END;\
512";
513
514const V20_BRAIN_PROFILE_PERSISTENCE: &str = "\
521 CREATE TABLE IF NOT EXISTS brain_profile_snapshots (\
522 profile_id TEXT NOT NULL,\
523 namespace TEXT NOT NULL DEFAULT 'default',\
524 snapshot_json TEXT NOT NULL,\
525 updated_at INTEGER NOT NULL,\
526 PRIMARY KEY (profile_id, namespace)\
527 );\
528 CREATE TABLE IF NOT EXISTS brain_event_log (\
529 id INTEGER PRIMARY KEY AUTOINCREMENT,\
530 profile_id TEXT NOT NULL,\
531 namespace TEXT NOT NULL DEFAULT 'default',\
532 event_kind TEXT NOT NULL,\
533 payload TEXT NOT NULL,\
534 created_at INTEGER NOT NULL\
535 );\
536 CREATE INDEX IF NOT EXISTS idx_brain_events_profile \
537 ON brain_event_log(profile_id, namespace, created_at);\
538";
539
540const V22_KNOWLEDGE_LIFECYCLE_STATUS: &str = "\
558 ALTER TABLE knowledge_atoms ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
559 ALTER TABLE knowledge_atoms ADD COLUMN source_uri TEXT;\
560 ALTER TABLE knowledge_atoms ADD COLUMN source_type TEXT;\
561 ALTER TABLE knowledge_sections ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
562 ALTER TABLE knowledge_domains ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
563 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_status \
564 ON knowledge_atoms(namespace, status);\
565 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_status \
566 ON knowledge_sections(status);\
567 CREATE INDEX IF NOT EXISTS idx_knowledge_domains_ns_status \
568 ON knowledge_domains(namespace, status);\
569 UPDATE knowledge_atoms SET status = 'reviewed' WHERE finalized = 1;\
570";
571
572const V21_KNOWLEDGE_SECTIONS: &str = "\
585 CREATE TABLE IF NOT EXISTS knowledge_sections (\
586 id TEXT PRIMARY KEY,\
587 atom_id TEXT NOT NULL,\
588 namespace TEXT NOT NULL,\
589 section_type TEXT NOT NULL,\
590 heading TEXT NOT NULL DEFAULT '',\
591 content TEXT NOT NULL DEFAULT '',\
592 tokens INTEGER NOT NULL DEFAULT 0,\
593 sort_order INTEGER NOT NULL DEFAULT 0,\
594 embedding BLOB,\
595 created_at INTEGER NOT NULL,\
596 updated_at INTEGER NOT NULL,\
597 FOREIGN KEY (atom_id) REFERENCES knowledge_atoms(id),\
598 UNIQUE(atom_id, section_type)\
599 );\
600 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_atom \
601 ON knowledge_sections(atom_id);\
602 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_ns_type \
603 ON knowledge_sections(namespace, section_type);\
604 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_ns_atom \
605 ON knowledge_sections(namespace, atom_id);\
606 CREATE VIRTUAL TABLE IF NOT EXISTS fts_sections \
607 USING fts5(\
608 id UNINDEXED,\
609 namespace UNINDEXED,\
610 atom_id UNINDEXED,\
611 section_type UNINDEXED,\
612 heading,\
613 content,\
614 content=knowledge_sections,\
615 content_rowid=rowid,\
616 tokenize='trigram case_sensitive 0'\
617 );\
618 CREATE TRIGGER IF NOT EXISTS fts_sections_ai \
619 AFTER INSERT ON knowledge_sections BEGIN \
620 INSERT INTO fts_sections(rowid, id, namespace, atom_id, section_type, heading, content) \
621 VALUES(new.rowid, new.id, new.namespace, new.atom_id, new.section_type, new.heading, new.content); \
622 END; \
623 CREATE TRIGGER IF NOT EXISTS fts_sections_ad \
624 AFTER DELETE ON knowledge_sections BEGIN \
625 INSERT INTO fts_sections(fts_sections, rowid, id, namespace, atom_id, section_type, heading, content) \
626 VALUES('delete', old.rowid, old.id, old.namespace, old.atom_id, old.section_type, old.heading, old.content); \
627 END; \
628 CREATE TRIGGER IF NOT EXISTS fts_sections_au \
629 AFTER UPDATE ON knowledge_sections BEGIN \
630 INSERT INTO fts_sections(fts_sections, rowid, id, namespace, atom_id, section_type, heading, content) \
631 VALUES('delete', old.rowid, old.id, old.namespace, old.atom_id, old.section_type, old.heading, old.content); \
632 INSERT INTO fts_sections(rowid, id, namespace, atom_id, section_type, heading, content) \
633 VALUES(new.rowid, new.id, new.namespace, new.atom_id, new.section_type, new.heading, new.content); \
634 END;\
635";
636
637pub const MIGRATIONS: &[VersionedMigration] = &[
638 VersionedMigration {
639 version: 1,
640 name: "initial_schema",
641 up: V1_UP,
642 },
643 VersionedMigration {
644 version: 2,
645 name: "add_name_to_notes",
646 up: "ALTER TABLE notes ADD COLUMN name TEXT;",
647 },
648 VersionedMigration {
649 version: 3,
650 name: "add_events_namespace_created_index",
651 up: "CREATE INDEX IF NOT EXISTS idx_events_ns_created ON events(namespace, created_at DESC);",
652 },
653 VersionedMigration {
654 version: 4,
655 name: "dedupe_graph_edge_triples",
656 up: V4_DEDUPE_GRAPH_EDGE_TRIPLES,
657 },
658 VersionedMigration {
659 version: 5,
660 name: "add_entity_type_to_entities",
661 up: V5_ADD_ENTITY_TYPE_TO_ENTITIES,
662 },
663 VersionedMigration {
675 version: 6,
676 name: "reserved_adr043_embedding_pipeline_extensions",
677 up: "SELECT 1;",
678 },
679 VersionedMigration {
680 version: 7,
681 name: "reserved_adr046_event_sourced_proposals_index",
682 up: "SELECT 1;",
683 },
684 VersionedMigration {
685 version: 8,
686 name: "reserved_adr041_event_observations_and_session_id",
687 up: "SELECT 1;",
688 },
689 VersionedMigration {
690 version: 9,
691 name: "edge_lifecycle_and_target_backend",
692 up: V9_EDGE_LIFECYCLE_AND_TARGET_BACKEND,
693 },
694 VersionedMigration {
695 version: 10,
696 name: "note_status_and_nullable_metrics",
697 up: V10_NOTE_STATUS_AND_NULLABLE_METRICS,
698 },
699 VersionedMigration {
700 version: 11,
701 name: "entity_tombstone_columns",
702 up: V11_ENTITY_TOMBSTONE_COLUMNS,
703 },
704 VersionedMigration {
705 version: 12,
706 name: "nullable_note_metrics",
707 up: V12_NULLABLE_NOTE_METRICS,
708 },
709 VersionedMigration {
710 version: 13,
711 name: "event_observability_provenance",
712 up: V13_EVENT_OBSERVABILITY_PROVENANCE,
713 },
714 VersionedMigration {
715 version: 14,
716 name: "embedding_model_registry",
717 up: V14_EMBEDDING_MODEL_REGISTRY,
718 },
719 VersionedMigration {
721 version: 15,
722 name: "proposals_open",
723 up: V15_PROPOSALS_OPEN,
724 },
725 VersionedMigration {
727 version: 16,
728 name: "vector_embedding_model_tag",
729 up: V16_VECTOR_EMBEDDING_MODEL_TAG,
730 },
731 VersionedMigration {
735 version: 17,
736 name: "vector_embedding_model_tag_preserving_rebuild",
737 up: V17_VECTOR_EMBEDDING_MODEL_TAG_PRESERVING_REBUILD,
738 },
739 VersionedMigration {
741 version: 18,
742 name: "proposals_open_add_applying_status",
743 up: "__v18_computed_at_runtime__",
744 },
745 VersionedMigration {
747 version: 19,
748 name: "knowledge_atoms_and_domains",
749 up: V19_KNOWLEDGE_ATOMS_AND_DOMAINS,
750 },
751 VersionedMigration {
752 version: 20,
753 name: "brain_profile_persistence",
754 up: V20_BRAIN_PROFILE_PERSISTENCE,
755 },
756 VersionedMigration {
760 version: 21,
761 name: "knowledge_sections",
762 up: V21_KNOWLEDGE_SECTIONS,
763 },
764 VersionedMigration {
769 version: 22,
770 name: "knowledge_lifecycle_status",
771 up: V22_KNOWLEDGE_LIFECYCLE_STATUS,
772 },
773];
774
775const MIGRATION_TRACKING_TABLE: &str = "\
776 CREATE TABLE IF NOT EXISTS _schema_migrations (\
777 version INTEGER PRIMARY KEY,\
778 name TEXT NOT NULL,\
779 applied_at INTEGER NOT NULL\
780 );\
781";
782
783pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
804 for (i, m) in MIGRATIONS.iter().enumerate() {
805 let expected = (i + 1) as u32;
806 if m.version != expected {
807 return Err(SqliteError::InvalidData(format!(
808 "MIGRATIONS array is not contiguous: expected version {expected} at index {i}, \
809 got version {}",
810 m.version
811 )));
812 }
813 }
814
815 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
816
817 let current_version: u32 = conn
819 .query_row(
820 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
821 [],
822 |row| row.get(0),
823 )
824 .unwrap_or(0);
825
826 let mut applied_version = current_version;
827
828 for migration in MIGRATIONS {
829 if migration.version <= current_version {
830 continue;
831 }
832
833 if migration.version == 2 {
838 let col_exists: bool = conn
839 .query_row(
840 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'name'",
841 [],
842 |row| row.get(0),
843 )
844 .unwrap_or(false);
845 if col_exists {
846 let now = chrono::Utc::now().timestamp_micros();
848 conn.execute(
849 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
850 VALUES (?1, ?2, ?3)",
851 rusqlite::params![migration.version, migration.name, now],
852 )
853 .map_err(|e| SqliteError::Migration {
854 version: migration.version,
855 error: e.to_string(),
856 })?;
857 applied_version = migration.version;
858 continue;
859 }
860 }
861
862 if migration.version == 5 {
866 let col_exists: bool = conn
867 .query_row(
868 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'entity_type'",
869 [],
870 |row| row.get(0),
871 )
872 .unwrap_or(false);
873 if col_exists {
874 let now = chrono::Utc::now().timestamp_micros();
875 conn.execute(
876 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
877 VALUES (?1, ?2, ?3)",
878 rusqlite::params![migration.version, migration.name, now],
879 )
880 .map_err(|e| SqliteError::Migration {
881 version: migration.version,
882 error: e.to_string(),
883 })?;
884 applied_version = migration.version;
885 continue;
886 }
887 }
888
889 if migration.version == 10 {
894 let col_exists: bool = conn
895 .query_row(
896 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'status'",
897 [],
898 |row| row.get(0),
899 )
900 .unwrap_or(false);
901 if col_exists {
902 let now = chrono::Utc::now().timestamp_micros();
903 conn.execute(
904 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
905 VALUES (?1, ?2, ?3)",
906 rusqlite::params![migration.version, migration.name, now],
907 )
908 .map_err(|e| SqliteError::Migration {
909 version: migration.version,
910 error: e.to_string(),
911 })?;
912 applied_version = migration.version;
913 continue;
914 }
915 }
916
917 if migration.version == 11 {
922 let col_exists: bool = conn
923 .query_row(
924 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'merged_into'",
925 [],
926 |row| row.get(0),
927 )
928 .unwrap_or(false);
929 if col_exists {
930 let now = chrono::Utc::now().timestamp_micros();
931 conn.execute(
932 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
933 VALUES (?1, ?2, ?3)",
934 rusqlite::params![migration.version, migration.name, now],
935 )
936 .map_err(|e| SqliteError::Migration {
937 version: migration.version,
938 error: e.to_string(),
939 })?;
940 applied_version = migration.version;
941 continue;
942 }
943 }
944
945 if migration.version == 12 {
950 let already_nullable: bool = conn
951 .query_row(
952 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') \
953 WHERE name = 'salience' AND \"notnull\" = 0",
954 [],
955 |row| row.get(0),
956 )
957 .unwrap_or(false);
958 if already_nullable {
959 let now = chrono::Utc::now().timestamp_micros();
960 conn.execute(
961 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
962 VALUES (?1, ?2, ?3)",
963 rusqlite::params![migration.version, migration.name, now],
964 )
965 .map_err(|e| SqliteError::Migration {
966 version: migration.version,
967 error: e.to_string(),
968 })?;
969 applied_version = migration.version;
970 continue;
971 }
972 }
973
974 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
975 version: migration.version,
976 error: e.to_string(),
977 })?;
978
979 let up_sql = if migration.version == 13 {
980 build_v13_event_observability_sql(&tx).map_err(|e| SqliteError::Migration {
981 version: migration.version,
982 error: e.to_string(),
983 })?
984 } else if migration.version == 14 {
985 build_v14_embedding_model_registry_sql(&tx).map_err(|e| SqliteError::Migration {
986 version: migration.version,
987 error: e.to_string(),
988 })?
989 } else if migration.version == 16 {
990 build_v16_vector_embedding_model_tag_sql(&tx).map_err(|e| SqliteError::Migration {
991 version: migration.version,
992 error: e.to_string(),
993 })?
994 } else if migration.version == 17 {
995 build_v17_preserving_rebuild_sql(&tx).map_err(|e| SqliteError::Migration {
996 version: migration.version,
997 error: e.to_string(),
998 })?
999 } else if migration.version == 18 {
1000 build_v18_proposals_applying_sql(&tx).map_err(|e| SqliteError::Migration {
1001 version: migration.version,
1002 error: e.to_string(),
1003 })?
1004 } else {
1005 migration.up.to_string()
1006 };
1007
1008 tx.execute_batch(&up_sql)
1009 .map_err(|e| SqliteError::Migration {
1010 version: migration.version,
1011 error: e.to_string(),
1012 })?;
1013
1014 let now = chrono::Utc::now().timestamp_micros();
1015 tx.execute(
1016 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
1017 rusqlite::params![migration.version, migration.name, now],
1018 )
1019 .map_err(|e| SqliteError::Migration {
1020 version: migration.version,
1021 error: e.to_string(),
1022 })?;
1023
1024 tx.commit().map_err(|e| SqliteError::Migration {
1025 version: migration.version,
1026 error: e.to_string(),
1027 })?;
1028
1029 applied_version = migration.version;
1030 }
1031
1032 Ok(applied_version)
1033}
1034
1035fn table_has_column(
1036 conn: &Connection,
1037 table: &'static str,
1038 column: &'static str,
1039) -> Result<bool, rusqlite::Error> {
1040 conn.query_row(
1041 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
1042 rusqlite::params![table, column],
1043 |row| row.get(0),
1044 )
1045}
1046
1047fn build_v13_event_observability_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1048 let mut sql = String::new();
1049 for (column, ddl) in [
1050 (
1051 "kind",
1052 "ALTER TABLE events ADD COLUMN kind TEXT NOT NULL DEFAULT 'audit';",
1053 ),
1054 (
1055 "payload",
1056 "ALTER TABLE events ADD COLUMN payload TEXT NOT NULL DEFAULT '{}';",
1057 ),
1058 (
1059 "payload_schema_version",
1060 "ALTER TABLE events ADD COLUMN payload_schema_version INTEGER NOT NULL DEFAULT 1;",
1061 ),
1062 (
1063 "profile_state_version",
1064 "ALTER TABLE events ADD COLUMN profile_state_version INTEGER;",
1065 ),
1066 (
1067 "session_id",
1068 "ALTER TABLE events ADD COLUMN session_id TEXT;",
1069 ),
1070 (
1071 "aggregate_kind",
1072 "ALTER TABLE events ADD COLUMN aggregate_kind TEXT;",
1073 ),
1074 (
1075 "aggregate_id",
1076 "ALTER TABLE events ADD COLUMN aggregate_id TEXT;",
1077 ),
1078 ] {
1079 if !table_has_column(conn, "events", column)? {
1080 sql.push_str(ddl);
1081 }
1082 }
1083 if table_has_column(conn, "events", "data")? && table_has_column(conn, "events", "payload")? {
1085 sql.push_str("UPDATE events SET payload = data WHERE data IS NOT NULL AND data <> '';");
1086 }
1087 sql.push_str(
1088 "CREATE TABLE IF NOT EXISTS event_observations (\
1089 event_id TEXT NOT NULL,\
1090 entity_id TEXT NOT NULL,\
1091 referent_kind TEXT NOT NULL,\
1092 role TEXT NOT NULL,\
1093 position INTEGER NOT NULL,\
1094 PRIMARY KEY (event_id, role, position)\
1095 );\
1096 CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);\
1097 CREATE INDEX IF NOT EXISTS idx_events_session ON events(namespace, session_id, created_at, id);\
1098 CREATE INDEX IF NOT EXISTS idx_events_ns_created_id ON events(namespace, created_at DESC, id DESC);\
1099 CREATE INDEX IF NOT EXISTS idx_events_payload_proposal_id ON events(json_extract(payload, '$.proposal_id'));\
1100 CREATE INDEX IF NOT EXISTS idx_event_obs_entity ON event_observations(entity_id, role);\
1101 CREATE INDEX IF NOT EXISTS idx_event_obs_event_role ON event_observations(event_id, role);",
1102 );
1103 Ok(sql)
1104}
1105
1106fn build_v14_embedding_model_registry_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1118 let mut sql = String::from(EMBEDDING_MODELS_DDL);
1119
1120 let mut stmt = conn.prepare(
1137 "SELECT name FROM sqlite_master \
1138 WHERE type = 'table' \
1139 AND name LIKE 'vec_%' \
1140 AND sql NOT LIKE '%VIRTUAL%' \
1141 AND sql NOT LIKE '%vec0%' \
1142 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1143 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1144 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1145 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1146 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1147 )?;
1148 let vec_tables: Vec<String> = stmt
1149 .query_map([], |row| row.get(0))?
1150 .filter_map(|r| r.ok())
1151 .collect();
1152
1153 for table in &vec_tables {
1154 let valid = table.starts_with("vec_")
1156 && table[4..]
1157 .chars()
1158 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1159 if !valid {
1160 continue;
1161 }
1162 let col_exists: bool = conn
1164 .query_row(
1165 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model_id'",
1166 rusqlite::params![table],
1167 |row| row.get(0),
1168 )
1169 .unwrap_or(false);
1170 if col_exists {
1171 continue;
1172 }
1173 sql.push_str(&format!(
1174 "ALTER TABLE {t} ADD COLUMN embedding_model_id BLOB REFERENCES _embedding_models(id);\
1175 CREATE INDEX IF NOT EXISTS idx_{t}_model ON {t}(embedding_model_id);",
1176 t = table,
1177 ));
1178 }
1179
1180 Ok(sql)
1181}
1182
1183fn build_v16_vector_embedding_model_tag_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1184 let mut stmt = conn.prepare(
1185 "SELECT name FROM sqlite_master \
1186 WHERE type = 'table' \
1187 AND name LIKE 'vec_%' \
1188 AND sql NOT LIKE '%VIRTUAL%' \
1189 AND sql NOT LIKE '%vec0%' \
1190 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1191 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1192 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1193 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1194 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1195 )?;
1196 let vec_tables: Vec<String> = stmt
1197 .query_map([], |row| row.get(0))?
1198 .filter_map(|r| r.ok())
1199 .collect();
1200
1201 let mut sql = String::new();
1202 for table in vec_tables {
1203 let valid = table.starts_with("vec_")
1204 && table[4..]
1205 .chars()
1206 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1207 if !valid {
1208 continue;
1209 }
1210 let col_exists: bool = conn
1211 .query_row(
1212 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model'",
1213 rusqlite::params![&table],
1214 |row| row.get(0),
1215 )
1216 .unwrap_or(false);
1217 if col_exists {
1218 continue;
1219 }
1220 sql.push_str(&format!(
1221 "ALTER TABLE {t} ADD COLUMN embedding_model TEXT NOT NULL DEFAULT 'all-minilm-l6-v2';\
1222 CREATE INDEX IF NOT EXISTS idx_{t}_subject_model ON {t}(subject_id, embedding_model);",
1223 t = table,
1224 ));
1225 }
1226 if sql.is_empty() {
1227 sql.push_str("SELECT 1;");
1228 }
1229 Ok(sql)
1230}
1231
1232fn infer_model_from_table_name(table: &str) -> String {
1242 let suffix = table.strip_prefix("vec_").unwrap_or("");
1243 if !suffix.is_empty()
1244 && suffix
1245 .chars()
1246 .all(|c| c.is_ascii_alphanumeric() || c == '_')
1247 {
1248 suffix.to_string()
1249 } else {
1250 "all-minilm-l6-v2".to_string()
1251 }
1252}
1253
1254pub fn build_v17_preserving_rebuild_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1274 let mut stmt = conn.prepare(
1281 "SELECT name, sql FROM sqlite_master \
1282 WHERE type = 'table' \
1283 AND name LIKE 'vec_%' \
1284 AND sql LIKE '%VIRTUAL%' \
1285 AND sql LIKE '%vec0%' \
1286 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1287 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1288 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1289 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1290 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1291 )?;
1292 let virtual_tables: Vec<(String, Option<String>)> = stmt
1293 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1294 .filter_map(|r| r.ok())
1295 .collect();
1296
1297 let mut sql = String::new();
1298
1299 for (table, ddl_opt) in &virtual_tables {
1300 let valid = table.starts_with("vec_")
1302 && table[4..]
1303 .chars()
1304 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1305 if !valid {
1306 continue;
1307 }
1308
1309 let mut has_field = false;
1311 let mut has_embedding_model = false;
1312
1313 let pragma = format!("PRAGMA table_xinfo({})", table);
1314 let mut col_stmt = conn.prepare(&pragma)?;
1315 let mut col_rows = col_stmt.query([])?;
1316 while let Some(row) = col_rows.next()? {
1317 let name: String = row.get(1)?;
1318 match name.as_str() {
1319 "field" => has_field = true,
1320 "embedding_model" => has_embedding_model = true,
1321 _ => {}
1322 }
1323 }
1324
1325 if has_field && has_embedding_model {
1326 continue;
1328 }
1329
1330 let dims = ddl_opt.as_deref().and_then(|ddl| {
1334 let lower = ddl.to_ascii_lowercase();
1335 let start = lower.find("float[")?;
1337 let rest = &lower[start + 6..];
1338 let end = rest.find(']')?;
1339 rest[..end].trim().parse::<u32>().ok()
1340 });
1341
1342 let dim = match dims {
1345 Some(d) => d,
1346 None => continue,
1347 };
1348
1349 let inferred_model = infer_model_from_table_name(table);
1350 let tmp = format!("tmp_{}", table);
1351
1352 let field_expr = if has_field {
1354 "field".to_string()
1355 } else {
1356 "'' AS field".to_string()
1357 };
1358 let model_expr = if has_embedding_model {
1359 "embedding_model".to_string()
1360 } else {
1361 format!("'{}' AS embedding_model", inferred_model)
1362 };
1363
1364 sql.push_str(&format!(
1366 "CREATE TABLE {tmp} (\
1367 subject_id TEXT PRIMARY KEY, \
1368 namespace TEXT NOT NULL, \
1369 kind TEXT NOT NULL, \
1370 field TEXT NOT NULL, \
1371 embedding_model TEXT NOT NULL, \
1372 embedding BLOB NOT NULL\
1373 );",
1374 tmp = tmp,
1375 ));
1376
1377 sql.push_str(&format!(
1379 "INSERT INTO {tmp} (subject_id, namespace, kind, field, embedding_model, embedding) \
1380 SELECT subject_id, namespace, kind, {field_expr}, {model_expr}, embedding \
1381 FROM {table};",
1382 tmp = tmp,
1383 field_expr = field_expr,
1384 model_expr = model_expr,
1385 table = table,
1386 ));
1387
1388 sql.push_str(&format!("DROP TABLE {table};", table = table));
1390
1391 sql.push_str(&format!(
1393 "CREATE VIRTUAL TABLE {table} USING vec0(\
1394 subject_id TEXT PRIMARY KEY, \
1395 namespace TEXT NOT NULL, \
1396 kind TEXT NOT NULL, \
1397 field TEXT NOT NULL, \
1398 embedding_model TEXT NOT NULL, \
1399 embedding float[{dim}] distance_metric=cosine\
1400 );",
1401 table = table,
1402 dim = dim,
1403 ));
1404
1405 sql.push_str(&format!(
1407 "INSERT INTO {table} (subject_id, namespace, kind, field, embedding_model, embedding) \
1408 SELECT subject_id, namespace, kind, field, embedding_model, embedding \
1409 FROM {tmp};",
1410 table = table,
1411 tmp = tmp,
1412 ));
1413
1414 sql.push_str(&format!("DROP TABLE {tmp};", tmp = tmp));
1416 }
1417
1418 if sql.is_empty() {
1419 sql.push_str("SELECT 1;");
1420 }
1421
1422 Ok(sql)
1423}
1424
1425#[derive(Clone, Debug)]
1427pub struct EmbeddingModelRegistryRecord {
1428 pub engine_name: String,
1429 pub model_id: String,
1430 pub key_version: String,
1431 pub dimensions: u32,
1432 pub status: String,
1433 pub activated_at: Option<i64>,
1434 pub superseded_at: Option<i64>,
1435}
1436
1437pub fn query_embedding_models(
1443 db: Option<&std::path::Path>,
1444 engine_filter: Option<&str>,
1445) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
1446 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
1447 std::env::var("HOME")
1448 .map(std::path::PathBuf::from)
1449 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1450 .join(".khive/khive-graph.db")
1451 });
1452 if !path.exists() {
1453 return Ok(Vec::new());
1454 }
1455
1456 let conn = Connection::open(path)?;
1457 let exists: bool = conn.query_row(
1458 "SELECT COUNT(*) > 0 FROM sqlite_master \
1459 WHERE type='table' AND name='_embedding_models'",
1460 [],
1461 |row| row.get(0),
1462 )?;
1463 if !exists {
1464 return Ok(Vec::new());
1465 }
1466
1467 let sql = if engine_filter.is_some() {
1468 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
1469 FROM _embedding_models WHERE engine_name = ?1 \
1470 ORDER BY engine_name, activated_at IS NULL, activated_at"
1471 } else {
1472 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
1473 FROM _embedding_models \
1474 ORDER BY engine_name, activated_at IS NULL, activated_at"
1475 };
1476 let mut stmt = conn.prepare(sql)?;
1477 let map_row = |row: &rusqlite::Row<'_>| {
1478 Ok(EmbeddingModelRegistryRecord {
1479 engine_name: row.get(0)?,
1480 model_id: row.get(1)?,
1481 key_version: row.get(2)?,
1482 dimensions: row.get::<_, i64>(3)? as u32,
1483 status: row.get(4)?,
1484 activated_at: row.get(5)?,
1485 superseded_at: row.get(6)?,
1486 })
1487 };
1488
1489 if let Some(engine) = engine_filter {
1490 stmt.query_map([engine], map_row)?
1491 .collect::<Result<Vec<_>, _>>()
1492 .map_err(Into::into)
1493 } else {
1494 stmt.query_map([], map_row)?
1495 .collect::<Result<Vec<_>, _>>()
1496 .map_err(Into::into)
1497 }
1498}
1499
1500pub(crate) fn build_v18_proposals_applying_sql(
1509 conn: &Connection,
1510) -> Result<String, rusqlite::Error> {
1511 let table_exists: bool = conn.query_row(
1512 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1513 [],
1514 |row| row.get(0),
1515 )?;
1516
1517 if !table_exists {
1518 return Ok("SELECT 1;".to_string());
1519 }
1520
1521 let ddl: String = conn.query_row(
1524 "SELECT sql FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1525 [],
1526 |row| row.get(0),
1527 )?;
1528 if ddl.contains("'applying'") {
1529 return Ok("SELECT 1;".to_string());
1530 }
1531
1532 Ok("\
1538 ALTER TABLE proposals_open RENAME TO proposals_open_v15;\
1539 CREATE TABLE proposals_open (\
1540 proposal_id TEXT PRIMARY KEY,\
1541 namespace TEXT NOT NULL,\
1542 proposer TEXT NOT NULL,\
1543 title TEXT NOT NULL,\
1544 status TEXT NOT NULL CHECK (status IN ('open', 'changes_requested', 'approved', 'applying', 'rejected', 'applied', 'withdrawn')),\
1545 created_at INTEGER NOT NULL,\
1546 updated_at INTEGER NOT NULL,\
1547 expiry INTEGER,\
1548 last_decision TEXT,\
1549 review_count INTEGER NOT NULL DEFAULT 0,\
1550 approve_count INTEGER NOT NULL DEFAULT 0,\
1551 reject_count INTEGER NOT NULL DEFAULT 0\
1552 );\
1553 INSERT INTO proposals_open \
1554 SELECT proposal_id, namespace, proposer, title, status, created_at, updated_at, \
1555 expiry, last_decision, review_count, approve_count, reject_count \
1556 FROM proposals_open_v15;\
1557 DROP TABLE proposals_open_v15;\
1558 CREATE INDEX IF NOT EXISTS idx_proposals_open_ns_status ON proposals_open(namespace, status);\
1559 CREATE INDEX IF NOT EXISTS idx_proposals_open_proposer ON proposals_open(namespace, proposer);\
1560 CREATE INDEX IF NOT EXISTS idx_proposals_open_updated ON proposals_open(namespace, updated_at DESC);\
1561 "
1562 .to_string())
1563}
1564
1565#[cfg(test)]
1570mod tests {
1571 use super::*;
1572
1573 fn open_memory() -> Connection {
1574 Connection::open_in_memory().expect("in-memory connection")
1575 }
1576
1577 #[test]
1578 fn fresh_db_migrates_to_latest() {
1579 let mut conn = open_memory();
1580 let version = run_migrations(&mut conn).expect("migrations should succeed");
1581 assert_eq!(version, 22);
1582
1583 let count: i64 = conn
1585 .query_row(
1586 "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22)",
1587 [],
1588 |row| row.get(0),
1589 )
1590 .unwrap();
1591 assert_eq!(count, 22);
1592
1593 let tbl_count: i64 = conn
1595 .query_row(
1596 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='entities'",
1597 [],
1598 |row| row.get(0),
1599 )
1600 .unwrap();
1601 assert_eq!(tbl_count, 1);
1602
1603 let col_count: i64 = conn
1605 .query_row(
1606 "SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name = 'name'",
1607 [],
1608 |row| row.get(0),
1609 )
1610 .unwrap();
1611 assert_eq!(col_count, 1, "V2 must add name column to notes");
1612
1613 let et_count: i64 = conn
1615 .query_row(
1616 "SELECT COUNT(*) FROM pragma_table_info('entities') WHERE name = 'entity_type'",
1617 [],
1618 |row| row.get(0),
1619 )
1620 .unwrap();
1621 assert_eq!(et_count, 1, "V5 must add entity_type column to entities");
1622
1623 let idx_count: i64 = conn
1625 .query_row(
1626 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
1627 AND name='idx_entities_kind_entity_type'",
1628 [],
1629 |row| row.get(0),
1630 )
1631 .unwrap();
1632 assert_eq!(idx_count, 1, "V5 must create idx_entities_kind_entity_type");
1633
1634 let status_col: i64 = conn
1636 .query_row(
1637 "SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name = 'status'",
1638 [],
1639 |row| row.get(0),
1640 )
1641 .unwrap();
1642 assert_eq!(status_col, 1, "V10 must add status column to notes");
1643
1644 let merged_into_col: i64 = conn
1646 .query_row(
1647 "SELECT COUNT(*) FROM pragma_table_info('entities') WHERE name = 'merged_into'",
1648 [],
1649 |row| row.get(0),
1650 )
1651 .unwrap();
1652 assert_eq!(
1653 merged_into_col, 1,
1654 "V11 must add merged_into column to entities"
1655 );
1656
1657 let salience_notnull: i64 = conn
1659 .query_row(
1660 "SELECT \"notnull\" FROM pragma_table_info('notes') WHERE name = 'salience'",
1661 [],
1662 |row| row.get(0),
1663 )
1664 .unwrap();
1665 assert_eq!(salience_notnull, 0, "V12 must make salience nullable");
1666
1667 for col in [
1669 "kind",
1670 "payload",
1671 "payload_schema_version",
1672 "profile_state_version",
1673 "session_id",
1674 "aggregate_kind",
1675 "aggregate_id",
1676 ] {
1677 let exists: bool = conn
1678 .query_row(
1679 "SELECT COUNT(*) > 0 FROM pragma_table_info('events') WHERE name = ?1",
1680 [col],
1681 |r| r.get(0),
1682 )
1683 .unwrap();
1684 assert!(exists, "V13 must add events.{col}");
1685 }
1686
1687 let obs_tbl: i64 = conn
1689 .query_row(
1690 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_observations'",
1691 [],
1692 |r| r.get(0),
1693 )
1694 .unwrap();
1695 assert_eq!(obs_tbl, 1, "V13 must create event_observations table");
1696
1697 for idx in [
1699 "idx_events_ns_created_id",
1700 "idx_events_session",
1701 "idx_events_payload_proposal_id",
1702 "idx_event_obs_entity",
1703 "idx_event_obs_event_role",
1704 ] {
1705 let exists: bool = conn
1706 .query_row(
1707 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1708 [idx],
1709 |r| r.get(0),
1710 )
1711 .unwrap();
1712 assert!(exists, "V13 must create index {idx}");
1713 }
1714
1715 let embed_tbl: i64 = conn
1717 .query_row(
1718 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_embedding_models'",
1719 [],
1720 |r| r.get(0),
1721 )
1722 .unwrap();
1723 assert_eq!(embed_tbl, 1, "V14 must create _embedding_models table");
1724
1725 for idx in [
1727 "idx_embed_models_one_active",
1728 "idx_embed_models_engine_status",
1729 ] {
1730 let exists: bool = conn
1731 .query_row(
1732 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1733 [idx],
1734 |r| r.get(0),
1735 )
1736 .unwrap();
1737 assert!(exists, "V14 must create index {idx}");
1738 }
1739
1740 let proposals_tbl: i64 = conn
1742 .query_row(
1743 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1744 [],
1745 |r| r.get(0),
1746 )
1747 .unwrap();
1748 assert_eq!(proposals_tbl, 1, "V15 must create proposals_open table");
1749
1750 for idx in [
1752 "idx_proposals_open_ns_status",
1753 "idx_proposals_open_proposer",
1754 "idx_proposals_open_updated",
1755 ] {
1756 let exists: bool = conn
1757 .query_row(
1758 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1759 [idx],
1760 |r| r.get(0),
1761 )
1762 .unwrap();
1763 assert!(exists, "V15 must create index {idx}");
1764 }
1765
1766 let snap_tbl: i64 = conn
1768 .query_row(
1769 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='brain_profile_snapshots'",
1770 [],
1771 |r| r.get(0),
1772 )
1773 .unwrap();
1774 assert_eq!(snap_tbl, 1, "V20 must create brain_profile_snapshots table");
1775
1776 let log_tbl: i64 = conn
1777 .query_row(
1778 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='brain_event_log'",
1779 [],
1780 |r| r.get(0),
1781 )
1782 .unwrap();
1783 assert_eq!(log_tbl, 1, "V20 must create brain_event_log table");
1784
1785 let sections_tbl: i64 = conn
1787 .query_row(
1788 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='knowledge_sections'",
1789 [],
1790 |r| r.get(0),
1791 )
1792 .unwrap();
1793 assert_eq!(sections_tbl, 1, "V21 must create knowledge_sections table");
1794
1795 for idx in [
1797 "idx_knowledge_sections_atom",
1798 "idx_knowledge_sections_ns_type",
1799 "idx_knowledge_sections_ns_atom",
1800 ] {
1801 let exists: bool = conn
1802 .query_row(
1803 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1804 [idx],
1805 |r| r.get(0),
1806 )
1807 .unwrap();
1808 assert!(exists, "V21 must create index {idx}");
1809 }
1810
1811 for col in [
1813 "id",
1814 "atom_id",
1815 "namespace",
1816 "section_type",
1817 "heading",
1818 "content",
1819 "tokens",
1820 "sort_order",
1821 "embedding",
1822 "created_at",
1823 "updated_at",
1824 ] {
1825 let exists: bool = conn
1826 .query_row(
1827 "SELECT COUNT(*) > 0 FROM pragma_table_info('knowledge_sections') WHERE name = ?1",
1828 [col],
1829 |r| r.get(0),
1830 )
1831 .unwrap();
1832 assert!(exists, "V21 must add knowledge_sections.{col}");
1833 }
1834 }
1835
1836 #[test]
1837 fn run_migrations_twice_is_idempotent() {
1838 let mut conn = open_memory();
1839 let v1 = run_migrations(&mut conn).expect("first run");
1840 let v2 = run_migrations(&mut conn).expect("second run");
1841 assert_eq!(v1, 22);
1842 assert_eq!(v2, 22);
1843
1844 let count: i64 = conn
1846 .query_row("SELECT COUNT(*) FROM _schema_migrations", [], |row| {
1847 row.get(0)
1848 })
1849 .unwrap();
1850 assert_eq!(count, 22);
1851 }
1852
1853 #[test]
1856 fn migration_v9_adds_target_backend_index() {
1857 let mut conn = open_memory();
1858 let version = run_migrations(&mut conn).expect("migrations should succeed");
1859 assert_eq!(
1860 version, 22,
1861 "F052: latest migration must be V22 (knowledge_lifecycle_status)"
1862 );
1863 let col: i64 = conn
1864 .query_row(
1865 "SELECT COUNT(*) FROM pragma_table_info('graph_edges') WHERE name = 'target_backend'",
1866 [],
1867 |row| row.get(0),
1868 )
1869 .unwrap();
1870 assert_eq!(
1871 col, 1,
1872 "F052: graph_edges must have target_backend column after V9 migration"
1873 );
1874 let idx: i64 = conn
1875 .query_row(
1876 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_graph_edges_target_backend'",
1877 [],
1878 |row| row.get(0),
1879 )
1880 .unwrap();
1881 assert_eq!(
1882 idx, 1,
1883 "F052: idx_graph_edges_target_backend partial index must exist after V9 migration"
1884 );
1885 }
1886
1887 #[test]
1888 fn failed_migration_rolls_back() {
1889 let bad_v23 = VersionedMigration {
1890 version: 23,
1891 name: "bad_migration",
1892 up: "THIS IS NOT VALID SQL;",
1893 };
1894
1895 let mut conn = open_memory();
1896
1897 run_migrations(&mut conn).expect("V1..V22 should apply cleanly");
1899
1900 let result = apply_single_migration(&mut conn, &bad_v23);
1902 assert!(result.is_err(), "bad migration should return error");
1903
1904 let v23_count: i64 = conn
1906 .query_row(
1907 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 23",
1908 [],
1909 |row| row.get(0),
1910 )
1911 .unwrap();
1912 assert_eq!(v23_count, 0, "V23 must not be recorded after rollback");
1913
1914 let applied_count: i64 = conn
1916 .query_row(
1917 "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22)",
1918 [],
1919 |row| row.get(0),
1920 )
1921 .unwrap();
1922 assert_eq!(
1923 applied_count, 22,
1924 "V1..V22 must still be recorded after V23 rollback"
1925 );
1926 }
1927
1928 #[test]
1929 fn store_ddl_then_migrations_is_idempotent() {
1930 use crate::stores::entity::ensure_entities_schema;
1931 use crate::stores::note::ensure_notes_schema;
1932
1933 let mut conn = open_memory();
1934
1935 ensure_notes_schema(&conn).expect("store DDL should create notes");
1938
1939 ensure_entities_schema(&conn).expect("store DDL should create entities");
1941
1942 let has_name: bool = conn
1944 .query_row(
1945 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'name'",
1946 [],
1947 |row| row.get(0),
1948 )
1949 .unwrap();
1950 assert!(has_name, "NOTES_DDL should include name column");
1951
1952 let version = run_migrations(&mut conn).expect("migrations after store DDL");
1968 assert_eq!(version, 22);
1969
1970 let v2_count: i64 = conn
1972 .query_row(
1973 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 2",
1974 [],
1975 |row| row.get(0),
1976 )
1977 .unwrap();
1978 assert_eq!(
1979 v2_count, 1,
1980 "V2 must be recorded even when column pre-exists"
1981 );
1982
1983 let v5_count: i64 = conn
1985 .query_row(
1986 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 5",
1987 [],
1988 |row| row.get(0),
1989 )
1990 .unwrap();
1991 assert_eq!(
1992 v5_count, 1,
1993 "V5 must be recorded even when entity_type column pre-exists"
1994 );
1995
1996 let v9_count: i64 = conn
1998 .query_row(
1999 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 9",
2000 [],
2001 |row| row.get(0),
2002 )
2003 .unwrap();
2004 assert_eq!(
2005 v9_count, 1,
2006 "V9 must be recorded after store-DDL + migrations"
2007 );
2008
2009 let v10_count: i64 = conn
2011 .query_row(
2012 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 10",
2013 [],
2014 |row| row.get(0),
2015 )
2016 .unwrap();
2017 assert_eq!(
2018 v10_count, 1,
2019 "V10 must be recorded even when status column pre-exists via NOTES_DDL"
2020 );
2021
2022 let v11_count: i64 = conn
2024 .query_row(
2025 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 11",
2026 [],
2027 |row| row.get(0),
2028 )
2029 .unwrap();
2030 assert_eq!(
2031 v11_count, 1,
2032 "V11 must be recorded even when merged_into column pre-exists via ENTITIES_DDL"
2033 );
2034
2035 let v12_count: i64 = conn
2038 .query_row(
2039 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 12",
2040 [],
2041 |row| row.get(0),
2042 )
2043 .unwrap();
2044 assert_eq!(
2045 v12_count, 1,
2046 "V12 must be recorded even when salience is already nullable via NOTES_DDL"
2047 );
2048
2049 let v13_count: i64 = conn
2051 .query_row(
2052 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 13",
2053 [],
2054 |row| row.get(0),
2055 )
2056 .unwrap();
2057 assert_eq!(
2058 v13_count, 1,
2059 "V13 must be recorded after store-DDL + migrations"
2060 );
2061
2062 let v14_count: i64 = conn
2064 .query_row(
2065 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 14",
2066 [],
2067 |row| row.get(0),
2068 )
2069 .unwrap();
2070 assert_eq!(
2071 v14_count, 1,
2072 "V14 must be recorded after store-DDL + migrations"
2073 );
2074
2075 let v15_count: i64 = conn
2077 .query_row(
2078 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 15",
2079 [],
2080 |row| row.get(0),
2081 )
2082 .unwrap();
2083 assert_eq!(
2084 v15_count, 1,
2085 "V15 must be recorded after store-DDL + migrations"
2086 );
2087
2088 let v21_count: i64 = conn
2090 .query_row(
2091 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 21",
2092 [],
2093 |row| row.get(0),
2094 )
2095 .unwrap();
2096 assert_eq!(
2097 v21_count, 1,
2098 "V21 must be recorded after store-DDL + migrations"
2099 );
2100 }
2101
2102 #[test]
2105 fn v1_to_v12_allows_null_salience() {
2106 let mut conn = open_memory();
2107
2108 conn.execute_batch(MIGRATION_TRACKING_TABLE).unwrap();
2111 conn.execute_batch(
2112 "CREATE TABLE entities (\
2113 id TEXT PRIMARY KEY,\
2114 namespace TEXT NOT NULL,\
2115 kind TEXT NOT NULL,\
2116 name TEXT NOT NULL,\
2117 description TEXT,\
2118 properties TEXT,\
2119 tags TEXT NOT NULL DEFAULT '[]',\
2120 created_at INTEGER NOT NULL,\
2121 updated_at INTEGER NOT NULL,\
2122 deleted_at INTEGER\
2123 );\
2124 CREATE TABLE graph_edges (\
2125 namespace TEXT NOT NULL,\
2126 id TEXT NOT NULL,\
2127 source_id TEXT NOT NULL,\
2128 target_id TEXT NOT NULL,\
2129 relation TEXT NOT NULL,\
2130 weight REAL NOT NULL DEFAULT 1.0,\
2131 created_at INTEGER NOT NULL,\
2132 metadata TEXT,\
2133 PRIMARY KEY (namespace, id)\
2134 );\
2135 CREATE TABLE notes (\
2136 id TEXT PRIMARY KEY,\
2137 namespace TEXT NOT NULL,\
2138 kind TEXT NOT NULL,\
2139 content TEXT NOT NULL DEFAULT '',\
2140 salience REAL NOT NULL DEFAULT 0.5,\
2141 decay_factor REAL NOT NULL DEFAULT 0.0,\
2142 expires_at INTEGER,\
2143 properties TEXT,\
2144 created_at INTEGER NOT NULL,\
2145 updated_at INTEGER NOT NULL,\
2146 deleted_at INTEGER\
2147 );\
2148 CREATE TABLE events (\
2149 id TEXT PRIMARY KEY,\
2150 namespace TEXT NOT NULL,\
2151 verb TEXT NOT NULL,\
2152 substrate TEXT NOT NULL,\
2153 actor TEXT NOT NULL,\
2154 outcome TEXT NOT NULL,\
2155 data TEXT,\
2156 duration_us INTEGER NOT NULL DEFAULT 0,\
2157 target_id TEXT,\
2158 created_at INTEGER NOT NULL\
2159 );",
2160 )
2161 .unwrap();
2162
2163 let now = chrono::Utc::now().timestamp_micros();
2165 conn.execute(
2166 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (1, 'initial_schema', ?1)",
2167 rusqlite::params![now],
2168 )
2169 .unwrap();
2170
2171 let version = run_migrations(&mut conn).expect("migrations should succeed");
2173 assert_eq!(version, 22);
2174
2175 let notnull: i64 = conn
2177 .query_row(
2178 "SELECT \"notnull\" FROM pragma_table_info('notes') WHERE name = 'salience'",
2179 [],
2180 |row| row.get(0),
2181 )
2182 .unwrap();
2183 assert_eq!(notnull, 0, "salience must be nullable after V12");
2184
2185 conn.execute(
2187 "INSERT INTO notes (id, namespace, kind, status, content, created_at, updated_at) \
2188 VALUES ('test-id', 'ns', 'observation', 'active', '', 1, 1)",
2189 [],
2190 )
2191 .expect("inserting note with NULL salience must succeed after V12");
2192
2193 let stored_salience: Option<f64> = conn
2194 .query_row(
2195 "SELECT salience FROM notes WHERE id = 'test-id'",
2196 [],
2197 |row| row.get(0),
2198 )
2199 .unwrap();
2200 assert!(
2201 stored_salience.is_none(),
2202 "salience must be NULL when not supplied"
2203 );
2204 }
2205
2206 #[test]
2207 fn store_ddl_then_event_migration_is_idempotent() {
2208 use crate::stores::event::ensure_events_schema;
2209
2210 let mut conn = open_memory();
2211
2212 ensure_events_schema(&conn).expect("store DDL should create events");
2215
2216 let version = run_migrations(&mut conn).expect("migrations after events store DDL");
2217 assert_eq!(version, 22, "must reach V22 even when events DDL ran first");
2218
2219 let v13_count: i64 = conn
2220 .query_row(
2221 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 13",
2222 [],
2223 |r| r.get(0),
2224 )
2225 .unwrap();
2226 assert_eq!(v13_count, 1, "V13 must be recorded");
2227
2228 let v14_count: i64 = conn
2229 .query_row(
2230 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 14",
2231 [],
2232 |r| r.get(0),
2233 )
2234 .unwrap();
2235 assert_eq!(v14_count, 1, "V14 must be recorded");
2236
2237 let v15_count: i64 = conn
2238 .query_row(
2239 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 15",
2240 [],
2241 |r| r.get(0),
2242 )
2243 .unwrap();
2244 assert_eq!(v15_count, 1, "V15 must be recorded");
2245 }
2246
2247 #[test]
2254 fn migration_v14_creates_embedding_model_registry() {
2255 let mut conn = open_memory();
2256 let version = run_migrations(&mut conn).expect("migrations should succeed");
2257 assert_eq!(
2258 version, 22,
2259 "F227: latest migration must be V22 (knowledge_lifecycle_status)"
2260 );
2261
2262 let tbl: i64 = conn
2264 .query_row(
2265 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_embedding_models'",
2266 [],
2267 |r| r.get(0),
2268 )
2269 .unwrap();
2270 assert_eq!(tbl, 1, "F227: _embedding_models table must exist after V14");
2271
2272 let one_active_idx: i64 = conn
2274 .query_row(
2275 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_embed_models_one_active'",
2276 [],
2277 |r| r.get(0),
2278 )
2279 .unwrap();
2280 assert_eq!(
2281 one_active_idx, 1,
2282 "V14 must create idx_embed_models_one_active partial unique index"
2283 );
2284
2285 let engine_status_idx: i64 = conn
2287 .query_row(
2288 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_embed_models_engine_status'",
2289 [],
2290 |r| r.get(0),
2291 )
2292 .unwrap();
2293 assert_eq!(
2294 engine_status_idx, 1,
2295 "V14 must create idx_embed_models_engine_status index"
2296 );
2297
2298 for col in [
2300 "id",
2301 "engine_name",
2302 "model_id",
2303 "key_version",
2304 "dim",
2305 "output_dim",
2306 "status",
2307 "activated_at",
2308 "superseded_at",
2309 "superseded_by",
2310 "canonical_key",
2311 "created_at",
2312 ] {
2313 let exists: bool = conn
2314 .query_row(
2315 "SELECT COUNT(*) > 0 FROM pragma_table_info('_embedding_models') WHERE name = ?1",
2316 [col],
2317 |r| r.get(0),
2318 )
2319 .unwrap();
2320 assert!(
2321 exists,
2322 "F227: _embedding_models must have column '{col}' after V14"
2323 );
2324 }
2325 }
2326
2327 #[test]
2333 fn migration_v14_adds_embedding_model_id_to_existing_regular_vec_tables() {
2334 let mut conn = open_memory();
2335
2336 conn.execute_batch(
2344 "CREATE TABLE vec_legacy_model (\
2345 subject_id TEXT PRIMARY KEY,\
2346 namespace TEXT NOT NULL,\
2347 kind TEXT NOT NULL,\
2348 field TEXT NOT NULL\
2349 );",
2350 )
2351 .unwrap();
2352
2353 let version = run_migrations(&mut conn).expect("migrations should succeed");
2356 assert_eq!(version, 22);
2357
2358 let col_exists: bool = conn
2360 .query_row(
2361 "SELECT COUNT(*) > 0 FROM pragma_table_info('vec_legacy_model') WHERE name = 'embedding_model_id'",
2362 [],
2363 |r| r.get(0),
2364 )
2365 .unwrap();
2366 assert!(
2367 col_exists,
2368 "F228: V14 must add embedding_model_id to existing regular vec_ tables"
2369 );
2370
2371 let version2 = run_migrations(&mut conn).expect("second run must succeed");
2373 assert_eq!(version2, 22);
2374 }
2375
2376 #[test]
2388 fn migration_v14_does_not_alter_sqlite_vec_shadow_tables() {
2389 let mut conn = open_memory();
2390
2391 conn.execute_batch(
2395 "CREATE TABLE vec_test_chunks (x INTEGER);\
2396 CREATE TABLE vec_test_rowids (x INTEGER);\
2397 CREATE TABLE vec_test_info (x INTEGER);\
2398 CREATE TABLE vec_test_vector_chunks00 (x INTEGER);",
2399 )
2400 .unwrap();
2401
2402 let version = run_migrations(&mut conn).expect("migrations should succeed");
2405 assert_eq!(version, 22);
2406
2407 for shadow in [
2408 "vec_test_chunks",
2409 "vec_test_rowids",
2410 "vec_test_info",
2411 "vec_test_vector_chunks00",
2412 ] {
2413 let col_added: bool = conn
2414 .query_row(
2415 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) \
2416 WHERE name = 'embedding_model_id'",
2417 rusqlite::params![shadow],
2418 |r| r.get(0),
2419 )
2420 .unwrap();
2421 assert!(
2422 !col_added,
2423 "CRIT-2: V14 must NOT add embedding_model_id to sqlite-vec shadow table '{shadow}'"
2424 );
2425 }
2426 }
2427
2428 #[test]
2450 fn v17_preserving_rebuild_preserves_rows_and_infers_model() {
2451 let conn = open_memory();
2452
2453 conn.execute_batch(MIGRATION_TRACKING_TABLE).unwrap();
2455
2456 assert_eq!(
2477 infer_model_from_table_name("vec_paraphrase"),
2478 "paraphrase",
2479 "suffix 'paraphrase' should be returned as-is"
2480 );
2481 assert_eq!(
2482 infer_model_from_table_name("vec_all_minilm_l6_v2"),
2483 "all_minilm_l6_v2",
2484 "underscore-containing suffix should be returned as-is"
2485 );
2486
2487 conn.execute_batch(
2494 "CREATE TABLE vec_paraphrase (\
2495 subject_id TEXT PRIMARY KEY,\
2496 namespace TEXT NOT NULL,\
2497 kind TEXT NOT NULL,\
2498 embedding BLOB NOT NULL\
2499 );",
2500 )
2501 .unwrap();
2502 conn.execute_batch(
2503 "INSERT INTO vec_paraphrase (subject_id, namespace, kind, embedding) \
2504 VALUES ('id-1', 'ns', 'entity', X'0000803F');",
2505 )
2506 .unwrap();
2507
2508 conn.execute_batch(
2510 "CREATE TABLE tmp_vec_paraphrase (\
2511 subject_id TEXT PRIMARY KEY,\
2512 namespace TEXT NOT NULL,\
2513 kind TEXT NOT NULL,\
2514 field TEXT NOT NULL,\
2515 embedding_model TEXT NOT NULL,\
2516 embedding BLOB NOT NULL\
2517 );\
2518 INSERT INTO tmp_vec_paraphrase \
2519 (subject_id, namespace, kind, field, embedding_model, embedding) \
2520 SELECT subject_id, namespace, kind, '' AS field, 'paraphrase' AS embedding_model, embedding \
2521 FROM vec_paraphrase;\
2522 DROP TABLE vec_paraphrase;\
2523 CREATE TABLE vec_paraphrase (\
2524 subject_id TEXT PRIMARY KEY,\
2525 namespace TEXT NOT NULL,\
2526 kind TEXT NOT NULL,\
2527 field TEXT NOT NULL,\
2528 embedding_model TEXT NOT NULL,\
2529 embedding BLOB NOT NULL\
2530 );\
2531 INSERT INTO vec_paraphrase \
2532 (subject_id, namespace, kind, field, embedding_model, embedding) \
2533 SELECT subject_id, namespace, kind, field, embedding_model, embedding \
2534 FROM tmp_vec_paraphrase;\
2535 DROP TABLE tmp_vec_paraphrase;",
2536 )
2537 .unwrap();
2538
2539 let (ns, model): (String, String) = conn
2541 .query_row(
2542 "SELECT namespace, embedding_model FROM vec_paraphrase WHERE subject_id = 'id-1'",
2543 [],
2544 |row| Ok((row.get(0)?, row.get(1)?)),
2545 )
2546 .unwrap();
2547 assert_eq!(ns, "ns");
2548 assert_eq!(
2549 model, "paraphrase",
2550 "V17 must infer model 'paraphrase' from table name 'vec_paraphrase'"
2551 );
2552 }
2553
2554 #[test]
2556 fn v17_infer_model_known_suffix() {
2557 assert_eq!(infer_model_from_table_name("vec_paraphrase"), "paraphrase");
2558 }
2559
2560 #[test]
2563 fn v17_infer_model_fallback_for_unknown_suffix() {
2564 assert_eq!(
2566 infer_model_from_table_name("vec_"),
2567 "all-minilm-l6-v2",
2568 "empty suffix must fall back to all-minilm-l6-v2"
2569 );
2570 assert_eq!(
2572 infer_model_from_table_name("other_table"),
2573 "all-minilm-l6-v2",
2574 "non-vec_ prefix must fall back to all-minilm-l6-v2"
2575 );
2576 }
2577
2578 #[test]
2581 fn v17_migration_is_noop_on_fresh_db() {
2582 let mut conn = open_memory();
2583 let version = run_migrations(&mut conn).expect("migrations must succeed on fresh DB");
2584 assert_eq!(version, 22);
2585
2586 let v17: i64 = conn
2588 .query_row(
2589 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 17",
2590 [],
2591 |r| r.get(0),
2592 )
2593 .unwrap();
2594 assert_eq!(v17, 1, "V17 must be recorded on fresh DB");
2595
2596 let v18: i64 = conn
2597 .query_row(
2598 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 18",
2599 [],
2600 |r| r.get(0),
2601 )
2602 .unwrap();
2603 assert_eq!(v18, 1, "V18 must be recorded on fresh DB");
2604 }
2605
2606 #[test]
2610 fn v17_skips_tables_that_already_have_both_columns() {
2611 let conn = open_memory();
2612
2613 conn.execute_batch(
2615 "CREATE TABLE vec_modern (\
2616 subject_id TEXT PRIMARY KEY,\
2617 namespace TEXT NOT NULL,\
2618 kind TEXT NOT NULL,\
2619 field TEXT NOT NULL,\
2620 embedding_model TEXT NOT NULL DEFAULT 'all-minilm-l6-v2',\
2621 embedding BLOB NOT NULL\
2622 );\
2623 INSERT INTO vec_modern VALUES ('id-2', 'ns', 'entity', 'content', 'my-model', X'00');",
2624 )
2625 .unwrap();
2626
2627 let sql = build_v17_preserving_rebuild_sql(&conn).unwrap();
2631 assert_eq!(
2632 sql, "SELECT 1;",
2633 "V17 must produce no-op SQL when no vec0 virtual tables need rebuilding"
2634 );
2635
2636 let count: i64 = conn
2638 .query_row("SELECT COUNT(*) FROM vec_modern", [], |r| r.get(0))
2639 .unwrap();
2640 assert_eq!(count, 1);
2641 }
2642
2643 fn apply_single_migration(
2646 conn: &mut Connection,
2647 migration: &VersionedMigration,
2648 ) -> Result<(), SqliteError> {
2649 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
2650 version: migration.version,
2651 error: e.to_string(),
2652 })?;
2653
2654 tx.execute_batch(migration.up)
2655 .map_err(|e| SqliteError::Migration {
2656 version: migration.version,
2657 error: e.to_string(),
2658 })?;
2659
2660 let now = chrono::Utc::now().timestamp_micros();
2661 tx.execute(
2662 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
2663 rusqlite::params![migration.version, migration.name, now],
2664 )
2665 .map_err(|e| SqliteError::Migration {
2666 version: migration.version,
2667 error: e.to_string(),
2668 })?;
2669
2670 tx.commit().map_err(|e| SqliteError::Migration {
2671 version: migration.version,
2672 error: e.to_string(),
2673 })?;
2674
2675 Ok(())
2676 }
2677}