1use std::fmt::{Display, Formatter};
2use std::time::Instant;
3
4use rusqlite::Connection;
5
6pub const SCHEMA_VERSION: u32 = 15;
7
8pub const PRAGMA_USER_VERSION: &str = "user_version";
13
14pub const SQLITE_SUFFIX: &str = ".sqlite";
16
17pub const WAL_SUFFIX: &str = "-wal";
19
20pub const LOCK_SUFFIX: &str = ".lock";
26
27pub const JOURNAL_SUFFIX: &str = "-journal";
30
31#[must_use]
32pub fn bootstrap_steps() -> &'static [&'static str] {
33 &["create canonical tables", "register projection metadata", "seed rewrite-era configuration"]
34}
35
36pub const CANONICAL_TABLES: &[&str] = &[
43 "canonical_nodes",
44 "canonical_edges",
45 "operational_collections",
46 "operational_mutations",
47 "operational_state",
48];
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct Migration {
52 pub step_id: u32,
53 pub sql: &'static str,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct MigrationStepReport {
58 pub step_id: u32,
59 pub duration_ms: Option<u64>,
60 pub failed: bool,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct MigrationReport {
65 pub schema_version_before: u32,
66 pub schema_version_after: u32,
67 pub migration_steps: Vec<MigrationStepReport>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct MigrationFailureReport {
72 pub schema_version_before: u32,
73 pub schema_version_current: u32,
74 pub migration_steps: Vec<MigrationStepReport>,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum MigrationError {
79 IncompatibleSchemaVersion { seen: u32, supported: u32 },
80 MigrationError(MigrationFailureReport),
81 Storage { message: &'static str },
82}
83
84impl Display for MigrationError {
85 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::IncompatibleSchemaVersion { seen, supported } => {
88 write!(f, "database schema version {seen} is incompatible with supported version {supported}")
89 }
90 Self::MigrationError(report) => write!(
91 f,
92 "schema migration failed at step {}",
93 report.migration_steps.last().map_or(0, |step| step.step_id)
94 ),
95 Self::Storage { message } => write!(f, "schema storage error: {message}"),
96 }
97 }
98}
99
100impl std::error::Error for MigrationError {}
101
102pub const MIGRATIONS: &[Migration] = &[
103 Migration {
104 step_id: 1,
105 sql: "CREATE TABLE IF NOT EXISTS _fathomdb_schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)",
106 },
107 Migration {
108 step_id: 2,
109 sql: "CREATE TABLE IF NOT EXISTS _fathomdb_migrations(step_id INTEGER PRIMARY KEY, applied_at_ms INTEGER NOT NULL);
110 CREATE TABLE IF NOT EXISTS canonical_nodes(write_cursor INTEGER NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL);
111 CREATE TABLE IF NOT EXISTS canonical_edges(write_cursor INTEGER NOT NULL, kind TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL);",
112 },
113 Migration {
114 step_id: 3,
115 sql: "CREATE TABLE IF NOT EXISTS _fathomdb_embedder_profiles(profile TEXT PRIMARY KEY, name TEXT NOT NULL, revision TEXT NOT NULL, dimension INTEGER NOT NULL)",
116 },
117 Migration {
118 step_id: 4,
119 sql: "CREATE TABLE IF NOT EXISTS operational_collections(
120 name TEXT PRIMARY KEY,
121 kind TEXT NOT NULL CHECK(kind IN ('append_only_log', 'latest_state')),
122 schema_json TEXT NOT NULL,
123 retention_json TEXT NOT NULL,
124 format_version INTEGER NOT NULL,
125 created_at INTEGER NOT NULL
126 );
127 CREATE TABLE IF NOT EXISTS operational_mutations(
128 id INTEGER PRIMARY KEY AUTOINCREMENT,
129 collection_name TEXT NOT NULL,
130 record_key TEXT NOT NULL,
131 op_kind TEXT NOT NULL CHECK(op_kind = 'append'),
132 payload_json TEXT NOT NULL,
133 schema_id TEXT,
134 write_cursor INTEGER NOT NULL
135 );
136 CREATE TABLE IF NOT EXISTS operational_state(
137 collection_name TEXT NOT NULL,
138 record_key TEXT NOT NULL,
139 payload_json TEXT NOT NULL,
140 schema_id TEXT,
141 write_cursor INTEGER NOT NULL,
142 PRIMARY KEY(collection_name, record_key)
143 );
144 CREATE TABLE IF NOT EXISTS _fathomdb_open_state(key TEXT PRIMARY KEY, value TEXT NOT NULL);
145 INSERT OR IGNORE INTO operational_collections(
146 name, kind, schema_json, retention_json, format_version, created_at
147 ) VALUES (
148 'projection_failures',
149 'append_only_log',
150 '{\"type\":\"object\"}',
151 '{}',
152 1,
153 0
154 );",
155 },
156 Migration {
157 step_id: 5,
158 sql: "CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
159 body,
160 kind UNINDEXED,
161 write_cursor UNINDEXED
162 );",
163 },
164 Migration {
165 step_id: 6,
166 sql: "CREATE TABLE IF NOT EXISTS _fathomdb_projection_state(
167 kind TEXT PRIMARY KEY,
168 last_enqueued_cursor INTEGER NOT NULL DEFAULT 0,
169 updated_at INTEGER NOT NULL DEFAULT 0
170 );
171 CREATE TABLE IF NOT EXISTS _fathomdb_vector_kinds(
172 kind TEXT PRIMARY KEY,
173 profile TEXT NOT NULL,
174 created_at INTEGER NOT NULL DEFAULT 0
175 );
176 CREATE TABLE IF NOT EXISTS _fathomdb_vector_rows(
177 rowid INTEGER PRIMARY KEY,
178 kind TEXT NOT NULL,
179 write_cursor INTEGER NOT NULL UNIQUE
180 );",
181 },
182 Migration {
183 step_id: 7,
184 sql: "CREATE TABLE IF NOT EXISTS _fathomdb_projection_terminal(
185 write_cursor INTEGER PRIMARY KEY,
186 state TEXT NOT NULL CHECK(state IN ('failed', 'up_to_date'))
187 );",
188 },
189 Migration {
197 step_id: 8,
198 sql: "ALTER TABLE canonical_nodes ADD COLUMN source_id TEXT;
199 ALTER TABLE canonical_edges ADD COLUMN source_id TEXT;
200 CREATE INDEX IF NOT EXISTS canonical_nodes_source_id_idx
201 ON canonical_nodes(source_id);
202 CREATE INDEX IF NOT EXISTS canonical_edges_source_id_idx
203 ON canonical_edges(source_id);",
204 },
205 Migration {
220 step_id: 9,
221 sql: "CREATE TEMP TABLE _vec0_migration_assertion(
235 check_passes INTEGER NOT NULL CHECK(check_passes = 1)
236 );
237 INSERT INTO _vec0_migration_assertion(check_passes)
238 SELECT CASE WHEN EXISTS (
239 SELECT 1 FROM _fathomdb_vector_rows
240 WHERE kind NOT IN ('email','article','paper','meeting','note','todo','doc')
241 ) THEN 0 ELSE 1 END;
242 DROP TABLE _vec0_migration_assertion;",
243 },
244 Migration {
252 step_id: 10,
253 sql: "ALTER TABLE _fathomdb_embedder_profiles ADD COLUMN mean_vec BLOB",
254 },
255 Migration {
269 step_id: 11,
270 sql: "-- MIGRATION-ACCRETION-EXEMPTION: tokenizer-default upgrade (drop+recreate FTS5 projection; no source-record migration)
271 DROP TABLE IF EXISTS search_index;
272 CREATE VIRTUAL TABLE search_index USING fts5(
273 body,
274 kind UNINDEXED,
275 write_cursor UNINDEXED,
276 tokenize = 'porter unicode61 remove_diacritics 2'
277 );",
278 },
279 Migration {
298 step_id: 12,
299 sql: "-- MIGRATION-ACCRETION-EXEMPTION: G0 transaction-time identity substrate
300 ALTER TABLE canonical_nodes ADD COLUMN logical_id TEXT;
301 ALTER TABLE canonical_nodes ADD COLUMN superseded_at INTEGER;
302 ALTER TABLE canonical_edges ADD COLUMN logical_id TEXT;
303 ALTER TABLE canonical_edges ADD COLUMN superseded_at INTEGER;
304 CREATE UNIQUE INDEX IF NOT EXISTS canonical_nodes_logical_active_idx
305 ON canonical_nodes(logical_id) WHERE superseded_at IS NULL;
306 CREATE UNIQUE INDEX IF NOT EXISTS canonical_edges_logical_active_idx
307 ON canonical_edges(logical_id) WHERE superseded_at IS NULL;
308 CREATE INDEX IF NOT EXISTS canonical_nodes_kind_idx
309 ON canonical_nodes(kind);
310 CREATE INDEX IF NOT EXISTS canonical_edges_from_id_idx
311 ON canonical_edges(from_id);
312 CREATE INDEX IF NOT EXISTS canonical_edges_to_id_idx
313 ON canonical_edges(to_id);",
314 },
315 Migration {
331 step_id: 13,
332 sql: "CREATE INDEX IF NOT EXISTS operational_mutations_collection_id_idx
333 ON operational_mutations(collection_name, id);",
334 },
335 Migration {
348 step_id: 14,
349 sql: "-- MIGRATION-ACCRETION-EXEMPTION: G11 edge enrichment (5 additive nullable columns + edge FTS table)
350 ALTER TABLE canonical_edges ADD COLUMN body TEXT;
351 ALTER TABLE canonical_edges ADD COLUMN t_valid TEXT;
352 ALTER TABLE canonical_edges ADD COLUMN t_invalid TEXT;
353 ALTER TABLE canonical_edges ADD COLUMN confidence REAL;
354 ALTER TABLE canonical_edges ADD COLUMN extractor_model_id TEXT;
355 CREATE VIRTUAL TABLE IF NOT EXISTS search_index_edges USING fts5(
356 body,
357 kind UNINDEXED,
358 write_cursor UNINDEXED,
359 tokenize = 'porter unicode61 remove_diacritics 2'
360 );",
361 },
362 Migration {
372 step_id: 15,
373 sql: "-- MIGRATION-ACCRETION-EXEMPTION: R3 temporal_fallback provenance flag (additive nullable BOOLEAN column)
374 ALTER TABLE canonical_edges ADD COLUMN temporal_fallback INTEGER;",
375 },
376];
377
378pub fn migrate(conn: &Connection) -> Result<MigrationReport, MigrationError> {
379 migrate_with_steps(conn, MIGRATIONS)
380}
381
382pub fn migrate_with_steps(
383 conn: &Connection,
384 migrations: &[Migration],
385) -> Result<MigrationReport, MigrationError> {
386 migrate_with_event_sink(conn, migrations, |_| {})
387}
388
389pub fn migrate_with_event_sink(
390 conn: &Connection,
391 migrations: &[Migration],
392 mut emit: impl FnMut(&MigrationStepReport),
393) -> Result<MigrationReport, MigrationError> {
394 let before = user_version(conn)?;
395 if before > SCHEMA_VERSION {
396 return Err(MigrationError::IncompatibleSchemaVersion {
397 seen: before,
398 supported: SCHEMA_VERSION,
399 });
400 }
401
402 let mut current = before;
403 let mut reports = Vec::new();
404
405 for migration in migrations.iter().filter(|migration| migration.step_id > before) {
406 if migration.step_id != current.saturating_add(1) {
407 return Err(MigrationError::Storage {
408 message: "migration registry is not contiguous",
409 });
410 }
411
412 let started = Instant::now();
413 if let Err(_err) = apply_one(conn, migration) {
414 reports.push(MigrationStepReport {
415 step_id: migration.step_id,
416 duration_ms: Some(duration_ms(started)),
417 failed: true,
418 });
419 emit(reports.last().expect("failed step report was just pushed"));
420 let schema_version_current = user_version(conn).unwrap_or(current);
421 return Err(MigrationError::MigrationError(MigrationFailureReport {
422 schema_version_before: before,
423 schema_version_current,
424 migration_steps: reports,
425 }));
426 }
427
428 current = migration.step_id;
429 reports.push(MigrationStepReport {
430 step_id: migration.step_id,
431 duration_ms: Some(duration_ms(started)),
432 failed: false,
433 });
434 emit(reports.last().expect("successful step report was just pushed"));
435 }
436
437 Ok(MigrationReport {
438 schema_version_before: before,
439 schema_version_after: user_version(conn)?,
440 migration_steps: reports,
441 })
442}
443
444fn apply_one(conn: &Connection, migration: &Migration) -> rusqlite::Result<()> {
445 conn.execute_batch("BEGIN IMMEDIATE")?;
446 let result = (|| {
447 conn.execute_batch(migration.sql)?;
448 conn.pragma_update(None, PRAGMA_USER_VERSION, migration.step_id)?;
449 Ok(())
450 })();
451
452 match result {
453 Ok(()) => conn.execute_batch("COMMIT"),
454 Err(err) => {
455 let _ = conn.execute_batch("ROLLBACK");
456 Err(err)
457 }
458 }
459}
460
461fn user_version(conn: &Connection) -> Result<u32, MigrationError> {
462 conn.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0))
463 .map_err(|_| MigrationError::Storage { message: "could not read schema version" })
464}
465
466fn duration_ms(started: Instant) -> u64 {
467 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
468}
469
470#[derive(Clone, Debug, Eq, PartialEq)]
471pub struct MigrationAccretionError {
472 pub offender: String,
473}
474
475impl Display for MigrationAccretionError {
476 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
477 write!(f, "migration accretion guard rejected {}", self.offender)
478 }
479}
480
481impl std::error::Error for MigrationAccretionError {}
482
483pub fn check_migration_accretion(name: &str, sql: &str) -> Result<(), MigrationAccretionError> {
484 let upper = sql.to_ascii_uppercase();
485 let adds_schema = upper.contains("CREATE TABLE") || upper.contains("ADD COLUMN");
486 let names_removal = upper.contains("DROP TABLE") || upper.contains("DROP COLUMN");
487 let has_exemption = sql.contains("-- MIGRATION-ACCRETION-EXEMPTION: ");
488
489 if adds_schema && !names_removal && !has_exemption {
490 return Err(MigrationAccretionError { offender: name.to_string() });
491 }
492
493 Ok(())
494}