Skip to main content

mempill_sqlite/
migrations.rs

1//! Schema migration runner for mempill-sqlite.
2//!
3//! Applies versioned DDL to a rusqlite [`Connection`] in a deterministic, idempotent manner.
4//! Schema version is tracked via SQLite's built-in `user_version` PRAGMA.
5//!
6//! # Intended PRAGMA environment (applied at connection open in connection.rs)
7//! - `PRAGMA journal_mode=WAL;`  — write-ahead log for concurrent reads during writes
8//! - `PRAGMA synchronous=FULL;`  — full durability (mandatory; WAL+NORMAL can lose writes on power loss)
9//! - `PRAGMA foreign_keys=ON;`   — enforce FK constraints defined in DDL
10
11use rusqlite::{Connection, Result};
12
13/// The target schema version this runner brings the database to.
14/// Increment this constant (and add a new migration step) for every future DDL change.
15pub const CURRENT_SCHEMA_VERSION: u32 = 3;
16
17/// Embedded DDL — the 4-table append-only schema (§5).
18const V1_INITIAL_SQL: &str = include_str!("schema/v1_initial.sql");
19
20/// Embedded index definitions (§5).
21const INDEXES_SQL: &str = include_str!("schema/indexes.sql");
22
23/// Embedded DDL — oracle adjudication queue (pending_adjudications table).
24const V2_PENDING_ADJUDICATIONS_SQL: &str = include_str!("schema/v2_pending_adjudications.sql");
25
26/// Embedded DDL — per-endpoint date-granularity columns on claims.
27const V3_DATE_GRANULARITY_SQL: &str = include_str!("schema/v3_date_granularity.sql");
28
29/// Migration error wrapper.
30#[derive(Debug, thiserror::Error)]
31pub enum MigrationError {
32    /// A rusqlite error occurred during schema migration.
33    #[error("SQLite error during migration: {0}")]
34    Sqlite(#[from] rusqlite::Error),
35}
36
37/// Apply all pending migrations to `conn` up to [`CURRENT_SCHEMA_VERSION`].
38///
39/// Idempotent: calling this function on a fully-migrated database is a no-op.
40/// Each migration step runs inside its own transaction so a partial failure leaves the
41/// database at a consistent version boundary (each migration step is fully atomic).
42///
43/// Connection lifecycle and PRAGMA initialisation (`journal_mode=WAL`, `synchronous=FULL`,
44/// `foreign_keys=ON`) are the caller's responsibility (implemented in `connection.rs`).
45pub fn apply_migrations(conn: &Connection) -> Result<(), MigrationError> {
46    let current = user_version(conn)?;
47
48    if current < 1 {
49        apply_v1(conn)?;
50    }
51
52    if current < 2 {
53        apply_v2(conn)?;
54    }
55
56    if current < 3 {
57        apply_v3(conn)?;
58    }
59
60    Ok(())
61}
62
63/// Read the SQLite `user_version` PRAGMA (0 = fresh/uninitialized database).
64fn user_version(conn: &Connection) -> Result<u32, MigrationError> {
65    let v: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
66    Ok(v)
67}
68
69/// Set the SQLite `user_version` PRAGMA.
70///
71/// This PRAGMA write is intentionally NOT inside the DDL transaction because SQLite
72/// does not allow PRAGMA user_version inside a transaction on all versions. We set it
73/// after the DDL transaction commits, so a crash between DDL commit and PRAGMA write is
74/// safe: the DDL tables already exist and `CREATE TABLE IF NOT EXISTS` makes the next
75/// migration run a no-op even if user_version is still 0.
76fn set_user_version(conn: &Connection, version: u32) -> Result<(), MigrationError> {
77    conn.execute_batch(&format!("PRAGMA user_version = {version};"))?;
78    Ok(())
79}
80
81/// Migration v1: create the 4 append-only tables and all structural indexes.
82pub(crate) fn apply_v1(conn: &Connection) -> Result<(), MigrationError> {
83    conn.execute_batch(V1_INITIAL_SQL)?;
84    conn.execute_batch(INDEXES_SQL)?;
85    set_user_version(conn, 1)?;
86    Ok(())
87}
88
89/// Migration v2: create the oracle adjudication queue table and its indexes.
90pub(crate) fn apply_v2(conn: &Connection) -> Result<(), MigrationError> {
91    conn.execute_batch(V2_PENDING_ADJUDICATIONS_SQL)?;
92    set_user_version(conn, 2)?;
93    Ok(())
94}
95
96/// Migration v3: add `valid_time_start_granularity` and `valid_time_end_granularity`
97/// nullable TEXT columns to the `claims` table.
98///
99/// Old rows upgrade cleanly: the new columns default to NULL, which the read path maps to
100/// `None` on `ValidTime::start_granularity` and `ValidTime::end_granularity`.
101pub(crate) fn apply_v3(conn: &Connection) -> Result<(), MigrationError> {
102    conn.execute_batch(V3_DATE_GRANULARITY_SQL)?;
103    set_user_version(conn, 3)?;
104    Ok(())
105}
106
107// ── Tests ──────────────────────────────────────────────────────────────────────
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use rusqlite::Connection;
113
114    fn open_memory() -> Connection {
115        Connection::open_in_memory().expect("in-memory database should open")
116    }
117
118    /// Helper: collect the column names for a given table from sqlite_master PRAGMA.
119    fn column_names(conn: &Connection, table: &str) -> Vec<String> {
120        let mut stmt = conn
121            .prepare(&format!("PRAGMA table_info({table})"))
122            .unwrap();
123        stmt.query_map([], |row| row.get::<_, String>(1))
124            .unwrap()
125            .map(|r| r.unwrap())
126            .collect()
127    }
128
129    /// Helper: check whether an index exists in sqlite_master.
130    fn index_exists(conn: &Connection, index_name: &str) -> bool {
131        let count: u32 = conn
132            .query_row(
133                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?1",
134                [index_name],
135                |row| row.get(0),
136            )
137            .unwrap_or(0);
138        count > 0
139    }
140
141    /// Helper: check whether a table exists in sqlite_master.
142    fn table_exists(conn: &Connection, table_name: &str) -> bool {
143        let count: u32 = conn
144            .query_row(
145                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
146                [table_name],
147                |row| row.get(0),
148            )
149            .unwrap_or(0);
150        count > 0
151    }
152
153    #[test]
154    fn all_four_tables_exist_after_migration() {
155        let conn = open_memory();
156        apply_migrations(&conn).expect("migrations should succeed");
157
158        assert!(table_exists(&conn, "claims"), "claims table must exist");
159        assert!(
160            table_exists(&conn, "validity_assertions"),
161            "validity_assertions table must exist"
162        );
163        assert!(
164            table_exists(&conn, "ledger_entries"),
165            "ledger_entries table must exist"
166        );
167        assert!(
168            table_exists(&conn, "claim_edges"),
169            "claim_edges table must exist"
170        );
171    }
172
173    #[test]
174    fn claims_table_has_expected_columns() {
175        let conn = open_memory();
176        apply_migrations(&conn).expect("migrations should succeed");
177
178        let cols = column_names(&conn, "claims");
179        for expected in &[
180            "claim_id",
181            "agent_id",
182            "subject",
183            "predicate",
184            "value",
185            "cardinality",
186            "provenance_label",
187            "nearest_external_anchor_id",
188            "derivation_depth",
189            "tx_time",
190            "valid_time_start",
191            "valid_time_end",
192            "valid_time_confidence",
193            "value_confidence",
194            "criticality",
195            "derived_from",
196            "metadata",
197            "snapshot_schema_version",
198            "embedding_model_id",
199            // v3 — date granularity
200            "valid_time_start_granularity",
201            "valid_time_end_granularity",
202        ] {
203            assert!(
204                cols.contains(&expected.to_string()),
205                "claims table missing column: {expected}"
206            );
207        }
208    }
209
210    #[test]
211    fn validity_assertions_table_has_expected_columns() {
212        let conn = open_memory();
213        apply_migrations(&conn).expect("migrations should succeed");
214
215        let cols = column_names(&conn, "validity_assertions");
216        for expected in &[
217            "assertion_id",
218            "agent_id",
219            "target_claim_id",
220            "assertion_kind",
221            "bound_at",
222            "reopen_at",
223            "provenance_label",
224            "value_confidence",
225            "valid_time_confidence",
226            "asserted_at",
227        ] {
228            assert!(
229                cols.contains(&expected.to_string()),
230                "validity_assertions table missing column: {expected}"
231            );
232        }
233    }
234
235    #[test]
236    fn ledger_entries_table_has_expected_columns() {
237        let conn = open_memory();
238        apply_migrations(&conn).expect("migrations should succeed");
239
240        let cols = column_names(&conn, "ledger_entries");
241        for expected in &[
242            "entry_id",
243            "agent_id",
244            "claim_id",
245            "event_kind",
246            "disposition",
247            "rationale",
248            "recorded_at",
249        ] {
250            assert!(
251                cols.contains(&expected.to_string()),
252                "ledger_entries table missing column: {expected}"
253            );
254        }
255    }
256
257    #[test]
258    fn claim_edges_table_has_expected_columns() {
259        let conn = open_memory();
260        apply_migrations(&conn).expect("migrations should succeed");
261
262        let cols = column_names(&conn, "claim_edges");
263        for expected in &[
264            "edge_id",
265            "agent_id",
266            "from_claim_id",
267            "to_claim_id",
268            "edge_kind",
269            "created_at",
270        ] {
271            assert!(
272                cols.contains(&expected.to_string()),
273                "claim_edges table missing column: {expected}"
274            );
275        }
276    }
277
278    #[test]
279    fn structural_subject_line_index_exists() {
280        let conn = open_memory();
281        apply_migrations(&conn).expect("migrations should succeed");
282
283        assert!(
284            index_exists(&conn, "idx_claims_subject_line"),
285            "primary structural subject-line index must exist"
286        );
287    }
288
289    #[test]
290    fn all_indexes_exist() {
291        let conn = open_memory();
292        apply_migrations(&conn).expect("migrations should succeed");
293
294        let expected_indexes = [
295            "idx_claims_subject_line",
296            "idx_validity_assertions_target",
297            "idx_ledger_agent_time",
298            "idx_edges_from",
299            "idx_edges_to",
300            "idx_claims_provenance",
301        ];
302        for idx in &expected_indexes {
303            assert!(
304                index_exists(&conn, idx),
305                "index missing after migration: {idx}"
306            );
307        }
308    }
309
310    #[test]
311    fn apply_migrations_is_idempotent() {
312        let conn = open_memory();
313        apply_migrations(&conn).expect("first migration should succeed");
314        apply_migrations(&conn).expect("second migration must not error (idempotent)");
315        apply_migrations(&conn).expect("third migration must not error (idempotent)");
316
317        // Tables and indexes must still be present after repeated runs.
318        assert!(table_exists(&conn, "claims"));
319        assert!(table_exists(&conn, "claim_edges"));
320        assert!(index_exists(&conn, "idx_claims_subject_line"));
321    }
322
323    #[test]
324    fn reserved_columns_exist_on_claims() {
325        let conn = open_memory();
326        apply_migrations(&conn).expect("migrations should succeed");
327
328        let cols = column_names(&conn, "claims");
329        assert!(
330            cols.contains(&"metadata".to_string()),
331            "reserved column 'metadata' must exist on claims"
332        );
333        assert!(
334            cols.contains(&"snapshot_schema_version".to_string()),
335            "reserved column 'snapshot_schema_version' must exist on claims"
336        );
337        assert!(
338            cols.contains(&"embedding_model_id".to_string()),
339            "reserved column 'embedding_model_id' must exist on claims"
340        );
341    }
342
343    #[test]
344    fn schema_version_is_set_after_migration() {
345        let conn = open_memory();
346        apply_migrations(&conn).expect("migrations should succeed");
347
348        let v = user_version(&conn).expect("user_version should be readable");
349        assert_eq!(
350            v, CURRENT_SCHEMA_VERSION,
351            "user_version PRAGMA must equal CURRENT_SCHEMA_VERSION after migration"
352        );
353    }
354
355    #[test]
356    fn pending_adjudications_table_exists_after_migration() {
357        let conn = open_memory();
358        apply_migrations(&conn).expect("migrations should succeed");
359        assert!(
360            table_exists(&conn, "pending_adjudications"),
361            "pending_adjudications table must exist after v2 migration"
362        );
363    }
364
365    #[test]
366    fn pending_adjudications_table_has_expected_columns() {
367        let conn = open_memory();
368        apply_migrations(&conn).expect("migrations should succeed");
369
370        let cols = column_names(&conn, "pending_adjudications");
371        for expected in &[
372            "handle_id",
373            "agent_id",
374            "subject",
375            "predicate",
376            "challenger_claim_ref",
377            "incumbent_claim_ref",
378            "request_payload",
379            "queued_at",
380            "expires_at",
381            "status",
382        ] {
383            assert!(
384                cols.contains(&expected.to_string()),
385                "pending_adjudications table missing column: {expected}"
386            );
387        }
388    }
389
390    #[test]
391    fn pending_adjudications_indexes_exist_after_migration() {
392        let conn = open_memory();
393        apply_migrations(&conn).expect("migrations should succeed");
394
395        // Agent-id lookup index (oracle poller).
396        assert!(
397            index_exists(&conn, "idx_pending_adj_agent_id"),
398            "idx_pending_adj_agent_id must exist after v2 migration"
399        );
400        // Partial TTL index (WHERE expires_at IS NOT NULL AND status = 'pending').
401        assert!(
402            index_exists(&conn, "idx_pending_adj_expires_at"),
403            "idx_pending_adj_expires_at must exist after v2 migration"
404        );
405    }
406
407    #[test]
408    fn apply_migrations_v2_is_idempotent() {
409        let conn = open_memory();
410        apply_migrations(&conn).expect("first migration should succeed");
411        apply_migrations(&conn).expect("second migration must not error (idempotent)");
412        apply_migrations(&conn).expect("third migration must not error (idempotent)");
413
414        assert!(table_exists(&conn, "pending_adjudications"));
415        assert!(index_exists(&conn, "idx_pending_adj_agent_id"));
416        assert!(index_exists(&conn, "idx_pending_adj_expires_at"));
417    }
418
419    // ── v3 migration tests ────────────────────────────────────────────────────
420
421    /// v3 adds the two nullable granularity columns to the claims table.
422    #[test]
423    fn v3_granularity_columns_exist_after_migration() {
424        let conn = open_memory();
425        apply_migrations(&conn).expect("migrations should succeed");
426
427        let cols = column_names(&conn, "claims");
428        assert!(
429            cols.contains(&"valid_time_start_granularity".to_string()),
430            "claims table missing column: valid_time_start_granularity (added in v3)"
431        );
432        assert!(
433            cols.contains(&"valid_time_end_granularity".to_string()),
434            "claims table missing column: valid_time_end_granularity (added in v3)"
435        );
436    }
437
438    /// v3 upgrade invariant: a DB at v2 can be upgraded to v3, and old rows (NULL columns)
439    /// still read back cleanly.
440    #[test]
441    fn v3_upgrade_from_v2_succeeds() {
442        // Start from scratch and apply only v1 + v2.
443        let conn = open_memory();
444        apply_v1(&conn).expect("v1 must succeed");
445        apply_v2(&conn).expect("v2 must succeed");
446        assert_eq!(user_version(&conn).unwrap(), 2, "after v2 version must be 2");
447
448        // Verify granularity columns don't exist yet.
449        let cols_before = column_names(&conn, "claims");
450        assert!(
451            !cols_before.contains(&"valid_time_start_granularity".to_string()),
452            "granularity column must not exist before v3"
453        );
454
455        // Now upgrade to v3.
456        apply_v3(&conn).expect("v3 upgrade must succeed");
457        assert_eq!(user_version(&conn).unwrap(), 3, "after v3 version must be 3");
458
459        let cols_after = column_names(&conn, "claims");
460        assert!(
461            cols_after.contains(&"valid_time_start_granularity".to_string()),
462            "granularity column must exist after v3"
463        );
464        assert!(
465            cols_after.contains(&"valid_time_end_granularity".to_string()),
466            "granularity column must exist after v3"
467        );
468    }
469
470    /// Running apply_migrations on a v2 database upgrades it to v3.
471    #[test]
472    fn apply_migrations_upgrades_v2_to_v3() {
473        let conn = open_memory();
474        apply_v1(&conn).expect("v1 must succeed");
475        apply_v2(&conn).expect("v2 must succeed");
476
477        // Simulate an existing v2 DB being opened with the new library.
478        apply_migrations(&conn).expect("apply_migrations must succeed on v2 db");
479
480        let v = user_version(&conn).unwrap();
481        assert_eq!(v, CURRENT_SCHEMA_VERSION, "version must be CURRENT_SCHEMA_VERSION after upgrade");
482
483        let cols = column_names(&conn, "claims");
484        assert!(cols.contains(&"valid_time_start_granularity".to_string()));
485        assert!(cols.contains(&"valid_time_end_granularity".to_string()));
486    }
487}