Skip to main content

fathomdb_schema/
lib.rs

1use std::fmt::{Display, Formatter};
2use std::time::Instant;
3
4use rusqlite::Connection;
5
6pub const SCHEMA_VERSION: u32 = 15;
7
8/// SQLite `PRAGMA` name carrying the on-disk schema-version sentinel.
9///
10/// Public on-disk surface per `dev/interfaces/wire.md` § Schema-version
11/// sentinel; advanced by successful migrations per `dev/design/migrations.md`.
12pub const PRAGMA_USER_VERSION: &str = "user_version";
13
14/// Suffix of the canonical SQLite database file (`<db-name>.sqlite`).
15pub const SQLITE_SUFFIX: &str = ".sqlite";
16
17/// Suffix of the SQLite write-ahead log file (`<db-name>.sqlite-wal`).
18pub const WAL_SUFFIX: &str = "-wal";
19
20/// Suffix of the sidecar lock file (`<db-name>.sqlite.lock`).
21///
22/// Per `dev/design/bindings.md` § 7, this sidecar flock is the load-bearing
23/// cross-process exclusion layer; it surfaces lock contention before SQLite
24/// I/O begins.
25pub const LOCK_SUFFIX: &str = ".lock";
26
27/// Suffix of the optional SQLite rollback journal file
28/// (`<db-name>.sqlite-journal`).
29pub 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
36/// Canonical tables owned by the rewrite-era schema, in stable display
37/// order. Excludes FTS, vec0, and projection shadow tables (re-derivable
38/// from canonical state) and internal `_fathomdb_*` metadata.
39///
40/// `doctor dump-row-counts` enumerates this set; `doctor dump-schema`
41/// uses it to order canonical tables ahead of derived/internal ones.
42pub 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    // Phase 9 Pack B — REQ-026 / AC-028a/b/c / AC-042 recovery seam.
190    // `source_id` is nullable; existing canonical rows back-fill to NULL,
191    // so reads from older callers stay schema-stable. REQ-045 accretion
192    // offset is documented in `migrations/008_source_id.sql` as inherently
193    // impossible for this slice (every existing canonical column is
194    // load-bearing for replay / projections / recovery locators); the
195    // next schema-touching pack carries the offset budget for two adds.
196    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    // 0.7.0 Pack 1 — Vector binary-quantization data encoding.
206    // Per `dev/design/0.7.0-vector-quant-pack1.md` D4 (fix-3). Stages
207    // the existing f32 corpus + kind mapping into a regular SQL table,
208    // drops + recreates `vector_default` with the new schema (sibling
209    // `embedding_bin bit[768]`, `source_type TEXT partition key`,
210    // `kind TEXT`, `created_at INTEGER`), then repopulates with
211    // SQL-side `vec_quantize_binary` and the D3 CASE mapping. A
212    // prefix CHECK-constraint preflight aborts the migration if any
213    // `_fathomdb_vector_rows.kind` is outside the locked vocabulary.
214    //
215    // `<dim>=768` is hardcoded against the default profile
216    // (`load_default_profile` -> `DEFAULT_EMBEDDER_DIMENSION` in
217    // fathomdb-engine). The design notes this constraint and defers
218    // a runtime-dim migration to 0.7.1.
219    Migration {
220        step_id: 9,
221        // SQL-side: D4 fix-3.1 preflight only. The vec0 reshape itself is
222        // dim-aware and lives in the engine crate's
223        // `ensure_vector_partition_pack1` (called by `ensure_vector_partition`
224        // immediately after `migrate_with_event_sink` returns). Splitting the
225        // preflight (SQL, in-tx with `apply_one`) from the reshape (Rust,
226        // dim-driven by `_fathomdb_embedder_profiles.dimension`) is required
227        // because `fathomdb-schema::Migration` is a `&'static str` with no
228        // runtime parameterization, and the existing dim=8 / dim=384 test
229        // suite must stay GREEN. The reshape is idempotent across crashes:
230        // if open fails between this step's commit (user_version=9) and the
231        // Rust reshape, the next open re-detects the old shape and replays
232        // the reshape. See dev/plans/runs/0.7.0-PVQ-P1-IMPL-output.json
233        // for the design-memo deviation note.
234        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    // 0.7.1 EU-5a2 — mean-centering schema column.
245    // Per `dev/design/embedder.md` §0.2: nullable BLOB holding the
246    // pinned per-workspace mean vector for the default profile. Byte
247    // length, when non-NULL, MUST equal `4 * dimension` (f32 little-endian).
248    // Pure additive ALTER; SQLite stores NULL for the pre-existing row.
249    // Lifecycle (compute-once-on-first-ingest threshold-pin) is in the
250    // engine crate, not the schema layer.
251    Migration {
252        step_id: 10,
253        sql: "ALTER TABLE _fathomdb_embedder_profiles ADD COLUMN mean_vec BLOB",
254    },
255    // 0.8.0 Slice 5 (G1) — global FTS5 tokenizer-default upgrade.
256    // Per `dev/plans/0.8.0-implementation.md` § "Slice 5" and the design
257    // memo `dev/design/0.8.0-slice-5-G1-design.md`. Migrations are
258    // forward-only and immutable, and FTS5 has no `ALTER … tokenize`, so the
259    // tokenizer default is upgraded by dropping and recreating the
260    // `search_index` virtual table rather than editing the step-5 DDL (which
261    // would change the tokenizer for new DBs only). The drop+recreate leaves
262    // the FTS index empty on a migrated DB; the engine re-tokenizes from the
263    // canonical source rows immediately after this step lands (open path,
264    // `reproject_search_index_after_tokenizer_upgrade`) — projection-only, no
265    // source-record migration. `DROP TABLE` already satisfies the accretion
266    // guard's `names_removal` branch; the exemption marker is carried to
267    // document intent and match the established pattern.
268    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    // 0.8.0 Slice 15 (G0 KEYSTONE) — transaction-time canonical-identity
280    // substrate. Per `dev/adr/ADR-0.8.0-canonical-identity-substrate.md`
281    // (SIGNED 2026-06-03) and `dev/design/slice-15-g0-design.md`. Two additive
282    // nullable columns on BOTH canonical tables: `logical_id TEXT` (stable
283    // cross-re-ingestion identity; NULL = legacy/own-identity row) and
284    // `superseded_at INTEGER` (transaction-time tombstone; NULL = active row).
285    // A partial UNIQUE INDEX `(logical_id) WHERE superseded_at IS NULL` per table
286    // enforces one active version per logical id — scoped to `logical_id` ALONE
287    // (Decision 5, HITL-SIGNED 2026-06-05; `kind` is payload/classification on
288    // nodes and relationship-type on edges, NEVER an identity-scope component).
289    // NULL-safe, so the many legacy NULL-logical_id rows never collide (SQLite
290    // treats each NULL as distinct; load-bearing). The folded G4/G5 read indexes
291    // (`canonical_nodes(kind)`, `canonical_edges(from_id)/(to_id)`) ride this one
292    // accretion offset budget. Pure additive ALTER (no DROP) → the exemption
293    // marker is REQUIRED (the accretion guard rejects ADD COLUMN without it);
294    // legacy rows read NULL with no data migration / re-open (in-place ALTER).
295    // Step-12 amended IN PLACE (Slice 31, no SCHEMA_VERSION bump): already-migrated
296    // local v12 DBs keep the old compound index until rebuilt (HITL: disposable).
297    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    // 0.8.0 Slice 33 (G3 / F4-READ) — op-store paginated read-back hardening.
316    // Per `dev/design/slice-33-cursor-hardening-design.md`. The governed
317    // `read.collection` / `read.mutations` SELECT is
318    // `WHERE collection_name = ?1 AND id > ?2 ORDER BY id LIMIT ?3`. Without an
319    // index on `collection_name`, SQLite rides the `id` PRIMARY KEY (EXPLAIN:
320    // `SEARCH … USING INTEGER PRIMARY KEY (rowid>?)`), scanning the id-ordered
321    // log and filtering `collection_name` row-by-row — O(rows-scanned) for a
322    // small collection inside a large multi-collection log. The composite
323    // `(collection_name, id)` index makes the plan index-driven (EXPLAIN:
324    // `SEARCH … USING INDEX operational_mutations_collection_id_idx
325    // (collection_name=? AND id>?)`): the leading equality on `collection_name`
326    // fixes the prefix, the trailing `id` serves BOTH the after-id cursor range
327    // and `ORDER BY id` with no temp B-tree — O(page). Pure additive
328    // `CREATE INDEX` (no table/column add, no DROP, no table reshape), so the
329    // accretion guard does not flag it and no exemption marker is required.
330    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    // 0.8.1 Slice 15 (G11) — fact-on-edge enrichment + edge projectability.
336    // Per `dev/adr/ADR-0.8.1-graph-substrate-g11-migration.md` (HITL-SIGNED
337    // 2026-06-13). Five additive nullable columns on `canonical_edges`:
338    //   `body`              — the fact/relationship text for FTS + vector projection
339    //   `t_valid`           — event valid-time (ISO-8601); NULL = "still valid"
340    //   `t_invalid`         — event invalid-time (ISO-8601); NULL = "still valid"
341    //   `confidence`        — extraction confidence ∈ [0.0, 1.0] from the harness
342    //   `extractor_model_id`— opaque model id from BYO-LLM harness `ready.model`
343    // All five are nullable; pre-G11 rows read NULL (no data migration required).
344    // Also creates `search_index_edges` FTS5 virtual table for edge-body FTS
345    // projection (Option B: separate table, no modification to the existing
346    // `search_index` path). MIGRATION-ACCRETION-EXEMPTION required for ADD COLUMN.
347    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    // 0.8.1 Slice 30 (R3) SCHEMA-GATE-1 — temporal_fallback provenance flag.
363    // HITL-SIGNED 2026-06-13: approved additive schema bump.
364    // Edges whose `t_valid` was defaulted to `created_at` by the ELPS extractor
365    // (not text-grounded) carry this flag so the graph-arm BFS can exclude them
366    // from temporal queries. NULL = not a fallback (pre-column rows and edges
367    // written without the flag are treated as NOT temporal_fallback — safe default
368    // since they were written before provenance tracking existed or via a direct
369    // write where the caller owns the t_valid).
370    // MIGRATION-ACCRETION-EXEMPTION required for ADD COLUMN.
371    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}