1use rusqlite::Connection;
10
11use crate::error::SqliteError;
12
13pub struct Migration {
19 pub id: &'static str,
21 pub up_sql: &'static str,
23 pub down_sql: Option<&'static str>,
25 pub is_already_applied: Option<fn(&Connection) -> bool>,
28}
29
30pub struct ServiceSchemaPlan {
32 pub service: &'static str,
34 pub sqlite: &'static [Migration],
36 pub postgres: &'static [Migration],
38}
39
40const SCHEMA_VERSION_TABLE: &str = "\
41 CREATE TABLE IF NOT EXISTS _schema_versions (\
42 service TEXT NOT NULL,\
43 migration_id TEXT NOT NULL,\
44 applied_at INTEGER NOT NULL,\
45 PRIMARY KEY (service, migration_id)\
46 );\
47";
48
49pub fn apply_schema_plan(conn: &Connection, plan: &ServiceSchemaPlan) -> Result<(), SqliteError> {
51 conn.execute_batch(SCHEMA_VERSION_TABLE)?;
52
53 for migration in plan.sqlite {
54 if let Some(check) = migration.is_already_applied {
56 if check(conn) {
57 continue;
58 }
59 }
60
61 let already: bool = conn.query_row(
63 "SELECT COUNT(*) > 0 FROM _schema_versions WHERE service = ?1 AND migration_id = ?2",
64 rusqlite::params![plan.service, migration.id],
65 |row| row.get(0),
66 )?;
67
68 if already {
69 continue;
70 }
71
72 conn.execute_batch(migration.up_sql)?;
74
75 conn.execute(
77 "INSERT INTO _schema_versions (service, migration_id, applied_at) VALUES (?1, ?2, ?3)",
78 rusqlite::params![
79 plan.service,
80 migration.id,
81 chrono::Utc::now().timestamp_micros(),
82 ],
83 )?;
84 }
85
86 Ok(())
87}
88
89pub struct VersionedMigration {
99 pub version: u32,
101 pub name: &'static str,
103 pub up: &'static str,
106}
107
108const V1_UP: &str = "\
110 CREATE TABLE IF NOT EXISTS entities (\
111 id TEXT PRIMARY KEY,\
112 namespace TEXT NOT NULL,\
113 kind TEXT NOT NULL,\
114 name TEXT NOT NULL,\
115 description TEXT,\
116 properties TEXT,\
117 tags TEXT NOT NULL DEFAULT '[]',\
118 created_at INTEGER NOT NULL,\
119 updated_at INTEGER NOT NULL,\
120 deleted_at INTEGER\
121 );\
122 CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace);\
123 CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(namespace, kind);\
124 CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(namespace, name);\
125 CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at DESC);\
126 CREATE TABLE IF NOT EXISTS graph_edges (\
127 namespace TEXT NOT NULL,\
128 id TEXT NOT NULL,\
129 source_id TEXT NOT NULL,\
130 target_id TEXT NOT NULL,\
131 relation TEXT NOT NULL,\
132 weight REAL NOT NULL DEFAULT 1.0,\
133 created_at INTEGER NOT NULL,\
134 metadata TEXT,\
135 PRIMARY KEY (namespace, id)\
136 );\
137 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_source ON graph_edges(namespace, source_id);\
138 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_target ON graph_edges(namespace, target_id);\
139 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_relation ON graph_edges(namespace, relation);\
140 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_src_rel ON graph_edges(namespace, source_id, relation);\
141 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_tgt_rel ON graph_edges(namespace, target_id, relation);\
142 CREATE TABLE IF NOT EXISTS notes (\
143 id TEXT PRIMARY KEY,\
144 namespace TEXT NOT NULL,\
145 kind TEXT NOT NULL,\
146 content TEXT NOT NULL DEFAULT '',\
147 salience REAL NOT NULL DEFAULT 0.5,\
148 decay_factor REAL NOT NULL DEFAULT 0.0,\
149 expires_at INTEGER,\
150 properties TEXT,\
151 created_at INTEGER NOT NULL,\
152 updated_at INTEGER NOT NULL,\
153 deleted_at INTEGER\
154 );\
155 CREATE INDEX IF NOT EXISTS idx_notes_namespace ON notes(namespace);\
156 CREATE INDEX IF NOT EXISTS idx_notes_kind ON notes(namespace, kind);\
157 CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);\
158 CREATE TABLE IF NOT EXISTS events (\
159 id TEXT PRIMARY KEY,\
160 namespace TEXT NOT NULL,\
161 verb TEXT NOT NULL,\
162 substrate TEXT NOT NULL,\
163 actor TEXT NOT NULL,\
164 outcome TEXT NOT NULL,\
165 data TEXT,\
166 duration_us INTEGER NOT NULL DEFAULT 0,\
167 target_id TEXT,\
168 created_at INTEGER NOT NULL\
169 );\
170 CREATE INDEX IF NOT EXISTS idx_events_namespace ON events(namespace);\
171 CREATE INDEX IF NOT EXISTS idx_events_verb ON events(verb);\
172 CREATE INDEX IF NOT EXISTS idx_events_substrate ON events(substrate);\
173 CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);\
174";
175
176const V4_DEDUPE_GRAPH_EDGE_TRIPLES: &str = "\
187 DELETE FROM graph_edges \
188 WHERE rowid NOT IN (\
189 SELECT MIN(rowid) \
190 FROM graph_edges \
191 GROUP BY namespace, source_id, target_id, relation\
192 );\
193 CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique_triple \
194 ON graph_edges(namespace, source_id, target_id, relation);\
195";
196
197const V5_ADD_ENTITY_TYPE_TO_ENTITIES: &str = "\
198 ALTER TABLE entities ADD COLUMN entity_type TEXT NULL;\
199 CREATE INDEX IF NOT EXISTS idx_entities_kind_entity_type \
200 ON entities(namespace, kind, entity_type);\
201";
202
203const V9_EDGE_LIFECYCLE_AND_TARGET_BACKEND: &str = "\
204 DROP INDEX IF EXISTS idx_graph_edges_unique_triple;\
205 DROP INDEX IF EXISTS idx_graph_edges_ns_source;\
206 DROP INDEX IF EXISTS idx_graph_edges_ns_target;\
207 DROP INDEX IF EXISTS idx_graph_edges_ns_relation;\
208 DROP INDEX IF EXISTS idx_graph_edges_ns_src_rel;\
209 DROP INDEX IF EXISTS idx_graph_edges_ns_tgt_rel;\
210 CREATE TABLE graph_edges_new (\
211 namespace TEXT NOT NULL,\
212 id TEXT NOT NULL,\
213 source_id TEXT NOT NULL,\
214 target_id TEXT NOT NULL,\
215 relation TEXT NOT NULL,\
216 weight REAL NOT NULL DEFAULT 1.0,\
217 created_at INTEGER NOT NULL,\
218 updated_at INTEGER NOT NULL,\
219 deleted_at INTEGER,\
220 metadata TEXT,\
221 target_backend TEXT,\
222 PRIMARY KEY (namespace, id)\
223 );\
224 INSERT INTO graph_edges_new \
225 (namespace, id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, metadata, target_backend) \
226 SELECT namespace, id, source_id, target_id, relation, weight, created_at, created_at, NULL, metadata, NULL \
227 FROM graph_edges;\
228 DROP TABLE graph_edges;\
229 ALTER TABLE graph_edges_new RENAME TO graph_edges;\
230 CREATE UNIQUE INDEX IF NOT EXISTS idx_graph_edges_unique_triple ON graph_edges(namespace, source_id, target_id, relation);\
231 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_source ON graph_edges(namespace, source_id);\
232 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_target ON graph_edges(namespace, target_id);\
233 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_relation ON graph_edges(namespace, relation);\
234 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_src_rel ON graph_edges(namespace, source_id, relation);\
235 CREATE INDEX IF NOT EXISTS idx_graph_edges_ns_tgt_rel ON graph_edges(namespace, target_id, relation);\
236 CREATE INDEX IF NOT EXISTS idx_graph_edges_target_backend ON graph_edges(target_backend) WHERE target_backend IS NOT NULL;\
237";
238
239const V10_NOTE_STATUS_AND_NULLABLE_METRICS: &str = "\
255 ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';\
256";
257
258const V11_ENTITY_TOMBSTONE_COLUMNS: &str = "\
270 ALTER TABLE entities ADD COLUMN merged_into TEXT;\
271 ALTER TABLE entities ADD COLUMN merge_event_id TEXT;\
272 CREATE INDEX IF NOT EXISTS idx_entities_merged_into ON entities(namespace, merged_into);\
273";
274
275const V12_NULLABLE_NOTE_METRICS: &str = "\
287 CREATE TABLE notes_new (\
288 id TEXT PRIMARY KEY,\
289 namespace TEXT NOT NULL,\
290 kind TEXT NOT NULL,\
291 status TEXT NOT NULL DEFAULT 'active',\
292 name TEXT,\
293 content TEXT NOT NULL DEFAULT '',\
294 salience REAL,\
295 decay_factor REAL,\
296 expires_at INTEGER,\
297 properties TEXT,\
298 created_at INTEGER NOT NULL,\
299 updated_at INTEGER NOT NULL,\
300 deleted_at INTEGER\
301 );\
302 INSERT INTO notes_new \
303 (id, namespace, kind, status, name, content, salience, decay_factor, \
304 expires_at, properties, created_at, updated_at, deleted_at) \
305 SELECT \
306 id, namespace, kind, status, name, content, salience, decay_factor, \
307 expires_at, properties, created_at, updated_at, deleted_at \
308 FROM notes;\
309 DROP TABLE notes;\
310 ALTER TABLE notes_new RENAME TO notes;\
311 CREATE INDEX IF NOT EXISTS idx_notes_namespace ON notes(namespace);\
312 CREATE INDEX IF NOT EXISTS idx_notes_kind ON notes(namespace, kind);\
313 CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at DESC);\
314";
315
316const V13_EVENT_OBSERVABILITY_PROVENANCE: &str = "__v13_computed_at_runtime__";
321
322pub const EMBEDDING_MODELS_DDL: &str = "\
329 CREATE TABLE IF NOT EXISTS _embedding_models (\
330 id BLOB PRIMARY KEY,\
331 engine_name TEXT NOT NULL,\
332 model_id TEXT NOT NULL,\
333 key_version TEXT NOT NULL,\
334 dim INTEGER NOT NULL,\
335 output_dim INTEGER,\
336 status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'superseded', 'archived')),\
337 activated_at INTEGER,\
338 superseded_at INTEGER,\
339 superseded_by BLOB,\
340 canonical_key BLOB NOT NULL UNIQUE,\
341 created_at INTEGER NOT NULL\
342 );\
343 CREATE UNIQUE INDEX IF NOT EXISTS idx_embed_models_one_active \
344 ON _embedding_models(engine_name) WHERE status = 'active';\
345 CREATE INDEX IF NOT EXISTS idx_embed_models_engine_status \
346 ON _embedding_models(engine_name, status);";
347
348const V14_EMBEDDING_MODEL_REGISTRY: &str = "__v14_computed_at_runtime__";
368
369const V16_VECTOR_EMBEDDING_MODEL_TAG: &str = "__v16_computed_at_runtime__";
377
378const V17_VECTOR_EMBEDDING_MODEL_TAG_PRESERVING_REBUILD: &str = "__v17_computed_at_runtime__";
387
388const V15_PROPOSALS_OPEN: &str = "\
394 CREATE TABLE IF NOT EXISTS proposals_open (\
395 proposal_id TEXT PRIMARY KEY,\
396 namespace TEXT NOT NULL,\
397 proposer TEXT NOT NULL,\
398 title TEXT NOT NULL,\
399 status TEXT NOT NULL CHECK (status IN ('open', 'changes_requested', 'approved', 'rejected', 'applied', 'withdrawn')),\
400 created_at INTEGER NOT NULL,\
401 updated_at INTEGER NOT NULL,\
402 expiry INTEGER,\
403 last_decision TEXT,\
404 review_count INTEGER NOT NULL DEFAULT 0,\
405 approve_count INTEGER NOT NULL DEFAULT 0,\
406 reject_count INTEGER NOT NULL DEFAULT 0\
407 );\
408 CREATE INDEX IF NOT EXISTS idx_proposals_open_ns_status ON proposals_open(namespace, status);\
409 CREATE INDEX IF NOT EXISTS idx_proposals_open_proposer ON proposals_open(namespace, proposer);\
410 CREATE INDEX IF NOT EXISTS idx_proposals_open_updated ON proposals_open(namespace, updated_at DESC);\
411";
412
413const V19_KNOWLEDGE_ATOMS_AND_DOMAINS: &str = "\
419 CREATE TABLE IF NOT EXISTS knowledge_atoms (\
420 id TEXT PRIMARY KEY,\
421 namespace TEXT NOT NULL,\
422 slug TEXT NOT NULL,\
423 name TEXT NOT NULL,\
424 description TEXT,\
425 content TEXT NOT NULL DEFAULT '',\
426 tags TEXT NOT NULL DEFAULT '[]',\
427 properties TEXT,\
428 finalized INTEGER NOT NULL DEFAULT 0,\
429 created_at INTEGER NOT NULL,\
430 updated_at INTEGER NOT NULL,\
431 deleted_at INTEGER\
432 );\
433 CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_slug \
434 ON knowledge_atoms(namespace, slug);\
435 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns \
436 ON knowledge_atoms(namespace);\
437 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_created \
438 ON knowledge_atoms(namespace, created_at DESC);\
439 CREATE TABLE IF NOT EXISTS knowledge_domains (\
440 id TEXT PRIMARY KEY,\
441 namespace TEXT NOT NULL,\
442 slug TEXT NOT NULL,\
443 name TEXT NOT NULL,\
444 description TEXT,\
445 tags TEXT NOT NULL DEFAULT '[]',\
446 members TEXT NOT NULL DEFAULT '[]',\
447 created_at INTEGER NOT NULL,\
448 updated_at INTEGER NOT NULL,\
449 deleted_at INTEGER\
450 );\
451 CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_domains_ns_slug \
452 ON knowledge_domains(namespace, slug);\
453 CREATE INDEX IF NOT EXISTS idx_knowledge_domains_ns \
454 ON knowledge_domains(namespace);\
455 CREATE VIRTUAL TABLE IF NOT EXISTS fts_knowledge \
456 USING fts5(\
457 id UNINDEXED,\
458 namespace UNINDEXED,\
459 slug,\
460 name,\
461 description,\
462 content,\
463 content=knowledge_atoms,\
464 content_rowid=rowid,\
465 tokenize='trigram case_sensitive 0'\
466 );\
467 CREATE TRIGGER IF NOT EXISTS fts_knowledge_ai \
468 AFTER INSERT ON knowledge_atoms \
469 WHEN new.deleted_at IS NULL BEGIN \
470 INSERT INTO fts_knowledge(rowid, id, namespace, slug, name, description, content) \
471 VALUES(new.rowid, new.id, new.namespace, new.slug, new.name, new.description, new.content); \
472 END; \
473 CREATE TRIGGER IF NOT EXISTS fts_knowledge_ad \
474 AFTER DELETE ON knowledge_atoms BEGIN \
475 INSERT INTO fts_knowledge(fts_knowledge, rowid, id, namespace, slug, name, description, content) \
476 VALUES('delete', old.rowid, old.id, old.namespace, old.slug, old.name, old.description, old.content); \
477 END; \
478 CREATE TRIGGER IF NOT EXISTS fts_knowledge_au \
479 AFTER UPDATE ON knowledge_atoms BEGIN \
480 INSERT INTO fts_knowledge(fts_knowledge, rowid, id, namespace, slug, name, description, content) \
481 VALUES('delete', old.rowid, old.id, old.namespace, old.slug, old.name, old.description, old.content); \
482 INSERT INTO fts_knowledge(rowid, id, namespace, slug, name, description, content) \
483 SELECT new.rowid, new.id, new.namespace, new.slug, new.name, new.description, new.content \
484 WHERE new.deleted_at IS NULL; \
485 END;\
486";
487
488const V20_BRAIN_PROFILE_PERSISTENCE: &str = "\
495 CREATE TABLE IF NOT EXISTS brain_profile_snapshots (\
496 profile_id TEXT NOT NULL,\
497 namespace TEXT NOT NULL DEFAULT 'default',\
498 snapshot_json TEXT NOT NULL,\
499 updated_at INTEGER NOT NULL,\
500 PRIMARY KEY (profile_id, namespace)\
501 );\
502 CREATE TABLE IF NOT EXISTS brain_event_log (\
503 id INTEGER PRIMARY KEY AUTOINCREMENT,\
504 profile_id TEXT NOT NULL,\
505 namespace TEXT NOT NULL DEFAULT 'default',\
506 event_kind TEXT NOT NULL,\
507 payload TEXT NOT NULL,\
508 created_at INTEGER NOT NULL\
509 );\
510 CREATE INDEX IF NOT EXISTS idx_brain_events_profile \
511 ON brain_event_log(profile_id, namespace, created_at);\
512";
513
514const V22_KNOWLEDGE_LIFECYCLE_STATUS: &str = "\
532 ALTER TABLE knowledge_atoms ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
533 ALTER TABLE knowledge_atoms ADD COLUMN source_uri TEXT;\
534 ALTER TABLE knowledge_atoms ADD COLUMN source_type TEXT;\
535 ALTER TABLE knowledge_sections ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
536 ALTER TABLE knowledge_domains ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';\
537 CREATE INDEX IF NOT EXISTS idx_knowledge_atoms_ns_status \
538 ON knowledge_atoms(namespace, status);\
539 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_status \
540 ON knowledge_sections(status);\
541 CREATE INDEX IF NOT EXISTS idx_knowledge_domains_ns_status \
542 ON knowledge_domains(namespace, status);\
543 UPDATE knowledge_atoms SET status = 'reviewed' WHERE finalized = 1;\
544";
545
546const V21_KNOWLEDGE_SECTIONS: &str = "\
559 CREATE TABLE IF NOT EXISTS knowledge_sections (\
560 id TEXT PRIMARY KEY,\
561 atom_id TEXT NOT NULL,\
562 namespace TEXT NOT NULL,\
563 section_type TEXT NOT NULL,\
564 heading TEXT NOT NULL DEFAULT '',\
565 content TEXT NOT NULL DEFAULT '',\
566 tokens INTEGER NOT NULL DEFAULT 0,\
567 sort_order INTEGER NOT NULL DEFAULT 0,\
568 embedding BLOB,\
569 created_at INTEGER NOT NULL,\
570 updated_at INTEGER NOT NULL,\
571 FOREIGN KEY (atom_id) REFERENCES knowledge_atoms(id),\
572 UNIQUE(atom_id, section_type)\
573 );\
574 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_atom \
575 ON knowledge_sections(atom_id);\
576 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_ns_type \
577 ON knowledge_sections(namespace, section_type);\
578 CREATE INDEX IF NOT EXISTS idx_knowledge_sections_ns_atom \
579 ON knowledge_sections(namespace, atom_id);\
580 CREATE VIRTUAL TABLE IF NOT EXISTS fts_sections \
581 USING fts5(\
582 id UNINDEXED,\
583 namespace UNINDEXED,\
584 atom_id UNINDEXED,\
585 section_type UNINDEXED,\
586 heading,\
587 content,\
588 content=knowledge_sections,\
589 content_rowid=rowid,\
590 tokenize='trigram case_sensitive 0'\
591 );\
592 CREATE TRIGGER IF NOT EXISTS fts_sections_ai \
593 AFTER INSERT ON knowledge_sections BEGIN \
594 INSERT INTO fts_sections(rowid, id, namespace, atom_id, section_type, heading, content) \
595 VALUES(new.rowid, new.id, new.namespace, new.atom_id, new.section_type, new.heading, new.content); \
596 END; \
597 CREATE TRIGGER IF NOT EXISTS fts_sections_ad \
598 AFTER DELETE ON knowledge_sections BEGIN \
599 INSERT INTO fts_sections(fts_sections, rowid, id, namespace, atom_id, section_type, heading, content) \
600 VALUES('delete', old.rowid, old.id, old.namespace, old.atom_id, old.section_type, old.heading, old.content); \
601 END; \
602 CREATE TRIGGER IF NOT EXISTS fts_sections_au \
603 AFTER UPDATE ON knowledge_sections BEGIN \
604 INSERT INTO fts_sections(fts_sections, rowid, id, namespace, atom_id, section_type, heading, content) \
605 VALUES('delete', old.rowid, old.id, old.namespace, old.atom_id, old.section_type, old.heading, old.content); \
606 INSERT INTO fts_sections(rowid, id, namespace, atom_id, section_type, heading, content) \
607 VALUES(new.rowid, new.id, new.namespace, new.atom_id, new.section_type, new.heading, new.content); \
608 END;\
609";
610
611pub const MIGRATIONS: &[VersionedMigration] = &[
613 VersionedMigration {
614 version: 1,
615 name: "initial_schema",
616 up: V1_UP,
617 },
618 VersionedMigration {
619 version: 2,
620 name: "add_name_to_notes",
621 up: "ALTER TABLE notes ADD COLUMN name TEXT;",
622 },
623 VersionedMigration {
624 version: 3,
625 name: "add_events_namespace_created_index",
626 up: "CREATE INDEX IF NOT EXISTS idx_events_ns_created ON events(namespace, created_at DESC);",
627 },
628 VersionedMigration {
629 version: 4,
630 name: "dedupe_graph_edge_triples",
631 up: V4_DEDUPE_GRAPH_EDGE_TRIPLES,
632 },
633 VersionedMigration {
634 version: 5,
635 name: "add_entity_type_to_entities",
636 up: V5_ADD_ENTITY_TYPE_TO_ENTITIES,
637 },
638 VersionedMigration {
649 version: 6,
650 name: "reserved_adr043_embedding_pipeline_extensions",
651 up: "SELECT 1;",
652 },
653 VersionedMigration {
654 version: 7,
655 name: "reserved_adr046_event_sourced_proposals_index",
656 up: "SELECT 1;",
657 },
658 VersionedMigration {
659 version: 8,
660 name: "reserved_adr041_event_observations_and_session_id",
661 up: "SELECT 1;",
662 },
663 VersionedMigration {
664 version: 9,
665 name: "edge_lifecycle_and_target_backend",
666 up: V9_EDGE_LIFECYCLE_AND_TARGET_BACKEND,
667 },
668 VersionedMigration {
669 version: 10,
670 name: "note_status_and_nullable_metrics",
671 up: V10_NOTE_STATUS_AND_NULLABLE_METRICS,
672 },
673 VersionedMigration {
674 version: 11,
675 name: "entity_tombstone_columns",
676 up: V11_ENTITY_TOMBSTONE_COLUMNS,
677 },
678 VersionedMigration {
679 version: 12,
680 name: "nullable_note_metrics",
681 up: V12_NULLABLE_NOTE_METRICS,
682 },
683 VersionedMigration {
684 version: 13,
685 name: "event_observability_provenance",
686 up: V13_EVENT_OBSERVABILITY_PROVENANCE,
687 },
688 VersionedMigration {
689 version: 14,
690 name: "embedding_model_registry",
691 up: V14_EMBEDDING_MODEL_REGISTRY,
692 },
693 VersionedMigration {
695 version: 15,
696 name: "proposals_open",
697 up: V15_PROPOSALS_OPEN,
698 },
699 VersionedMigration {
701 version: 16,
702 name: "vector_embedding_model_tag",
703 up: V16_VECTOR_EMBEDDING_MODEL_TAG,
704 },
705 VersionedMigration {
709 version: 17,
710 name: "vector_embedding_model_tag_preserving_rebuild",
711 up: V17_VECTOR_EMBEDDING_MODEL_TAG_PRESERVING_REBUILD,
712 },
713 VersionedMigration {
715 version: 18,
716 name: "proposals_open_add_applying_status",
717 up: "__v18_computed_at_runtime__",
718 },
719 VersionedMigration {
721 version: 19,
722 name: "knowledge_atoms_and_domains",
723 up: V19_KNOWLEDGE_ATOMS_AND_DOMAINS,
724 },
725 VersionedMigration {
726 version: 20,
727 name: "brain_profile_persistence",
728 up: V20_BRAIN_PROFILE_PERSISTENCE,
729 },
730 VersionedMigration {
734 version: 21,
735 name: "knowledge_sections",
736 up: V21_KNOWLEDGE_SECTIONS,
737 },
738 VersionedMigration {
743 version: 22,
744 name: "knowledge_lifecycle_status",
745 up: V22_KNOWLEDGE_LIFECYCLE_STATUS,
746 },
747];
748
749const MIGRATION_TRACKING_TABLE: &str = "\
750 CREATE TABLE IF NOT EXISTS _schema_migrations (\
751 version INTEGER PRIMARY KEY,\
752 name TEXT NOT NULL,\
753 applied_at INTEGER NOT NULL\
754 );\
755";
756
757pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
760 for (i, m) in MIGRATIONS.iter().enumerate() {
761 let expected = (i + 1) as u32;
762 if m.version != expected {
763 return Err(SqliteError::InvalidData(format!(
764 "MIGRATIONS array is not contiguous: expected version {expected} at index {i}, \
765 got version {}",
766 m.version
767 )));
768 }
769 }
770
771 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
772
773 let current_version: u32 = conn
775 .query_row(
776 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
777 [],
778 |row| row.get(0),
779 )
780 .unwrap_or(0);
781
782 let mut applied_version = current_version;
783
784 for migration in MIGRATIONS {
785 if migration.version <= current_version {
786 continue;
787 }
788
789 if migration.version == 2 {
794 let col_exists: bool = conn
795 .query_row(
796 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'name'",
797 [],
798 |row| row.get(0),
799 )
800 .unwrap_or(false);
801 if col_exists {
802 let now = chrono::Utc::now().timestamp_micros();
804 conn.execute(
805 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
806 VALUES (?1, ?2, ?3)",
807 rusqlite::params![migration.version, migration.name, now],
808 )
809 .map_err(|e| SqliteError::Migration {
810 version: migration.version,
811 error: e.to_string(),
812 })?;
813 applied_version = migration.version;
814 continue;
815 }
816 }
817
818 if migration.version == 5 {
822 let col_exists: bool = conn
823 .query_row(
824 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'entity_type'",
825 [],
826 |row| row.get(0),
827 )
828 .unwrap_or(false);
829 if col_exists {
830 let now = chrono::Utc::now().timestamp_micros();
831 conn.execute(
832 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
833 VALUES (?1, ?2, ?3)",
834 rusqlite::params![migration.version, migration.name, now],
835 )
836 .map_err(|e| SqliteError::Migration {
837 version: migration.version,
838 error: e.to_string(),
839 })?;
840 applied_version = migration.version;
841 continue;
842 }
843 }
844
845 if migration.version == 10 {
850 let col_exists: bool = conn
851 .query_row(
852 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') WHERE name = 'status'",
853 [],
854 |row| row.get(0),
855 )
856 .unwrap_or(false);
857 if col_exists {
858 let now = chrono::Utc::now().timestamp_micros();
859 conn.execute(
860 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
861 VALUES (?1, ?2, ?3)",
862 rusqlite::params![migration.version, migration.name, now],
863 )
864 .map_err(|e| SqliteError::Migration {
865 version: migration.version,
866 error: e.to_string(),
867 })?;
868 applied_version = migration.version;
869 continue;
870 }
871 }
872
873 if migration.version == 11 {
878 let col_exists: bool = conn
879 .query_row(
880 "SELECT COUNT(*) > 0 FROM pragma_table_info('entities') WHERE name = 'merged_into'",
881 [],
882 |row| row.get(0),
883 )
884 .unwrap_or(false);
885 if col_exists {
886 let now = chrono::Utc::now().timestamp_micros();
887 conn.execute(
888 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
889 VALUES (?1, ?2, ?3)",
890 rusqlite::params![migration.version, migration.name, now],
891 )
892 .map_err(|e| SqliteError::Migration {
893 version: migration.version,
894 error: e.to_string(),
895 })?;
896 applied_version = migration.version;
897 continue;
898 }
899 }
900
901 if migration.version == 12 {
906 let already_nullable: bool = conn
907 .query_row(
908 "SELECT COUNT(*) > 0 FROM pragma_table_info('notes') \
909 WHERE name = 'salience' AND \"notnull\" = 0",
910 [],
911 |row| row.get(0),
912 )
913 .unwrap_or(false);
914 if already_nullable {
915 let now = chrono::Utc::now().timestamp_micros();
916 conn.execute(
917 "INSERT OR IGNORE INTO _schema_migrations (version, name, applied_at) \
918 VALUES (?1, ?2, ?3)",
919 rusqlite::params![migration.version, migration.name, now],
920 )
921 .map_err(|e| SqliteError::Migration {
922 version: migration.version,
923 error: e.to_string(),
924 })?;
925 applied_version = migration.version;
926 continue;
927 }
928 }
929
930 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
931 version: migration.version,
932 error: e.to_string(),
933 })?;
934
935 let up_sql = if migration.version == 13 {
936 build_v13_event_observability_sql(&tx).map_err(|e| SqliteError::Migration {
937 version: migration.version,
938 error: e.to_string(),
939 })?
940 } else if migration.version == 14 {
941 build_v14_embedding_model_registry_sql(&tx).map_err(|e| SqliteError::Migration {
942 version: migration.version,
943 error: e.to_string(),
944 })?
945 } else if migration.version == 16 {
946 build_v16_vector_embedding_model_tag_sql(&tx).map_err(|e| SqliteError::Migration {
947 version: migration.version,
948 error: e.to_string(),
949 })?
950 } else if migration.version == 17 {
951 build_v17_preserving_rebuild_sql(&tx).map_err(|e| SqliteError::Migration {
952 version: migration.version,
953 error: e.to_string(),
954 })?
955 } else if migration.version == 18 {
956 build_v18_proposals_applying_sql(&tx).map_err(|e| SqliteError::Migration {
957 version: migration.version,
958 error: e.to_string(),
959 })?
960 } else {
961 migration.up.to_string()
962 };
963
964 tx.execute_batch(&up_sql)
965 .map_err(|e| SqliteError::Migration {
966 version: migration.version,
967 error: e.to_string(),
968 })?;
969
970 let now = chrono::Utc::now().timestamp_micros();
971 tx.execute(
972 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
973 rusqlite::params![migration.version, migration.name, now],
974 )
975 .map_err(|e| SqliteError::Migration {
976 version: migration.version,
977 error: e.to_string(),
978 })?;
979
980 tx.commit().map_err(|e| SqliteError::Migration {
981 version: migration.version,
982 error: e.to_string(),
983 })?;
984
985 applied_version = migration.version;
986 }
987
988 Ok(applied_version)
989}
990
991fn table_has_column(
992 conn: &Connection,
993 table: &'static str,
994 column: &'static str,
995) -> Result<bool, rusqlite::Error> {
996 conn.query_row(
997 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = ?2",
998 rusqlite::params![table, column],
999 |row| row.get(0),
1000 )
1001}
1002
1003fn build_v13_event_observability_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1004 let mut sql = String::new();
1005 for (column, ddl) in [
1006 (
1007 "kind",
1008 "ALTER TABLE events ADD COLUMN kind TEXT NOT NULL DEFAULT 'audit';",
1009 ),
1010 (
1011 "payload",
1012 "ALTER TABLE events ADD COLUMN payload TEXT NOT NULL DEFAULT '{}';",
1013 ),
1014 (
1015 "payload_schema_version",
1016 "ALTER TABLE events ADD COLUMN payload_schema_version INTEGER NOT NULL DEFAULT 1;",
1017 ),
1018 (
1019 "profile_state_version",
1020 "ALTER TABLE events ADD COLUMN profile_state_version INTEGER;",
1021 ),
1022 (
1023 "session_id",
1024 "ALTER TABLE events ADD COLUMN session_id TEXT;",
1025 ),
1026 (
1027 "aggregate_kind",
1028 "ALTER TABLE events ADD COLUMN aggregate_kind TEXT;",
1029 ),
1030 (
1031 "aggregate_id",
1032 "ALTER TABLE events ADD COLUMN aggregate_id TEXT;",
1033 ),
1034 ] {
1035 if !table_has_column(conn, "events", column)? {
1036 sql.push_str(ddl);
1037 }
1038 }
1039 if table_has_column(conn, "events", "data")? && table_has_column(conn, "events", "payload")? {
1041 sql.push_str("UPDATE events SET payload = data WHERE data IS NOT NULL AND data <> '';");
1042 }
1043 sql.push_str(
1044 "CREATE TABLE IF NOT EXISTS event_observations (\
1045 event_id TEXT NOT NULL,\
1046 entity_id TEXT NOT NULL,\
1047 referent_kind TEXT NOT NULL,\
1048 role TEXT NOT NULL,\
1049 position INTEGER NOT NULL,\
1050 PRIMARY KEY (event_id, role, position)\
1051 );\
1052 CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);\
1053 CREATE INDEX IF NOT EXISTS idx_events_session ON events(namespace, session_id, created_at, id);\
1054 CREATE INDEX IF NOT EXISTS idx_events_ns_created_id ON events(namespace, created_at DESC, id DESC);\
1055 CREATE INDEX IF NOT EXISTS idx_events_payload_proposal_id ON events(json_extract(payload, '$.proposal_id'));\
1056 CREATE INDEX IF NOT EXISTS idx_event_obs_entity ON event_observations(entity_id, role);\
1057 CREATE INDEX IF NOT EXISTS idx_event_obs_event_role ON event_observations(event_id, role);",
1058 );
1059 Ok(sql)
1060}
1061
1062fn build_v14_embedding_model_registry_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1074 let mut sql = String::from(EMBEDDING_MODELS_DDL);
1075
1076 let mut stmt = conn.prepare(
1093 "SELECT name FROM sqlite_master \
1094 WHERE type = 'table' \
1095 AND name LIKE 'vec_%' \
1096 AND sql NOT LIKE '%VIRTUAL%' \
1097 AND sql NOT LIKE '%vec0%' \
1098 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1099 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1100 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1101 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1102 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1103 )?;
1104 let vec_tables: Vec<String> = stmt
1105 .query_map([], |row| row.get(0))?
1106 .filter_map(|r| r.ok())
1107 .collect();
1108
1109 for table in &vec_tables {
1110 let valid = table.starts_with("vec_")
1112 && table[4..]
1113 .chars()
1114 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1115 if !valid {
1116 continue;
1117 }
1118 let col_exists: bool = conn
1120 .query_row(
1121 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model_id'",
1122 rusqlite::params![table],
1123 |row| row.get(0),
1124 )
1125 .unwrap_or(false);
1126 if col_exists {
1127 continue;
1128 }
1129 sql.push_str(&format!(
1130 "ALTER TABLE {t} ADD COLUMN embedding_model_id BLOB REFERENCES _embedding_models(id);\
1131 CREATE INDEX IF NOT EXISTS idx_{t}_model ON {t}(embedding_model_id);",
1132 t = table,
1133 ));
1134 }
1135
1136 Ok(sql)
1137}
1138
1139fn build_v16_vector_embedding_model_tag_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1140 let mut stmt = conn.prepare(
1141 "SELECT name FROM sqlite_master \
1142 WHERE type = 'table' \
1143 AND name LIKE 'vec_%' \
1144 AND sql NOT LIKE '%VIRTUAL%' \
1145 AND sql NOT LIKE '%vec0%' \
1146 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1147 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1148 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1149 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1150 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1151 )?;
1152 let vec_tables: Vec<String> = stmt
1153 .query_map([], |row| row.get(0))?
1154 .filter_map(|r| r.ok())
1155 .collect();
1156
1157 let mut sql = String::new();
1158 for table in vec_tables {
1159 let valid = table.starts_with("vec_")
1160 && table[4..]
1161 .chars()
1162 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1163 if !valid {
1164 continue;
1165 }
1166 let col_exists: bool = conn
1167 .query_row(
1168 "SELECT COUNT(*) > 0 FROM pragma_table_info(?1) WHERE name = 'embedding_model'",
1169 rusqlite::params![&table],
1170 |row| row.get(0),
1171 )
1172 .unwrap_or(false);
1173 if col_exists {
1174 continue;
1175 }
1176 sql.push_str(&format!(
1177 "ALTER TABLE {t} ADD COLUMN embedding_model TEXT NOT NULL DEFAULT 'all-minilm-l6-v2';\
1178 CREATE INDEX IF NOT EXISTS idx_{t}_subject_model ON {t}(subject_id, embedding_model);",
1179 t = table,
1180 ));
1181 }
1182 if sql.is_empty() {
1183 sql.push_str("SELECT 1;");
1184 }
1185 Ok(sql)
1186}
1187
1188fn infer_model_from_table_name(table: &str) -> String {
1198 let suffix = table.strip_prefix("vec_").unwrap_or("");
1199 if !suffix.is_empty()
1200 && suffix
1201 .chars()
1202 .all(|c| c.is_ascii_alphanumeric() || c == '_')
1203 {
1204 suffix.to_string()
1205 } else {
1206 "all-minilm-l6-v2".to_string()
1207 }
1208}
1209
1210pub fn build_v17_preserving_rebuild_sql(conn: &Connection) -> Result<String, rusqlite::Error> {
1230 let mut stmt = conn.prepare(
1237 "SELECT name, sql FROM sqlite_master \
1238 WHERE type = 'table' \
1239 AND name LIKE 'vec_%' \
1240 AND sql LIKE '%VIRTUAL%' \
1241 AND sql LIKE '%vec0%' \
1242 AND name NOT LIKE '%\\_chunks' ESCAPE '\\' \
1243 AND name NOT LIKE '%\\_rowids' ESCAPE '\\' \
1244 AND name NOT LIKE '%\\_info' ESCAPE '\\' \
1245 AND name NOT LIKE '%\\_vector\\_chunks%' ESCAPE '\\' \
1246 AND name NOT LIKE '%\\_metadata%' ESCAPE '\\'",
1247 )?;
1248 let virtual_tables: Vec<(String, Option<String>)> = stmt
1249 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1250 .filter_map(|r| r.ok())
1251 .collect();
1252
1253 let mut sql = String::new();
1254
1255 for (table, ddl_opt) in &virtual_tables {
1256 let valid = table.starts_with("vec_")
1258 && table[4..]
1259 .chars()
1260 .all(|c| c.is_ascii_alphanumeric() || c == '_');
1261 if !valid {
1262 continue;
1263 }
1264
1265 let mut has_field = false;
1267 let mut has_embedding_model = false;
1268
1269 let pragma = format!("PRAGMA table_xinfo({})", table);
1270 let mut col_stmt = conn.prepare(&pragma)?;
1271 let mut col_rows = col_stmt.query([])?;
1272 while let Some(row) = col_rows.next()? {
1273 let name: String = row.get(1)?;
1274 match name.as_str() {
1275 "field" => has_field = true,
1276 "embedding_model" => has_embedding_model = true,
1277 _ => {}
1278 }
1279 }
1280
1281 if has_field && has_embedding_model {
1282 continue;
1284 }
1285
1286 let dims = ddl_opt.as_deref().and_then(|ddl| {
1290 let lower = ddl.to_ascii_lowercase();
1291 let start = lower.find("float[")?;
1293 let rest = &lower[start + 6..];
1294 let end = rest.find(']')?;
1295 rest[..end].trim().parse::<u32>().ok()
1296 });
1297
1298 let dim = match dims {
1301 Some(d) => d,
1302 None => continue,
1303 };
1304
1305 let inferred_model = infer_model_from_table_name(table);
1306 let tmp = format!("tmp_{}", table);
1307
1308 let field_expr = if has_field {
1310 "field".to_string()
1311 } else {
1312 "'' AS field".to_string()
1313 };
1314 let model_expr = if has_embedding_model {
1315 "embedding_model".to_string()
1316 } else {
1317 format!("'{}' AS embedding_model", inferred_model)
1318 };
1319
1320 sql.push_str(&format!(
1322 "CREATE TABLE {tmp} (\
1323 subject_id TEXT PRIMARY KEY, \
1324 namespace TEXT NOT NULL, \
1325 kind TEXT NOT NULL, \
1326 field TEXT NOT NULL, \
1327 embedding_model TEXT NOT NULL, \
1328 embedding BLOB NOT NULL\
1329 );",
1330 tmp = tmp,
1331 ));
1332
1333 sql.push_str(&format!(
1335 "INSERT INTO {tmp} (subject_id, namespace, kind, field, embedding_model, embedding) \
1336 SELECT subject_id, namespace, kind, {field_expr}, {model_expr}, embedding \
1337 FROM {table};",
1338 tmp = tmp,
1339 field_expr = field_expr,
1340 model_expr = model_expr,
1341 table = table,
1342 ));
1343
1344 sql.push_str(&format!("DROP TABLE {table};", table = table));
1346
1347 sql.push_str(&format!(
1349 "CREATE VIRTUAL TABLE {table} USING vec0(\
1350 subject_id TEXT PRIMARY KEY, \
1351 namespace TEXT NOT NULL, \
1352 kind TEXT NOT NULL, \
1353 field TEXT NOT NULL, \
1354 embedding_model TEXT NOT NULL, \
1355 embedding float[{dim}] distance_metric=cosine\
1356 );",
1357 table = table,
1358 dim = dim,
1359 ));
1360
1361 sql.push_str(&format!(
1363 "INSERT INTO {table} (subject_id, namespace, kind, field, embedding_model, embedding) \
1364 SELECT subject_id, namespace, kind, field, embedding_model, embedding \
1365 FROM {tmp};",
1366 table = table,
1367 tmp = tmp,
1368 ));
1369
1370 sql.push_str(&format!("DROP TABLE {tmp};", tmp = tmp));
1372 }
1373
1374 if sql.is_empty() {
1375 sql.push_str("SELECT 1;");
1376 }
1377
1378 Ok(sql)
1379}
1380
1381#[derive(Clone, Debug)]
1383pub struct EmbeddingModelRegistryRecord {
1384 pub engine_name: String,
1386 pub model_id: String,
1388 pub key_version: String,
1390 pub dimensions: u32,
1392 pub status: String,
1394 pub activated_at: Option<i64>,
1396 pub superseded_at: Option<i64>,
1398}
1399
1400pub fn query_embedding_models(
1406 db: Option<&std::path::Path>,
1407 engine_filter: Option<&str>,
1408) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
1409 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
1410 std::env::var("HOME")
1411 .map(std::path::PathBuf::from)
1412 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1413 .join(".khive/khive-graph.db")
1414 });
1415 if !path.exists() {
1416 return Ok(Vec::new());
1417 }
1418
1419 let conn = Connection::open(path)?;
1420 let exists: bool = conn.query_row(
1421 "SELECT COUNT(*) > 0 FROM sqlite_master \
1422 WHERE type='table' AND name='_embedding_models'",
1423 [],
1424 |row| row.get(0),
1425 )?;
1426 if !exists {
1427 return Ok(Vec::new());
1428 }
1429
1430 let sql = if engine_filter.is_some() {
1431 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
1432 FROM _embedding_models WHERE engine_name = ?1 \
1433 ORDER BY engine_name, activated_at IS NULL, activated_at"
1434 } else {
1435 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
1436 FROM _embedding_models \
1437 ORDER BY engine_name, activated_at IS NULL, activated_at"
1438 };
1439 let mut stmt = conn.prepare(sql)?;
1440 let map_row = |row: &rusqlite::Row<'_>| {
1441 Ok(EmbeddingModelRegistryRecord {
1442 engine_name: row.get(0)?,
1443 model_id: row.get(1)?,
1444 key_version: row.get(2)?,
1445 dimensions: row.get::<_, i64>(3)? as u32,
1446 status: row.get(4)?,
1447 activated_at: row.get(5)?,
1448 superseded_at: row.get(6)?,
1449 })
1450 };
1451
1452 if let Some(engine) = engine_filter {
1453 stmt.query_map([engine], map_row)?
1454 .collect::<Result<Vec<_>, _>>()
1455 .map_err(Into::into)
1456 } else {
1457 stmt.query_map([], map_row)?
1458 .collect::<Result<Vec<_>, _>>()
1459 .map_err(Into::into)
1460 }
1461}
1462
1463pub(crate) fn build_v18_proposals_applying_sql(
1472 conn: &Connection,
1473) -> Result<String, rusqlite::Error> {
1474 let table_exists: bool = conn.query_row(
1475 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1476 [],
1477 |row| row.get(0),
1478 )?;
1479
1480 if !table_exists {
1481 return Ok("SELECT 1;".to_string());
1482 }
1483
1484 let ddl: String = conn.query_row(
1487 "SELECT sql FROM sqlite_master WHERE type='table' AND name='proposals_open'",
1488 [],
1489 |row| row.get(0),
1490 )?;
1491 if ddl.contains("'applying'") {
1492 return Ok("SELECT 1;".to_string());
1493 }
1494
1495 Ok("\
1501 ALTER TABLE proposals_open RENAME TO proposals_open_v15;\
1502 CREATE TABLE proposals_open (\
1503 proposal_id TEXT PRIMARY KEY,\
1504 namespace TEXT NOT NULL,\
1505 proposer TEXT NOT NULL,\
1506 title TEXT NOT NULL,\
1507 status TEXT NOT NULL CHECK (status IN ('open', 'changes_requested', 'approved', 'applying', 'rejected', 'applied', 'withdrawn')),\
1508 created_at INTEGER NOT NULL,\
1509 updated_at INTEGER NOT NULL,\
1510 expiry INTEGER,\
1511 last_decision TEXT,\
1512 review_count INTEGER NOT NULL DEFAULT 0,\
1513 approve_count INTEGER NOT NULL DEFAULT 0,\
1514 reject_count INTEGER NOT NULL DEFAULT 0\
1515 );\
1516 INSERT INTO proposals_open \
1517 SELECT proposal_id, namespace, proposer, title, status, created_at, updated_at, \
1518 expiry, last_decision, review_count, approve_count, reject_count \
1519 FROM proposals_open_v15;\
1520 DROP TABLE proposals_open_v15;\
1521 CREATE INDEX IF NOT EXISTS idx_proposals_open_ns_status ON proposals_open(namespace, status);\
1522 CREATE INDEX IF NOT EXISTS idx_proposals_open_proposer ON proposals_open(namespace, proposer);\
1523 CREATE INDEX IF NOT EXISTS idx_proposals_open_updated ON proposals_open(namespace, updated_at DESC);\
1524 "
1525 .to_string())
1526}
1527
1528#[cfg(test)]
1533#[path = "migrations_tests.rs"]
1534mod tests;