Skip to main content

meerkat_sqlite/
ledger.rs

1//! Per-file schema migration ledger.
2//!
3//! Every SQLite file carries a `meerkat_schema(domain TEXT PRIMARY KEY,
4//! version INTEGER NOT NULL)` table with exactly one row per schema domain.
5//! Each store registers its ordered, idempotent migrations; the existing DDL
6//! of every store is migration 0001 of its domain.
7//!
8//! # The pinned transaction protocol
9//!
10//! Idempotent migration functions alone do not make concurrent opens safe,
11//! so the runner pins a minimal protocol (consumed unchanged by downstream
12//! adopters):
13//!
14//! 1. exactly one ledger row per domain;
15//! 2. `BEGIN IMMEDIATE`;
16//! 3. re-read the version *inside* that transaction;
17//! 4. reject a future version before any mutation
18//!    ([`SqliteStoreError::SchemaFromTheFuture`]);
19//! 5. execute the pending migrations and the ledger update atomically in the
20//!    same transaction — custody is verified with a runner-owned savepoint
21//!    around each body, so a body that COMMITs or ROLLBACKs underneath the
22//!    runner is refused ([`SqliteStoreError::MigrationBrokeTransaction`])
23//!    even when it re-BEGINs a fresh transaction afterwards.
24//!
25//! A table merely *named* `meerkat_schema` is not trusted: before any read
26//! the pinned column shape is validated against `main`'s catalog, versions
27//! must be positive, and at most one row may exist per domain
28//! ([`SqliteStoreError::LedgerMalformed`] otherwise). All ledger SQL is
29//! `main.`-qualified, so a TEMP table shadowing the name can neither satisfy
30//! nor bypass the ledger.
31//!
32//! Concurrent opens race safely: the loser's in-transaction re-read sees the
33//! winner's committed version and applies nothing.
34//!
35//! # Migrations on files older than the ledger
36//!
37//! Files written before the ledger existed carry tables of some historical
38//! vintage and no `meerkat_schema` row (version 0). Migration 0001 is the
39//! store's `CREATE ... IF NOT EXISTS` DDL, which converges missing tables
40//! without touching existing ones; later migrations guard internally (for
41//! example `PRAGMA table_info` probes before `ALTER TABLE`) exactly like the
42//! historical upgrade functions they were lifted from. Running the full
43//! sequence therefore converges a file of any vintage and stamps it — this
44//! is also what the offline ledger-baseline migration case does, under the
45//! maintenance fence.
46//!
47//! Foreign domain rows (other stores co-tenanting the same file) are never
48//! read or written; the ledger keys strictly by domain name.
49
50use rusqlite::{Connection, OptionalExtension, Transaction};
51
52use crate::error::SqliteStoreError;
53
54const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
55    domain TEXT PRIMARY KEY,
56    version INTEGER NOT NULL
57)";
58
59/// Custody marker established inside the runner's transaction immediately
60/// before each migration body. A savepoint is discarded when its enclosing
61/// transaction ends — by COMMIT or ROLLBACK alike — so it survives the body
62/// exactly when the runner's transaction does.
63const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
64const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
65
66/// One schema migration step for a domain.
67#[derive(Debug)]
68pub struct Migration {
69    /// Target version this migration brings the domain to. Versions are
70    /// contiguous and start at 1.
71    pub version: i64,
72    /// Stable human-readable name (shows up in errors and reports).
73    pub name: &'static str,
74    /// The migration body. Runs inside the runner's IMMEDIATE transaction;
75    /// it must not end that transaction (nested savepoints of its own are
76    /// fine). Bodies lifted from historical upgrade functions keep their
77    /// internal idempotence guards.
78    pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
79}
80
81/// A store's schema domain: its ledger name plus the ordered migration list.
82#[derive(Debug)]
83pub struct SchemaDomain {
84    /// Ledger key. Kebab-case, stable forever (it is persisted in files).
85    pub name: &'static str,
86    /// Ordered migrations, versions contiguous from 1.
87    pub migrations: &'static [Migration],
88}
89
90impl SchemaDomain {
91    /// Highest version this binary knows for the domain.
92    pub fn supported_version(&self) -> i64 {
93        self.migrations.last().map_or(0, |m| m.version)
94    }
95
96    fn validate(&self) -> Result<(), SqliteStoreError> {
97        for (idx, migration) in self.migrations.iter().enumerate() {
98            let expected = idx as i64 + 1;
99            if migration.version != expected {
100                return Err(SqliteStoreError::InvalidMigrationList {
101                    domain: self.name.to_string(),
102                    detail: format!(
103                        "migration at position {idx} has version {}, expected {expected} \
104                         (versions must be contiguous from 1)",
105                        migration.version
106                    ),
107                });
108            }
109        }
110        Ok(())
111    }
112}
113
114/// Outcome of [`apply_domain_migrations`].
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct LedgerReport {
117    /// Version found before this call (0 = no ledger row).
118    pub from_version: i64,
119    /// Version after this call.
120    pub to_version: i64,
121}
122
123impl LedgerReport {
124    /// True when this call applied at least one migration.
125    pub fn migrated(&self) -> bool {
126        self.to_version > self.from_version
127    }
128}
129
130/// Read a domain's ledger version without applying anything.
131///
132/// `Ok(None)` means the file has no ledger table or no row for the domain —
133/// a pre-ledger file. Read-only diagnostic surfaces (doctor) use this.
134/// A ledger table that fails the pinned-shape or version validation yields
135/// [`SqliteStoreError::LedgerMalformed`], never a healed reading.
136pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
137    if !ledger_table_exists(conn)? {
138        return Ok(None);
139    }
140    validate_ledger_shape(conn)?;
141    read_version(conn, domain)
142}
143
144/// Refuse when the file's ledger already records a version of `domain`
145/// newer than this binary supports, without mutating anything.
146///
147/// This is the [`crate::profile::OpenOptions::schema_preflight`] hook: the
148/// Primary profile runs it before its mutating pragmas so an old binary
149/// leaves a future database's logical content unmodified. Reading the
150/// ledger of a WAL-mode file over a read-write connection may still touch
151/// its `-wal`/`-shm` sidecars
152/// ([`crate::profile::WriteContact::ReadOnlyWalSidecars`]); the main
153/// database file itself is not written. Files without a ledger (fresh or
154/// pre-ledger) pass; the pinned in-transaction re-check in
155/// [`apply_domain_migrations`] remains the migration-time authority.
156pub fn refuse_future_schema(
157    conn: &Connection,
158    domain: &SchemaDomain,
159) -> Result<(), SqliteStoreError> {
160    if let Some(found) = domain_version(conn, domain.name)? {
161        let supported = domain.supported_version();
162        if found > supported {
163            return Err(SqliteStoreError::SchemaFromTheFuture {
164                domain: domain.name.to_string(),
165                found,
166                supported,
167            });
168        }
169    }
170    Ok(())
171}
172
173/// Bring `domain` up to date in the file behind `conn`, per the pinned
174/// protocol. Returns the version movement.
175///
176/// The fast path (already at the supported version) costs one read; a future
177/// version is refused before any mutation.
178pub fn apply_domain_migrations(
179    conn: &mut Connection,
180    domain: &SchemaDomain,
181) -> Result<LedgerReport, SqliteStoreError> {
182    domain.validate()?;
183    let supported = domain.supported_version();
184
185    // Fast path: no write transaction when the file is already current.
186    if ledger_table_exists(conn)? {
187        validate_ledger_shape(conn)?;
188        if let Some(version) = read_version(conn, domain.name)? {
189            if version == supported {
190                return Ok(LedgerReport {
191                    from_version: version,
192                    to_version: version,
193                });
194            }
195            if version > supported {
196                return Err(SqliteStoreError::SchemaFromTheFuture {
197                    domain: domain.name.to_string(),
198                    found: version,
199                    supported,
200                });
201            }
202        }
203    }
204
205    // The ledger table's own DDL is the one statement allowed outside the
206    // protocol transaction: it is idempotent and racing creators converge.
207    conn.execute_batch(CREATE_LEDGER_SQL)?;
208
209    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
210    // Re-validate inside the write transaction: the CREATE above is
211    // IF NOT EXISTS, so a pre-existing (possibly foreign) table survives it
212    // and must not be stamped into.
213    validate_ledger_shape(&tx)?;
214    // Re-read inside the write transaction: a concurrent open may have
215    // migrated between the fast path and lock acquisition.
216    let current = read_version(&tx, domain.name)?.unwrap_or(0);
217    if current > supported {
218        // Reject before any mutation; the transaction commits nothing.
219        return Err(SqliteStoreError::SchemaFromTheFuture {
220            domain: domain.name.to_string(),
221            found: current,
222            supported,
223        });
224    }
225    if current == supported {
226        return Ok(LedgerReport {
227            from_version: current,
228            to_version: current,
229        });
230    }
231
232    for migration in domain.migrations.iter().filter(|m| m.version > current) {
233        tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
234        (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
235            domain: domain.name.to_string(),
236            version: migration.version,
237            name: migration.name.to_string(),
238            source,
239        })?;
240        // The `&Transaction` handed to the body cannot type-prevent COMMIT /
241        // ROLLBACK statements, so custody is verified instead. Autocommit
242        // going true is the cheap first line, but it misses a body that
243        // ended the transaction and then re-BEGAN one; the savepoint is the
244        // authority: RELEASE fails exactly when the savepoint no longer
245        // exists, i.e. the body ended the runner's transaction (COMMIT and
246        // ROLLBACK both discard it), whether or not it opened a new one.
247        // Stamping the ledger inside such a foreign transaction would commit
248        // separately from — or after rollback of — the schema work.
249        if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
250            return Err(SqliteStoreError::MigrationBrokeTransaction {
251                domain: domain.name.to_string(),
252                version: migration.version,
253                name: migration.name.to_string(),
254            });
255        }
256    }
257    tx.execute(
258        "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
259         ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
260        rusqlite::params![domain.name, supported],
261    )?;
262    tx.commit()?;
263
264    Ok(LedgerReport {
265        from_version: current,
266        to_version: supported,
267    })
268}
269
270fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
271    let exists = conn
272        .query_row(
273            "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
274            [],
275            |_| Ok(()),
276        )
277        .optional()?
278        .is_some();
279    Ok(exists)
280}
281
282fn malformed(detail: String) -> SqliteStoreError {
283    SqliteStoreError::LedgerMalformed { detail }
284}
285
286/// Validate the pinned ledger shape against `main`'s real catalog.
287///
288/// `domain` must be `TEXT` and the *sole* primary-key column (a composite
289/// key would permit multiple rows per domain); `version` must be
290/// `INTEGER NOT NULL`. Columns beyond the pinned pair are tolerated as long
291/// as they carry no primary-key position, so a future ledger protocol can
292/// extend the table compatibly. The schema-qualified `pragma_table_info`
293/// resolves in `main`, so a TEMP shadow cannot satisfy this check.
294fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
295    let mut stmt = conn.prepare(
296        "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
297    )?;
298    let mut rows = stmt.query([])?;
299    let mut domain_ok = false;
300    let mut version_ok = false;
301    while let Some(row) = rows.next()? {
302        let name: String = row.get(0)?;
303        let decl_type: String = row.get(1)?;
304        let notnull: bool = row.get(2)?;
305        let pk: i64 = row.get(3)?;
306        match name.as_str() {
307            "domain" => {
308                if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
309                    return Err(malformed(format!(
310                        "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
311                         with pk position {pk}"
312                    )));
313                }
314                domain_ok = true;
315            }
316            "version" => {
317                if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
318                    return Err(malformed(format!(
319                        "column `version` must be non-key `INTEGER NOT NULL`, found type \
320                         `{decl_type}` notnull={notnull} pk position {pk}"
321                    )));
322                }
323                version_ok = true;
324            }
325            other => {
326                if pk != 0 {
327                    return Err(malformed(format!(
328                        "unexpected primary-key column `{other}`"
329                    )));
330                }
331            }
332        }
333    }
334    if !domain_ok || !version_ok {
335        return Err(malformed(
336            "table lacks the pinned `domain`/`version` columns".to_string(),
337        ));
338    }
339    Ok(())
340}
341
342/// Read one domain's version, refusing corrupt ledger state typed: more than
343/// one row per domain (impossible under the validated single-column primary
344/// key; kept as defense in depth) and non-positive versions (0 is the
345/// implicit "no row" reading and negatives are meaningless — a stored
346/// non-positive version is damage to refuse, not an old schema to re-migrate
347/// over).
348fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
349    let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
350    let mut rows = stmt.query([domain])?;
351    let Some(row) = rows.next()? else {
352        return Ok(None);
353    };
354    let version: i64 = row.get(0)?;
355    if rows.next()?.is_some() {
356        return Err(malformed(format!(
357            "multiple ledger rows for domain `{domain}`"
358        )));
359    }
360    if version <= 0 {
361        return Err(malformed(format!(
362            "domain `{domain}` records non-positive version {version}"
363        )));
364    }
365    Ok(Some(version))
366}
367
368#[cfg(test)]
369#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
370mod tests {
371    use super::*;
372    use crate::profile::{ConnectionProfile, open};
373
374    fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
375        tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
376    }
377
378    fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
379        let has_column = tx
380            .prepare("PRAGMA table_info(t1)")?
381            .query_map([], |row| row.get::<_, String>(1))?
382            .collect::<Result<Vec<_>, _>>()?
383            .iter()
384            .any(|name| name == "y");
385        if !has_column {
386            tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
387        }
388        Ok(())
389    }
390
391    const DOMAIN_V1: SchemaDomain = SchemaDomain {
392        name: "test-domain",
393        migrations: &[Migration {
394            version: 1,
395            name: "base",
396            apply: create_t1,
397        }],
398    };
399
400    const DOMAIN_V2: SchemaDomain = SchemaDomain {
401        name: "test-domain",
402        migrations: &[
403            Migration {
404                version: 1,
405                name: "base",
406                apply: create_t1,
407            },
408            Migration {
409                version: 2,
410                name: "add-y",
411                apply: add_column_guarded,
412            },
413        ],
414    };
415
416    fn temp_conn(dir: &tempfile::TempDir) -> Connection {
417        open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
418    }
419
420    #[test]
421    fn fresh_file_applies_all_and_stamps() {
422        let dir = tempfile::tempdir().expect("tempdir");
423        let mut conn = temp_conn(&dir);
424        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
425        assert_eq!(
426            report,
427            LedgerReport {
428                from_version: 0,
429                to_version: 2
430            }
431        );
432        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
433        conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
434            .expect("schema converged");
435    }
436
437    #[test]
438    fn second_open_is_noop_fast_path() {
439        let dir = tempfile::tempdir().expect("tempdir");
440        let mut conn = temp_conn(&dir);
441        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
442        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
443        assert!(!report.migrated());
444    }
445
446    #[test]
447    fn upgrade_applies_only_pending() {
448        let dir = tempfile::tempdir().expect("tempdir");
449        let mut conn = temp_conn(&dir);
450        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
451        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
452        assert_eq!(
453            report,
454            LedgerReport {
455                from_version: 1,
456                to_version: 2
457            }
458        );
459    }
460
461    #[test]
462    fn pre_ledger_file_with_existing_tables_converges_and_stamps() {
463        let dir = tempfile::tempdir().expect("tempdir");
464        let mut conn = temp_conn(&dir);
465        // Simulate a legacy file: tables exist (old vintage, no y column),
466        // no ledger.
467        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
468            .expect("legacy ddl");
469        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("baseline");
470        assert_eq!(
471            report,
472            LedgerReport {
473                from_version: 0,
474                to_version: 2
475            }
476        );
477        conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
478            .expect("guarded alter converged the legacy table");
479    }
480
481    #[test]
482    fn future_version_is_refused_before_any_mutation() {
483        let dir = tempfile::tempdir().expect("tempdir");
484        let mut conn = temp_conn(&dir);
485        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
486        // An older binary knows only v1.
487        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
488        match err {
489            SqliteStoreError::SchemaFromTheFuture {
490                domain,
491                found,
492                supported,
493            } => {
494                assert_eq!(domain, "test-domain");
495                assert_eq!(found, 2);
496                assert_eq!(supported, 1);
497            }
498            other => panic!("wrong error: {other}"),
499        }
500        // Nothing moved.
501        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
502    }
503
504    #[test]
505    fn foreign_domain_rows_are_untouched() {
506        let dir = tempfile::tempdir().expect("tempdir");
507        let mut conn = temp_conn(&dir);
508        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
509        conn.execute(
510            "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
511            [],
512        )
513        .expect("foreign row");
514        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
515        let foreign: i64 = conn
516            .query_row(
517                "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
518                [],
519                |r| r.get(0),
520            )
521            .expect("foreign row survives");
522        assert_eq!(foreign, 7);
523    }
524
525    #[test]
526    fn invalid_migration_list_is_refused_without_touching_the_file() {
527        const BAD: SchemaDomain = SchemaDomain {
528            name: "bad-domain",
529            migrations: &[Migration {
530                version: 3,
531                name: "gap",
532                apply: create_t1,
533            }],
534        };
535        let dir = tempfile::tempdir().expect("tempdir");
536        let mut conn = temp_conn(&dir);
537        let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
538        assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
539        assert!(!ledger_table_exists(&conn).expect("check"));
540    }
541
542    #[test]
543    fn failed_migration_rolls_back_atomically() {
544        fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
545            tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
546            tx.execute_batch("THIS IS NOT SQL")
547        }
548        const FAILING: SchemaDomain = SchemaDomain {
549            name: "failing-domain",
550            migrations: &[
551                Migration {
552                    version: 1,
553                    name: "base",
554                    apply: create_t1,
555                },
556                Migration {
557                    version: 2,
558                    name: "explodes",
559                    apply: fail,
560                },
561            ],
562        };
563        let dir = tempfile::tempdir().expect("tempdir");
564        let mut conn = temp_conn(&dir);
565        let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
566        assert!(matches!(
567            err,
568            SqliteStoreError::MigrationFailed { version: 2, .. }
569        ));
570        // Atomic: neither the v1 table, the half-done table, nor a ledger row
571        // survives.
572        assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
573        let tables: Vec<String> = conn
574            .prepare(
575                "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
576            )
577            .expect("prepare")
578            .query_map([], |r| r.get(0))
579            .expect("query")
580            .collect::<Result<_, _>>()
581            .expect("rows");
582        assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
583    }
584
585    #[test]
586    fn malformed_ledger_shape_is_refused_not_healed() {
587        let dir = tempfile::tempdir().expect("tempdir");
588        let mut conn = temp_conn(&dir);
589        // A foreign table wearing the ledger's name.
590        conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
591            .expect("foreign ddl");
592        let err = domain_version(&conn, "test-domain").expect_err("refuse read");
593        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
594        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
595        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
596        // Refused, not healed: the foreign table is untouched and unstamped.
597        let count: i64 = conn
598            .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
599            .expect("foreign table survives");
600        assert_eq!(count, 0);
601    }
602
603    #[test]
604    fn non_positive_versions_are_refused_not_healed() {
605        for bad_version in [0i64, -3] {
606            let dir = tempfile::tempdir().expect("tempdir");
607            let mut conn = temp_conn(&dir);
608            conn.execute_batch(
609                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
610            )
611            .expect("ledger ddl");
612            conn.execute(
613                "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
614                [bad_version],
615            )
616            .expect("seed bad version");
617            let err = domain_version(&conn, "test-domain").expect_err("refuse read");
618            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
619            let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
620            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
621            // The bad row must survive untouched for forensics.
622            let stored: i64 = conn
623                .query_row(
624                    "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
625                    [],
626                    |r| r.get(0),
627                )
628                .expect("row survives");
629            assert_eq!(stored, bad_version);
630        }
631    }
632
633    #[test]
634    fn duplicate_domain_rows_are_refused() {
635        let dir = tempfile::tempdir().expect("tempdir");
636        let conn = temp_conn(&dir);
637        // No primary key: shape validation would already refuse this table;
638        // the row-cardinality guard is exercised directly as defense in
639        // depth.
640        conn.execute_batch(
641            "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
642             INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
643             INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
644        )
645        .expect("seed duplicates");
646        let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
647        match err {
648            SqliteStoreError::LedgerMalformed { detail } => {
649                assert!(detail.contains("multiple ledger rows"), "{detail}");
650            }
651            other => panic!("wrong error: {other}"),
652        }
653    }
654
655    #[test]
656    fn temp_shadowing_cannot_hijack_the_ledger() {
657        let dir = tempfile::tempdir().expect("tempdir");
658        let mut conn = temp_conn(&dir);
659        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
660        // A TEMP shadow claiming a future version: unqualified reads would
661        // see 999 and refuse; the main-qualified ledger keeps reading truth.
662        conn.execute_batch(
663            "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
664             INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
665        )
666        .expect("temp shadow");
667        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
668        let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
669        assert!(!report.migrated());
670    }
671
672    #[test]
673    fn migration_that_ends_the_transaction_is_refused_unstamped() {
674        fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
675            tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
676        }
677        fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
678            tx.execute_batch("ROLLBACK")
679        }
680        // The re-BEGIN variants leave autocommit false at the custody check:
681        // only the savepoint detects that the runner's transaction is gone
682        // and the ledger stamp would land in a foreign one.
683        fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
684            tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
685        }
686        fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
687            tx.execute_batch("ROLLBACK; BEGIN")
688        }
689        const COMMITS: SchemaDomain = SchemaDomain {
690            name: "custody-commit",
691            migrations: &[Migration {
692                version: 1,
693                name: "commits-underneath",
694                apply: commits_underneath,
695            }],
696        };
697        const ROLLS_BACK: SchemaDomain = SchemaDomain {
698            name: "custody-rollback",
699            migrations: &[Migration {
700                version: 1,
701                name: "rolls-back-underneath",
702                apply: rolls_back_underneath,
703            }],
704        };
705        const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
706            name: "custody-commit-begin",
707            migrations: &[Migration {
708                version: 1,
709                name: "commits-then-begins",
710                apply: commits_then_begins,
711            }],
712        };
713        const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
714            name: "custody-rollback-begin",
715            migrations: &[Migration {
716                version: 1,
717                name: "rolls-back-then-begins",
718                apply: rolls_back_then_begins,
719            }],
720        };
721        for (domain, expected_name) in [
722            (&COMMITS, "commits-underneath"),
723            (&ROLLS_BACK, "rolls-back-underneath"),
724            (&COMMITS_THEN_BEGINS, "commits-then-begins"),
725            (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
726        ] {
727            let dir = tempfile::tempdir().expect("tempdir");
728            let mut conn = temp_conn(&dir);
729            let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
730            match err {
731                SqliteStoreError::MigrationBrokeTransaction {
732                    domain: err_domain,
733                    version,
734                    name,
735                } => {
736                    assert_eq!(err_domain, domain.name);
737                    assert_eq!(version, 1);
738                    assert_eq!(name, expected_name);
739                }
740                other => panic!("wrong error: {other}"),
741            }
742            // The stamp never landed: custody broke before the ledger write.
743            assert_eq!(domain_version(&conn, domain.name).expect("read"), None);
744        }
745    }
746
747    #[test]
748    fn migration_using_its_own_savepoints_keeps_custody() {
749        // A body may nest its own savepoints; custody only trips when the
750        // runner's enclosing transaction (and with it the custody savepoint)
751        // is gone.
752        fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
753            tx.execute_batch(
754                "SAVEPOINT body_sp;
755                 CREATE TABLE sp_t (x INTEGER);
756                 RELEASE SAVEPOINT body_sp",
757            )
758        }
759        const NESTED: SchemaDomain = SchemaDomain {
760            name: "custody-nested-savepoint",
761            migrations: &[Migration {
762                version: 1,
763                name: "nests-savepoints",
764                apply: nests_savepoints,
765            }],
766        };
767        let dir = tempfile::tempdir().expect("tempdir");
768        let mut conn = temp_conn(&dir);
769        let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
770        assert_eq!(report.to_version, 1);
771        assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
772    }
773
774    #[test]
775    fn refuse_future_schema_passes_fresh_and_current_refuses_future() {
776        let dir = tempfile::tempdir().expect("tempdir");
777        let mut conn = temp_conn(&dir);
778        refuse_future_schema(&conn, &DOMAIN_V1).expect("no ledger yet");
779        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
780        refuse_future_schema(&conn, &DOMAIN_V2).expect("current");
781        let err = refuse_future_schema(&conn, &DOMAIN_V1).expect_err("future for old binary");
782        assert!(matches!(
783            err,
784            SqliteStoreError::SchemaFromTheFuture {
785                found: 2,
786                supported: 1,
787                ..
788            }
789        ));
790    }
791
792    #[test]
793    fn concurrent_opens_race_safely() {
794        let dir = tempfile::tempdir().expect("tempdir");
795        let path = dir.path().join("db.sqlite3");
796        let mut handles = Vec::new();
797        for _ in 0..8 {
798            let path = path.clone();
799            handles.push(std::thread::spawn(move || {
800                let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
801                apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
802            }));
803        }
804        let mut migrated = 0;
805        for handle in handles {
806            let report = handle.join().expect("thread");
807            assert_eq!(report.to_version, 2);
808            if report.migrated() {
809                migrated += 1;
810            }
811        }
812        assert!(migrated >= 1, "someone must have migrated");
813        let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
814        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
815    }
816}