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 V15_PROPOSALS_OPEN: &str = "\
381 CREATE TABLE IF NOT EXISTS proposals_open (\
382 proposal_id TEXT PRIMARY KEY,\
383 namespace TEXT NOT NULL,\
384 proposer TEXT NOT NULL,\
385 title TEXT NOT NULL,\
386 status TEXT NOT NULL CHECK (status IN ('open', 'changes_requested', 'approved', 'rejected', 'applied', 'withdrawn')),\
387 created_at INTEGER NOT NULL,\
388 updated_at INTEGER NOT NULL,\
389 expiry INTEGER,\
390 last_decision TEXT,\
391 review_count INTEGER NOT NULL DEFAULT 0,\
392 approve_count INTEGER NOT NULL DEFAULT 0,\
393 reject_count INTEGER NOT NULL DEFAULT 0\
394 );\
395 CREATE INDEX IF NOT EXISTS idx_proposals_open_ns_status ON proposals_open(namespace, status);\
396 CREATE INDEX IF NOT EXISTS idx_proposals_open_proposer ON proposals_open(namespace, proposer);\
397 CREATE INDEX IF NOT EXISTS idx_proposals_open_updated ON proposals_open(namespace, updated_at DESC);\
398";
399
400pub const MIGRATIONS: &[VersionedMigration] = &[
401 VersionedMigration {
402 version: 1,
403 name: "initial_schema",
404 up: V1_UP,
405 },
406 VersionedMigration {
407 version: 2,
408 name: "add_name_to_notes",
409 up: "ALTER TABLE notes ADD COLUMN name TEXT;",
410 },
411 VersionedMigration {
412 version: 3,
413 name: "add_events_namespace_created_index",
414 up: "CREATE INDEX IF NOT EXISTS idx_events_ns_created ON events(namespace, created_at DESC);",
415 },
416 VersionedMigration {
417 version: 4,
418 name: "dedupe_graph_edge_triples",
419 up: V4_DEDUPE_GRAPH_EDGE_TRIPLES,
420 },
421 VersionedMigration {
422 version: 5,
423 name: "add_entity_type_to_entities",
424 up: V5_ADD_ENTITY_TYPE_TO_ENTITIES,
425 },
426 VersionedMigration {
438 version: 6,
439 name: "reserved_adr043_embedding_pipeline_extensions",
440 up: "SELECT 1;",
441 },
442 VersionedMigration {
443 version: 7,
444 name: "reserved_adr046_event_sourced_proposals_index",
445 up: "SELECT 1;",
446 },
447 VersionedMigration {
448 version: 8,
449 name: "reserved_adr041_event_observations_and_session_id",
450 up: "SELECT 1;",
451 },
452 VersionedMigration {
453 version: 9,
454 name: "edge_lifecycle_and_target_backend",
455 up: V9_EDGE_LIFECYCLE_AND_TARGET_BACKEND,
456 },
457 VersionedMigration {
458 version: 10,
459 name: "note_status_and_nullable_metrics",
460 up: V10_NOTE_STATUS_AND_NULLABLE_METRICS,
461 },
462 VersionedMigration {
463 version: 11,
464 name: "entity_tombstone_columns",
465 up: V11_ENTITY_TOMBSTONE_COLUMNS,
466 },
467 VersionedMigration {
468 version: 12,
469 name: "nullable_note_metrics",
470 up: V12_NULLABLE_NOTE_METRICS,
471 },
472 VersionedMigration {
473 version: 13,
474 name: "event_observability_provenance",
475 up: V13_EVENT_OBSERVABILITY_PROVENANCE,
476 },
477 VersionedMigration {
478 version: 14,
479 name: "embedding_model_registry",
480 up: V14_EMBEDDING_MODEL_REGISTRY,
481 },
482 VersionedMigration {
484 version: 15,
485 name: "proposals_open",
486 up: V15_PROPOSALS_OPEN,
487 },
488];
489
490const MIGRATION_TRACKING_TABLE: &str = "\
491 CREATE TABLE IF NOT EXISTS _schema_migrations (\
492 version INTEGER PRIMARY KEY,\
493 name TEXT NOT NULL,\
494 applied_at INTEGER NOT NULL\
495 );\
496";
497
498pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
519 for (i, m) in MIGRATIONS.iter().enumerate() {
520 let expected = (i + 1) as u32;
521 if m.version != expected {
522 return Err(SqliteError::InvalidData(format!(
523 "MIGRATIONS array is not contiguous: expected version {expected} at index {i}, \
524 got version {}",
525 m.version
526 )));
527 }
528 }
529
530 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
531
532 let current_version: u32 = conn
534 .query_row(
535 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
536 [],
537 |row| row.get(0),
538 )
539 .unwrap_or(0);
540
541 let mut applied_version = current_version;
542
543 for migration in MIGRATIONS {
544 if migration.version <= current_version {
545 continue;
546 }
547
548 if migration.version == 2 {
553 let col_exists: bool = conn
554 .query_row(
555 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'name'",
556 [],
557 |row| row.get(0),
558 )
559 .unwrap_or(false);
560 if col_exists {
561 let now = chrono::Utc::now().timestamp_micros();
563 conn.execute(
564 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
565 VALUES (?1, ?2, ?3)",
566 rusqlite::params![migration.version, migration.name, now],
567 )
568 .map_err(|e| SqliteError::Migration {
569 version: migration.version,
570 error: e.to_string(),
571 })?;
572 applied_version = migration.version;
573 continue;
574 }
575 }
576
577 if migration.version == 5 {
581 let col_exists: bool = conn
582 .query_row(
583 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'entity_type'",
584 [],
585 |row| row.get(0),
586 )
587 .unwrap_or(false);
588 if col_exists {
589 let now = chrono::Utc::now().timestamp_micros();
590 conn.execute(
591 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
592 VALUES (?1, ?2, ?3)",
593 rusqlite::params![migration.version, migration.name, now],
594 )
595 .map_err(|e| SqliteError::Migration {
596 version: migration.version,
597 error: e.to_string(),
598 })?;
599 applied_version = migration.version;
600 continue;
601 }
602 }
603
604 if migration.version == 10 {
609 let col_exists: bool = conn
610 .query_row(
611 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'status'",
612 [],
613 |row| row.get(0),
614 )
615 .unwrap_or(false);
616 if col_exists {
617 let now = chrono::Utc::now().timestamp_micros();
618 conn.execute(
619 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
620 VALUES (?1, ?2, ?3)",
621 rusqlite::params![migration.version, migration.name, now],
622 )
623 .map_err(|e| SqliteError::Migration {
624 version: migration.version,
625 error: e.to_string(),
626 })?;
627 applied_version = migration.version;
628 continue;
629 }
630 }
631
632 if migration.version == 11 {
637 let col_exists: bool = conn
638 .query_row(
639 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'merged_into'",
640 [],
641 |row| row.get(0),
642 )
643 .unwrap_or(false);
644 if col_exists {
645 let now = chrono::Utc::now().timestamp_micros();
646 conn.execute(
647 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
648 VALUES (?1, ?2, ?3)",
649 rusqlite::params![migration.version, migration.name, now],
650 )
651 .map_err(|e| SqliteError::Migration {
652 version: migration.version,
653 error: e.to_string(),
654 })?;
655 applied_version = migration.version;
656 continue;
657 }
658 }
659
660 if migration.version == 12 {
665 let already_nullable: bool = conn
666 .query_row(
667 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') \
668 WHERE name = 'salience' AND \"notnull\" = 0",
669 [],
670 |row| row.get(0),
671 )
672 .unwrap_or(false);
673 if already_nullable {
674 let now = chrono::Utc::now().timestamp_micros();
675 conn.execute(
676 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
677 VALUES (?1, ?2, ?3)",
678 rusqlite::params![migration.version, migration.name, now],
679 )
680 .map_err(|e| SqliteError::Migration {
681 version: migration.version,
682 error: e.to_string(),
683 })?;
684 applied_version = migration.version;
685 continue;
686 }
687 }
688
689 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
690 version: migration.version,
691 error: e.to_string(),
692 })?;
693
694 let up_sql = if migration.version == 13 {
695 build_v13_event_observability_sql(&tx).map_err(|e| SqliteError::Migration {
696 version: migration.version,
697 error: e.to_string(),
698 })?
699 } else if migration.version == 14 {
700 build_v14_embedding_model_registry_sql(&tx).map_err(|e| SqliteError::Migration {
701 version: migration.version,
702 error: e.to_string(),
703 })?
704 } else {
705 migration.up.to_string()
706 };
707
708 tx.execute_batch(&up_sql)
709 .map_err(|e| SqliteError::Migration {
710 version: migration.version,
711 error: e.to_string(),
712 })?;
713
714 let now = chrono::Utc::now().timestamp_micros();
715 tx.execute(
716 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
717 rusqlite::params![migration.version, migration.name, now],
718 )
719 .map_err(|e| SqliteError::Migration {
720 version: migration.version,
721 error: e.to_string(),
722 })?;
723
724 tx.commit().map_err(|e| SqliteError::Migration {
725 version: migration.version,
726 error: e.to_string(),
727 })?;
728
729 applied_version = migration.version;
730 }
731
732 Ok(applied_version)
733}
734
735fn table_has_column(
736 conn: &Connection,
737 table: &'static str,
738 column: &'static str,
739) -> Result<bool, rusqlite::Error> {
740 conn.query_row(
741 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
742 rusqlite::params![table, column],
743 |row| row.get(0),
744 )
745}
746
747fn build_v13_event_observability_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
748 let mut sql = String::new();
749 for (column, ddl) in [
750 (
751 "kind",
752 "ALTER TABLE events ADD COLUMN kind TEXT NOT NULL DEFAULT 'audit';",
753 ),
754 (
755 "payload",
756 "ALTER TABLE events ADD COLUMN payload TEXT NOT NULL DEFAULT '{}';",
757 ),
758 (
759 "payload_schema_version",
760 "ALTER TABLE events ADD COLUMN payload_schema_version INTEGER NOT NULL DEFAULT 1;",
761 ),
762 (
763 "profile_state_version",
764 "ALTER TABLE events ADD COLUMN profile_state_version INTEGER;",
765 ),
766 (
767 "session_id",
768 "ALTER TABLE events ADD COLUMN session_id TEXT;",
769 ),
770 (
771 "aggregate_kind",
772 "ALTER TABLE events ADD COLUMN aggregate_kind TEXT;",
773 ),
774 (
775 "aggregate_id",
776 "ALTER TABLE events ADD COLUMN aggregate_id TEXT;",
777 ),
778 ] {
779 if !table_has_column(conn, "events", column)? {
780 sql.push_str(ddl);
781 }
782 }
783 if table_has_column(conn, "events", "data")? && table_has_column(conn, "events", "payload")? {
785 sql.push_str("UPDATE events SET payload = data WHERE data IS NOT NULL AND data <> '';");
786 }
787 sql.push_str(
788 "CREATE TABLE IF NOT EXISTS event_observations (\
789 event_id TEXT NOT NULL,\
790 entity_id TEXT NOT NULL,\
791 referent_kind TEXT NOT NULL,\
792 role TEXT NOT NULL,\
793 position INTEGER NOT NULL,\
794 PRIMARY KEY (event_id, role, position)\
795 );\
796 CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);\
797 CREATE INDEX IF NOT EXISTS idx_events_session ON events(namespace, session_id, created_at, id);\
798 CREATE INDEX IF NOT EXISTS idx_events_ns_created_id ON events(namespace, created_at DESC, id DESC);\
799 CREATE INDEX IF NOT EXISTS idx_events_payload_proposal_id ON events(json_extract(payload, '$.proposal_id'));\
800 CREATE INDEX IF NOT EXISTS idx_event_obs_entity ON event_observations(entity_id, role);\
801 CREATE INDEX IF NOT EXISTS idx_event_obs_event_role ON event_observations(event_id, role);",
802 );
803 Ok(sql)
804}
805
806fn build_v14_embedding_model_registry_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
818 let mut sql = String::from(EMBEDDING_MODELS_DDL);
819
820 let mut stmt = conn.prepare(
834 "SELECT name FROM sqlite_master \
835 WHERE type = 'table' \
836 AND name LIKE 'vec_%' \
837 AND sql NOT LIKE '%VIRTUAL%' \
838 AND sql NOT LIKE '%vec0%' \
839 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
840 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
841 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
842 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\'",
843 )?;
844 let vec_tables: Vec<String> = stmt
845 .query_map([], |row| row.get(0))?
846 .filter_map(|r| r.ok())
847 .collect();
848
849 for table in &vec_tables {
850 let valid = table.starts_with("vec_")
852 && table[4..]
853 .chars()
854 .all(|c| c.is_ascii_alphanumeric() || c == '_');
855 if !valid {
856 continue;
857 }
858 let col_exists: bool = conn
860 .query_row(
861 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model_id'",
862 rusqlite::params![table],
863 |row| row.get(0),
864 )
865 .unwrap_or(false);
866 if col_exists {
867 continue;
868 }
869 sql.push_str(&format!(
870 "ALTER TABLE {t} ADD COLUMN embedding_model_id BLOB REFERENCES _embedding_models(id);\
871 CREATE INDEX IF NOT EXISTS idx_{t}_model ON {t}(embedding_model_id);",
872 t = table,
873 ));
874 }
875
876 Ok(sql)
877}
878
879#[cfg(test)]
884mod tests {
885 use super::*;
886
887 fn open_memory() -> Connection {
888 Connection::open_in_memory().expect("in-memory connection")
889 }
890
891 #[test]
892 fn fresh_db_migrates_to_latest() {
893 let mut conn = open_memory();
894 let version = run_migrations(&mut conn).expect("migrations should succeed");
895 assert_eq!(version, 15);
896
897 let count: i64 = conn
899 .query_row(
900 "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)",
901 [],
902 |row| row.get(0),
903 )
904 .unwrap();
905 assert_eq!(count, 15);
906
907 let tbl_count: i64 = conn
909 .query_row(
910 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='entities'",
911 [],
912 |row| row.get(0),
913 )
914 .unwrap();
915 assert_eq!(tbl_count, 1);
916
917 let col_count: i64 = conn
919 .query_row(
920 "SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name = 'name'",
921 [],
922 |row| row.get(0),
923 )
924 .unwrap();
925 assert_eq!(col_count, 1, "V2 must add name column to notes");
926
927 let et_count: i64 = conn
929 .query_row(
930 "SELECT COUNT(*) FROM pragma_table_info('entities') WHERE name = 'entity_type'",
931 [],
932 |row| row.get(0),
933 )
934 .unwrap();
935 assert_eq!(et_count, 1, "V5 must add entity_type column to entities");
936
937 let idx_count: i64 = conn
939 .query_row(
940 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' \
941 AND name='idx_entities_kind_entity_type'",
942 [],
943 |row| row.get(0),
944 )
945 .unwrap();
946 assert_eq!(idx_count, 1, "V5 must create idx_entities_kind_entity_type");
947
948 let status_col: i64 = conn
950 .query_row(
951 "SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name = 'status'",
952 [],
953 |row| row.get(0),
954 )
955 .unwrap();
956 assert_eq!(status_col, 1, "V10 must add status column to notes");
957
958 let merged_into_col: i64 = conn
960 .query_row(
961 "SELECT COUNT(*) FROM pragma_table_info('entities') WHERE name = 'merged_into'",
962 [],
963 |row| row.get(0),
964 )
965 .unwrap();
966 assert_eq!(
967 merged_into_col, 1,
968 "V11 must add merged_into column to entities"
969 );
970
971 let salience_notnull: i64 = conn
973 .query_row(
974 "SELECT \"notnull\" FROM pragma_table_info('notes') WHERE name = 'salience'",
975 [],
976 |row| row.get(0),
977 )
978 .unwrap();
979 assert_eq!(salience_notnull, 0, "V12 must make salience nullable");
980
981 for col in [
983 "kind",
984 "payload",
985 "payload_schema_version",
986 "profile_state_version",
987 "session_id",
988 "aggregate_kind",
989 "aggregate_id",
990 ] {
991 let exists: bool = conn
992 .query_row(
993 "SELECT COUNT(*) > 0 FROM pragma_table_info('events') WHERE name = ?1",
994 [col],
995 |r| r.get(0),
996 )
997 .unwrap();
998 assert!(exists, "V13 must add events.{col}");
999 }
1000
1001 let obs_tbl: i64 = conn
1003 .query_row(
1004 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='event_observations'",
1005 [],
1006 |r| r.get(0),
1007 )
1008 .unwrap();
1009 assert_eq!(obs_tbl, 1, "V13 must create event_observations table");
1010
1011 for idx in [
1013 "idx_events_ns_created_id",
1014 "idx_events_session",
1015 "idx_events_payload_proposal_id",
1016 "idx_event_obs_entity",
1017 "idx_event_obs_event_role",
1018 ] {
1019 let exists: bool = conn
1020 .query_row(
1021 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1022 [idx],
1023 |r| r.get(0),
1024 )
1025 .unwrap();
1026 assert!(exists, "V13 must create index {idx}");
1027 }
1028
1029 let embed_tbl: i64 = conn
1031 .query_row(
1032 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_embedding_models'",
1033 [],
1034 |r| r.get(0),
1035 )
1036 .unwrap();
1037 assert_eq!(embed_tbl, 1, "V14 must create _embedding_models table");
1038
1039 for idx in [
1041 "idx_embed_models_one_active",
1042 "idx_embed_models_engine_status",
1043 ] {
1044 let exists: bool = conn
1045 .query_row(
1046 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1047 [idx],
1048 |r| r.get(0),
1049 )
1050 .unwrap();
1051 assert!(exists, "V14 must create index {idx}");
1052 }
1053
1054 let proposals_tbl: i64 = conn
1056 .query_row(
1057 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1058 [],
1059 |r| r.get(0),
1060 )
1061 .unwrap();
1062 assert_eq!(proposals_tbl, 1, "V15 must create proposals_open table");
1063
1064 for idx in [
1066 "idx_proposals_open_ns_status",
1067 "idx_proposals_open_proposer",
1068 "idx_proposals_open_updated",
1069 ] {
1070 let exists: bool = conn
1071 .query_row(
1072 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
1073 [idx],
1074 |r| r.get(0),
1075 )
1076 .unwrap();
1077 assert!(exists, "V15 must create index {idx}");
1078 }
1079 }
1080
1081 #[test]
1082 fn run_migrations_twice_is_idempotent() {
1083 let mut conn = open_memory();
1084 let v1 = run_migrations(&mut conn).expect("first run");
1085 let v2 = run_migrations(&mut conn).expect("second run");
1086 assert_eq!(v1, 15);
1087 assert_eq!(v2, 15);
1088
1089 let count: i64 = conn
1091 .query_row("SELECT COUNT(*) FROM _schema_migrations", [], |row| {
1092 row.get(0)
1093 })
1094 .unwrap();
1095 assert_eq!(count, 15);
1096 }
1097
1098 #[test]
1101 fn migration_v9_adds_target_backend_index() {
1102 let mut conn = open_memory();
1103 let version = run_migrations(&mut conn).expect("migrations should succeed");
1104 assert_eq!(
1105 version, 15,
1106 "F052: latest migration must be V15 (proposals_open)"
1107 );
1108 let col: i64 = conn
1109 .query_row(
1110 "SELECT COUNT(*) FROM pragma_table_info('graph_edges') WHERE name = 'target_backend'",
1111 [],
1112 |row| row.get(0),
1113 )
1114 .unwrap();
1115 assert_eq!(
1116 col, 1,
1117 "F052: graph_edges must have target_backend column after V9 migration"
1118 );
1119 let idx: i64 = conn
1120 .query_row(
1121 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_graph_edges_target_backend'",
1122 [],
1123 |row| row.get(0),
1124 )
1125 .unwrap();
1126 assert_eq!(
1127 idx, 1,
1128 "F052: idx_graph_edges_target_backend partial index must exist after V9 migration"
1129 );
1130 }
1131
1132 #[test]
1133 fn failed_migration_rolls_back() {
1134 let bad_v16 = VersionedMigration {
1135 version: 16,
1136 name: "bad_migration",
1137 up: "THIS IS NOT VALID SQL;",
1138 };
1139
1140 let mut conn = open_memory();
1141
1142 run_migrations(&mut conn).expect("V1..V15 should apply cleanly");
1144
1145 let result = apply_single_migration(&mut conn, &bad_v16);
1147 assert!(result.is_err(), "bad migration should return error");
1148
1149 let v16_count: i64 = conn
1151 .query_row(
1152 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 16",
1153 [],
1154 |row| row.get(0),
1155 )
1156 .unwrap();
1157 assert_eq!(v16_count, 0, "V16 must not be recorded after rollback");
1158
1159 let applied_count: i64 = conn
1161 .query_row(
1162 "SELECT COUNT(*) FROM _schema_migrations WHERE version IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)",
1163 [],
1164 |row| row.get(0),
1165 )
1166 .unwrap();
1167 assert_eq!(applied_count, 15, "V1..V15 must still be recorded");
1168 }
1169
1170 #[test]
1171 fn store_ddl_then_migrations_is_idempotent() {
1172 use crate::stores::entity::ensure_entities_schema;
1173 use crate::stores::note::ensure_notes_schema;
1174
1175 let mut conn = open_memory();
1176
1177 ensure_notes_schema(&conn).expect("store DDL should create notes");
1180
1181 ensure_entities_schema(&conn).expect("store DDL should create entities");
1183
1184 let has_name: bool = conn
1186 .query_row(
1187 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'name'",
1188 [],
1189 |row| row.get(0),
1190 )
1191 .unwrap();
1192 assert!(has_name, "NOTES_DDL should include name column");
1193
1194 let version = run_migrations(&mut conn).expect("migrations after store DDL");
1203 assert_eq!(version, 15);
1204
1205 let v2_count: i64 = conn
1207 .query_row(
1208 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 2",
1209 [],
1210 |row| row.get(0),
1211 )
1212 .unwrap();
1213 assert_eq!(
1214 v2_count, 1,
1215 "V2 must be recorded even when column pre-exists"
1216 );
1217
1218 let v5_count: i64 = conn
1220 .query_row(
1221 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 5",
1222 [],
1223 |row| row.get(0),
1224 )
1225 .unwrap();
1226 assert_eq!(
1227 v5_count, 1,
1228 "V5 must be recorded even when entity_type column pre-exists"
1229 );
1230
1231 let v9_count: i64 = conn
1233 .query_row(
1234 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 9",
1235 [],
1236 |row| row.get(0),
1237 )
1238 .unwrap();
1239 assert_eq!(
1240 v9_count, 1,
1241 "V9 must be recorded after store-DDL + migrations"
1242 );
1243
1244 let v10_count: i64 = conn
1246 .query_row(
1247 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 10",
1248 [],
1249 |row| row.get(0),
1250 )
1251 .unwrap();
1252 assert_eq!(
1253 v10_count, 1,
1254 "V10 must be recorded even when status column pre-exists via NOTES_DDL"
1255 );
1256
1257 let v11_count: i64 = conn
1259 .query_row(
1260 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 11",
1261 [],
1262 |row| row.get(0),
1263 )
1264 .unwrap();
1265 assert_eq!(
1266 v11_count, 1,
1267 "V11 must be recorded even when merged_into column pre-exists via ENTITIES_DDL"
1268 );
1269
1270 let v12_count: i64 = conn
1273 .query_row(
1274 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 12",
1275 [],
1276 |row| row.get(0),
1277 )
1278 .unwrap();
1279 assert_eq!(
1280 v12_count, 1,
1281 "V12 must be recorded even when salience is already nullable via NOTES_DDL"
1282 );
1283
1284 let v13_count: i64 = conn
1286 .query_row(
1287 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 13",
1288 [],
1289 |row| row.get(0),
1290 )
1291 .unwrap();
1292 assert_eq!(
1293 v13_count, 1,
1294 "V13 must be recorded after store-DDL + migrations"
1295 );
1296
1297 let v14_count: i64 = conn
1299 .query_row(
1300 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 14",
1301 [],
1302 |row| row.get(0),
1303 )
1304 .unwrap();
1305 assert_eq!(
1306 v14_count, 1,
1307 "V14 must be recorded after store-DDL + migrations"
1308 );
1309
1310 let v15_count: i64 = conn
1312 .query_row(
1313 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 15",
1314 [],
1315 |row| row.get(0),
1316 )
1317 .unwrap();
1318 assert_eq!(
1319 v15_count, 1,
1320 "V15 must be recorded after store-DDL + migrations"
1321 );
1322 }
1323
1324 #[test]
1327 fn v1_to_v12_allows_null_salience() {
1328 let mut conn = open_memory();
1329
1330 conn.execute_batch(MIGRATION_TRACKING_TABLE).unwrap();
1333 conn.execute_batch(
1334 "CREATE TABLE entities (\
1335 id TEXT PRIMARY KEY,\
1336 namespace TEXT NOT NULL,\
1337 kind TEXT NOT NULL,\
1338 name TEXT NOT NULL,\
1339 description TEXT,\
1340 properties TEXT,\
1341 tags TEXT NOT NULL DEFAULT '[]',\
1342 created_at INTEGER NOT NULL,\
1343 updated_at INTEGER NOT NULL,\
1344 deleted_at INTEGER\
1345 );\
1346 CREATE TABLE graph_edges (\
1347 namespace TEXT NOT NULL,\
1348 id TEXT NOT NULL,\
1349 source_id TEXT NOT NULL,\
1350 target_id TEXT NOT NULL,\
1351 relation TEXT NOT NULL,\
1352 weight REAL NOT NULL DEFAULT 1.0,\
1353 created_at INTEGER NOT NULL,\
1354 metadata TEXT,\
1355 PRIMARY KEY (namespace, id)\
1356 );\
1357 CREATE TABLE notes (\
1358 id TEXT PRIMARY KEY,\
1359 namespace TEXT NOT NULL,\
1360 kind TEXT NOT NULL,\
1361 content TEXT NOT NULL DEFAULT '',\
1362 salience REAL NOT NULL DEFAULT 0.5,\
1363 decay_factor REAL NOT NULL DEFAULT 0.0,\
1364 expires_at INTEGER,\
1365 properties TEXT,\
1366 created_at INTEGER NOT NULL,\
1367 updated_at INTEGER NOT NULL,\
1368 deleted_at INTEGER\
1369 );\
1370 CREATE TABLE events (\
1371 id TEXT PRIMARY KEY,\
1372 namespace TEXT NOT NULL,\
1373 verb TEXT NOT NULL,\
1374 substrate TEXT NOT NULL,\
1375 actor TEXT NOT NULL,\
1376 outcome TEXT NOT NULL,\
1377 data TEXT,\
1378 duration_us INTEGER NOT NULL DEFAULT 0,\
1379 target_id TEXT,\
1380 created_at INTEGER NOT NULL\
1381 );",
1382 )
1383 .unwrap();
1384
1385 let now = chrono::Utc::now().timestamp_micros();
1387 conn.execute(
1388 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (1, 'initial_schema', ?1)",
1389 rusqlite::params![now],
1390 )
1391 .unwrap();
1392
1393 let version = run_migrations(&mut conn).expect("migrations should succeed");
1395 assert_eq!(version, 15);
1396
1397 let notnull: i64 = conn
1399 .query_row(
1400 "SELECT \"notnull\" FROM pragma_table_info('notes') WHERE name = 'salience'",
1401 [],
1402 |row| row.get(0),
1403 )
1404 .unwrap();
1405 assert_eq!(notnull, 0, "salience must be nullable after V12");
1406
1407 conn.execute(
1409 "INSERT INTO notes (id, namespace, kind, status, content, created_at, updated_at) \
1410 VALUES ('test-id', 'ns', 'observation', 'active', '', 1, 1)",
1411 [],
1412 )
1413 .expect("inserting note with NULL salience must succeed after V12");
1414
1415 let stored_salience: Option<f64> = conn
1416 .query_row(
1417 "SELECT salience FROM notes WHERE id = 'test-id'",
1418 [],
1419 |row| row.get(0),
1420 )
1421 .unwrap();
1422 assert!(
1423 stored_salience.is_none(),
1424 "salience must be NULL when not supplied"
1425 );
1426 }
1427
1428 #[test]
1429 fn store_ddl_then_event_migration_is_idempotent() {
1430 use crate::stores::event::ensure_events_schema;
1431
1432 let mut conn = open_memory();
1433
1434 ensure_events_schema(&conn).expect("store DDL should create events");
1437
1438 let version = run_migrations(&mut conn).expect("migrations after events store DDL");
1439 assert_eq!(version, 15, "must reach V15 even when events DDL ran first");
1440
1441 let v13_count: i64 = conn
1442 .query_row(
1443 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 13",
1444 [],
1445 |r| r.get(0),
1446 )
1447 .unwrap();
1448 assert_eq!(v13_count, 1, "V13 must be recorded");
1449
1450 let v14_count: i64 = conn
1451 .query_row(
1452 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 14",
1453 [],
1454 |r| r.get(0),
1455 )
1456 .unwrap();
1457 assert_eq!(v14_count, 1, "V14 must be recorded");
1458
1459 let v15_count: i64 = conn
1460 .query_row(
1461 "SELECT COUNT(*) FROM _schema_migrations WHERE version = 15",
1462 [],
1463 |r| r.get(0),
1464 )
1465 .unwrap();
1466 assert_eq!(v15_count, 1, "V15 must be recorded");
1467 }
1468
1469 #[test]
1476 fn migration_v14_creates_embedding_model_registry() {
1477 let mut conn = open_memory();
1478 let version = run_migrations(&mut conn).expect("migrations should succeed");
1479 assert_eq!(
1480 version, 15,
1481 "F227: latest migration must be V15 (proposals_open)"
1482 );
1483
1484 let tbl: i64 = conn
1486 .query_row(
1487 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='_embedding_models'",
1488 [],
1489 |r| r.get(0),
1490 )
1491 .unwrap();
1492 assert_eq!(tbl, 1, "F227: _embedding_models table must exist after V14");
1493
1494 let one_active_idx: i64 = conn
1496 .query_row(
1497 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_embed_models_one_active'",
1498 [],
1499 |r| r.get(0),
1500 )
1501 .unwrap();
1502 assert_eq!(
1503 one_active_idx, 1,
1504 "V14 must create idx_embed_models_one_active partial unique index"
1505 );
1506
1507 let engine_status_idx: i64 = conn
1509 .query_row(
1510 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_embed_models_engine_status'",
1511 [],
1512 |r| r.get(0),
1513 )
1514 .unwrap();
1515 assert_eq!(
1516 engine_status_idx, 1,
1517 "V14 must create idx_embed_models_engine_status index"
1518 );
1519
1520 for col in [
1522 "id",
1523 "engine_name",
1524 "model_id",
1525 "key_version",
1526 "dim",
1527 "output_dim",
1528 "status",
1529 "activated_at",
1530 "superseded_at",
1531 "superseded_by",
1532 "canonical_key",
1533 "created_at",
1534 ] {
1535 let exists: bool = conn
1536 .query_row(
1537 "SELECT COUNT(*) > 0 FROM pragma_table_info('_embedding_models') WHERE name = ?1",
1538 [col],
1539 |r| r.get(0),
1540 )
1541 .unwrap();
1542 assert!(
1543 exists,
1544 "F227: _embedding_models must have column '{col}' after V14"
1545 );
1546 }
1547 }
1548
1549 #[test]
1555 fn migration_v14_adds_embedding_model_id_to_existing_regular_vec_tables() {
1556 let mut conn = open_memory();
1557
1558 conn.execute_batch(
1566 "CREATE TABLE vec_legacy_model (\
1567 subject_id TEXT PRIMARY KEY,\
1568 namespace TEXT NOT NULL,\
1569 kind TEXT NOT NULL,\
1570 field TEXT NOT NULL\
1571 );",
1572 )
1573 .unwrap();
1574
1575 let version = run_migrations(&mut conn).expect("migrations should succeed");
1578 assert_eq!(version, 15);
1579
1580 let col_exists: bool = conn
1582 .query_row(
1583 "SELECT COUNT(*) > 0 FROM pragma_table_info('vec_legacy_model') WHERE name = 'embedding_model_id'",
1584 [],
1585 |r| r.get(0),
1586 )
1587 .unwrap();
1588 assert!(
1589 col_exists,
1590 "F228: V14 must add embedding_model_id to existing regular vec_ tables"
1591 );
1592
1593 let version2 = run_migrations(&mut conn).expect("second run must succeed");
1595 assert_eq!(version2, 15);
1596 }
1597
1598 #[test]
1610 fn migration_v14_does_not_alter_sqlite_vec_shadow_tables() {
1611 let mut conn = open_memory();
1612
1613 conn.execute_batch(
1617 "CREATE TABLE vec_test_chunks (x INTEGER);\
1618 CREATE TABLE vec_test_rowids (x INTEGER);\
1619 CREATE TABLE vec_test_info (x INTEGER);\
1620 CREATE TABLE vec_test_vector_chunks00 (x INTEGER);",
1621 )
1622 .unwrap();
1623
1624 let version = run_migrations(&mut conn).expect("migrations should succeed");
1627 assert_eq!(version, 15);
1628
1629 for shadow in [
1630 "vec_test_chunks",
1631 "vec_test_rowids",
1632 "vec_test_info",
1633 "vec_test_vector_chunks00",
1634 ] {
1635 let col_added: bool = conn
1636 .query_row(
1637 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) \
1638 WHERE name = 'embedding_model_id'",
1639 rusqlite::params![shadow],
1640 |r| r.get(0),
1641 )
1642 .unwrap();
1643 assert!(
1644 !col_added,
1645 "CRIT-2: V14 must NOT add embedding_model_id to sqlite-vec shadow table '{shadow}'"
1646 );
1647 }
1648 }
1649
1650 fn apply_single_migration(
1653 conn: &mut Connection,
1654 migration: &VersionedMigration,
1655 ) -> Result<(), SqliteError> {
1656 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
1657 version: migration.version,
1658 error: e.to_string(),
1659 })?;
1660
1661 tx.execute_batch(migration.up)
1662 .map_err(|e| SqliteError::Migration {
1663 version: migration.version,
1664 error: e.to_string(),
1665 })?;
1666
1667 let now = chrono::Utc::now().timestamp_micros();
1668 tx.execute(
1669 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
1670 rusqlite::params![migration.version, migration.name, now],
1671 )
1672 .map_err(|e| SqliteError::Migration {
1673 version: migration.version,
1674 error: e.to_string(),
1675 })?;
1676
1677 tx.commit().map_err(|e| SqliteError::Migration {
1678 version: migration.version,
1679 error: e.to_string(),
1680 })?;
1681
1682 Ok(())
1683 }
1684}