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 migrations, the exact released versions
6//! it may upgrade, and every `main.sqlite_schema` object it owns.
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//! # Compatibility floor
36//!
37//! A missing domain row is accepted only when none of that domain's declared
38//! objects exist. That is a fresh domain (possibly in a file containing
39//! foreign co-tenant domains), so its dedicated `initialize_current`
40//! function may build the current shape directly. A missing row plus an
41//! owned table, index, trigger, or view is refused as
42//! [`SqliteStoreError::UnledgeredDomainObjects`]; this runner never infers a
43//! version from ambient DDL or stamps an unauthenticated historical shape.
44//!
45//! A present row may be current or one of the exact released predecessor
46//! versions declared by the domain. Pre-floor versions and gaps are refused
47//! as [`SqliteStoreError::UnsupportedSchemaPredecessor`]. Eligibility is
48//! re-established under the same `BEGIN IMMEDIATE` transaction as the DDL
49//! and ledger update. The ledger table itself is not created until after that
50//! decision, so a refusal leaves both schema and ledger unchanged.
51//!
52//! Foreign domain rows (other stores co-tenanting the same file) are never
53//! read or written; the ledger keys strictly by domain name.
54
55use rusqlite::{Connection, OptionalExtension, Transaction};
56use std::collections::BTreeMap;
57use std::sync::{Mutex, OnceLock};
58
59use crate::error::SqliteStoreError;
60
61const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
62    domain TEXT PRIMARY KEY,
63    version INTEGER NOT NULL
64)";
65
66/// Custody marker established inside the runner's transaction immediately
67/// before each migration body. A savepoint is discarded when its enclosing
68/// transaction ends — by COMMIT or ROLLBACK alike — so it survives the body
69/// exactly when the runner's transaction does.
70const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
71const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
72
73/// One schema migration step for a domain.
74#[derive(Debug)]
75pub struct Migration {
76    /// Target version this migration brings the domain to. Versions are
77    /// contiguous and start at 1.
78    pub version: i64,
79    /// Stable human-readable name (shows up in errors and reports).
80    pub name: &'static str,
81    /// The migration body. Runs inside the runner's IMMEDIATE transaction;
82    /// it must not end that transaction (nested savepoints of its own are
83    /// fine). Bodies lifted from historical upgrade functions keep their
84    /// internal idempotence guards.
85    pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
86}
87
88/// Frozen verifier for one released predecessor version.
89#[derive(Debug)]
90pub struct SchemaPredecessor {
91    pub version: i64,
92    pub verify: fn(&Connection) -> Result<(), String>,
93}
94
95/// SQLite catalog object kind owned by a schema domain.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum SchemaObjectKind {
98    Table,
99    Index,
100    Trigger,
101    View,
102}
103
104impl SchemaObjectKind {
105    fn sqlite_name(self) -> &'static str {
106        match self {
107            Self::Table => "table",
108            Self::Index => "index",
109            Self::Trigger => "trigger",
110            Self::View => "view",
111        }
112    }
113}
114
115/// One exact `main.sqlite_schema` object name owned by a domain.
116///
117/// Names are the eligibility boundary, not merely documentation: any object
118/// using one of these names makes an unledgered domain non-fresh, including
119/// an object of the wrong kind. The expected kind is retained for validation
120/// and health-visible diagnostics.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct SchemaObject {
123    pub kind: SchemaObjectKind,
124    pub name: &'static str,
125}
126
127/// A store's schema domain: its ledger name plus the ordered migration list.
128#[derive(Debug)]
129pub struct SchemaDomain {
130    /// Ledger key. Kebab-case, stable forever (it is persisted in files).
131    pub name: &'static str,
132    /// Ordered migrations, versions contiguous from 1.
133    pub migrations: &'static [Migration],
134    /// Initialize a genuinely fresh domain directly at the current schema.
135    ///
136    /// This is intentionally separate from historical upgrades. A current
137    /// base initializer may already contain objects that a released
138    /// predecessor transition creates or rebuilds; replaying the transition
139    /// on fresh state would either collide or weaken strict collision
140    /// detection with idempotent DDL.
141    pub initialize_current: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
142    /// Exact existing released versions that this binary may open. The
143    /// current supported version is included for an explicit manifest even
144    /// though it needs no migration.
145    pub allowed_existing_versions: &'static [i64],
146    /// Exact catalog verifiers for every allowed version below current.
147    pub released_predecessors: &'static [SchemaPredecessor],
148    /// Complete set of catalog objects owned by this domain across its
149    /// current schema. Foreign co-tenant objects are deliberately absent.
150    pub owned_objects: &'static [SchemaObject],
151    /// Names owned by supported predecessors but intentionally absent from
152    /// the current schema. They remain reserved for fresh-domain detection
153    /// and predecessor fingerprints.
154    pub retired_objects: &'static [SchemaObject],
155}
156
157impl SchemaDomain {
158    /// Highest version this binary knows for the domain.
159    pub fn supported_version(&self) -> i64 {
160        self.migrations.last().map_or(0, |m| m.version)
161    }
162
163    fn validate(&self) -> Result<(), SqliteStoreError> {
164        for (idx, migration) in self.migrations.iter().enumerate() {
165            let expected = idx as i64 + 1;
166            if migration.version != expected {
167                return Err(SqliteStoreError::InvalidMigrationList {
168                    domain: self.name.to_string(),
169                    detail: format!(
170                        "migration at position {idx} has version {}, expected {expected} \
171                         (versions must be contiguous from 1)",
172                        migration.version
173                    ),
174                });
175            }
176        }
177        let supported = self.supported_version();
178        let mut previous = None;
179        for &version in self.allowed_existing_versions {
180            if version <= 0 || version > supported {
181                return Err(SqliteStoreError::InvalidMigrationList {
182                    domain: self.name.to_string(),
183                    detail: format!(
184                        "allowed existing version {version} is outside 1..={supported}"
185                    ),
186                });
187            }
188            if previous.is_some_and(|value| value >= version) {
189                return Err(SqliteStoreError::InvalidMigrationList {
190                    domain: self.name.to_string(),
191                    detail: "allowed existing versions must be strictly increasing".to_string(),
192                });
193            }
194            previous = Some(version);
195        }
196        if !self.allowed_existing_versions.contains(&supported) {
197            return Err(SqliteStoreError::InvalidMigrationList {
198                domain: self.name.to_string(),
199                detail: format!(
200                    "allowed existing versions must explicitly include current version {supported}"
201                ),
202            });
203        }
204        for &version in self
205            .allowed_existing_versions
206            .iter()
207            .filter(|&&version| version < supported)
208        {
209            let matches = self
210                .released_predecessors
211                .iter()
212                .filter(|predecessor| predecessor.version == version)
213                .count();
214            if matches != 1 {
215                return Err(SqliteStoreError::InvalidMigrationList {
216                    domain: self.name.to_string(),
217                    detail: format!(
218                        "allowed predecessor version {version} must have exactly one frozen \
219                         verifier, found {matches}"
220                    ),
221                });
222            }
223        }
224        for predecessor in self.released_predecessors {
225            if predecessor.version >= supported
226                || !self
227                    .allowed_existing_versions
228                    .contains(&predecessor.version)
229            {
230                return Err(SqliteStoreError::InvalidMigrationList {
231                    domain: self.name.to_string(),
232                    detail: format!(
233                        "fingerprint verifier for version {} is not an allowed predecessor",
234                        predecessor.version
235                    ),
236                });
237            }
238        }
239        for (idx, object) in self
240            .owned_objects
241            .iter()
242            .chain(self.retired_objects)
243            .enumerate()
244        {
245            if object.name.is_empty() || object.name == "meerkat_schema" {
246                return Err(SqliteStoreError::InvalidMigrationList {
247                    domain: self.name.to_string(),
248                    detail: format!(
249                        "owned object at position {idx} has reserved or empty name `{}`",
250                        object.name
251                    ),
252                });
253            }
254            if self
255                .owned_objects
256                .iter()
257                .chain(self.retired_objects)
258                .take(idx)
259                .any(|prior| prior.name == object.name)
260            {
261                return Err(SqliteStoreError::InvalidMigrationList {
262                    domain: self.name.to_string(),
263                    detail: format!("owned object name `{}` is duplicated", object.name),
264                });
265            }
266        }
267        Ok(())
268    }
269
270    fn accepts_existing_version(&self, version: i64) -> bool {
271        self.allowed_existing_versions.contains(&version)
272    }
273
274    fn verify_predecessor(&self, conn: &Connection, version: i64) -> Result<(), SqliteStoreError> {
275        if version == self.supported_version() {
276            return verify_current_schema_fingerprint(conn, self).map_err(|detail| {
277                SqliteStoreError::SchemaFingerprintMismatch {
278                    domain: self.name.to_string(),
279                    version,
280                    detail,
281                }
282            });
283        }
284        let predecessor = self
285            .released_predecessors
286            .iter()
287            .find(|predecessor| predecessor.version == version)
288            .ok_or_else(|| unsupported_predecessor(self, version))?;
289        (predecessor.verify)(conn).map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
290            domain: self.name.to_string(),
291            version,
292            detail,
293        })
294    }
295}
296
297/// Outcome of [`apply_domain_migrations`].
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct LedgerReport {
300    /// Version found before this call (0 = no ledger row).
301    pub from_version: i64,
302    /// Version after this call.
303    pub to_version: i64,
304}
305
306impl LedgerReport {
307    /// True when this call applied at least one migration.
308    pub fn migrated(&self) -> bool {
309        self.to_version > self.from_version
310    }
311}
312
313/// Read a domain's ledger version without applying anything.
314///
315/// `Ok(None)` means the file has no ledger table or no row for the domain.
316/// It says nothing about eligibility: an owning store separately proves that
317/// the domain owns zero objects before treating it as fresh.
318/// A ledger table that fails the pinned-shape or version validation yields
319/// [`SqliteStoreError::LedgerMalformed`], never a healed reading.
320pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
321    if !ledger_table_exists(conn)? {
322        return Ok(None);
323    }
324    validate_ledger_shape(conn)?;
325    read_version(conn, domain)
326}
327
328/// Establish read-only schema eligibility before a profile's mutating
329/// pragmas: current and released predecessor rows must match their exact
330/// catalog fingerprints; future, pre-floor, gap, and unledgered-owned shapes
331/// are refused.
332///
333/// This is the [`crate::profile::OpenOptions::schema_preflight`] hook: the
334/// Primary profile runs it before its mutating pragmas so an old binary
335/// leaves an ineligible database's logical content unmodified. Reading the
336/// ledger of a WAL-mode file over a read-write connection may still touch
337/// its `-wal`/`-shm` sidecars
338/// ([`crate::profile::WriteContact::ReadOnlyWalSidecars`]); the main
339/// database file itself is not written. A missing row passes only when the
340/// domain owns zero catalog objects; the pinned in-transaction re-check in
341/// [`apply_domain_migrations`] remains the migration-time authority.
342pub fn preflight_schema_eligibility(
343    conn: &Connection,
344    domain: &SchemaDomain,
345) -> Result<(), SqliteStoreError> {
346    domain.validate()?;
347    let supported = domain.supported_version();
348    match domain_version(conn, domain.name)? {
349        Some(found) if found > supported => {
350            return Err(SqliteStoreError::SchemaFromTheFuture {
351                domain: domain.name.to_string(),
352                found,
353                supported,
354            });
355        }
356        Some(found) if !domain.accepts_existing_version(found) => {
357            return Err(unsupported_predecessor(domain, found));
358        }
359        Some(found) => domain.verify_predecessor(conn, found)?,
360        None => {
361            let objects = find_owned_objects(conn, domain)?;
362            if !objects.is_empty() {
363                return Err(SqliteStoreError::UnledgeredDomainObjects {
364                    domain: domain.name.to_string(),
365                    objects,
366                });
367            }
368        }
369    }
370    Ok(())
371}
372
373/// Bring `domain` up to date in the file behind `conn`, per the pinned
374/// protocol. Returns the version movement.
375///
376/// Eligibility, including the current-version no-op, is established under
377/// one IMMEDIATE transaction. A future or unsupported version is refused
378/// before any schema or ledger mutation.
379pub fn apply_domain_migrations(
380    conn: &mut Connection,
381    domain: &SchemaDomain,
382) -> Result<LedgerReport, SqliteStoreError> {
383    domain.validate()?;
384    let supported = domain.supported_version();
385
386    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
387    // Establish eligibility inside the write transaction. No ledger or
388    // domain DDL has run yet.
389    let current = if ledger_table_exists(&tx)? {
390        validate_ledger_shape(&tx)?;
391        read_version(&tx, domain.name)?
392    } else {
393        None
394    };
395    if let Some(found) = current {
396        if found > supported {
397            return Err(SqliteStoreError::SchemaFromTheFuture {
398                domain: domain.name.to_string(),
399                found,
400                supported,
401            });
402        }
403        if !domain.accepts_existing_version(found) {
404            return Err(unsupported_predecessor(domain, found));
405        }
406        domain.verify_predecessor(&tx, found)?;
407    } else {
408        let objects = find_owned_objects(&tx, domain)?;
409        if !objects.is_empty() {
410            return Err(SqliteStoreError::UnledgeredDomainObjects {
411                domain: domain.name.to_string(),
412                objects,
413            });
414        }
415    }
416    let current = current.unwrap_or(0);
417    if current == supported {
418        return Ok(LedgerReport {
419            from_version: current,
420            to_version: current,
421        });
422    }
423
424    // Eligibility is now pinned by the IMMEDIATE transaction. Only now may
425    // the runner materialize its ledger table.
426    if !ledger_table_exists(&tx)? {
427        tx.execute_batch(CREATE_LEDGER_SQL)?;
428        validate_ledger_shape(&tx)?;
429    }
430
431    if current == 0 {
432        tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
433        (domain.initialize_current)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
434            domain: domain.name.to_string(),
435            version: supported,
436            name: "initialize-current".to_string(),
437            source,
438        })?;
439        if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
440            return Err(SqliteStoreError::MigrationBrokeTransaction {
441                domain: domain.name.to_string(),
442                version: supported,
443                name: "initialize-current".to_string(),
444            });
445        }
446    } else {
447        for migration in domain.migrations.iter().filter(|m| m.version > current) {
448            tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
449            (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
450                domain: domain.name.to_string(),
451                version: migration.version,
452                name: migration.name.to_string(),
453                source,
454            })?;
455            // The `&Transaction` handed to the body cannot type-prevent COMMIT /
456            // ROLLBACK statements, so custody is verified instead. Autocommit
457            // going true is the cheap first line, but it misses a body that
458            // ended the transaction and then re-BEGAN one; the savepoint is the
459            // authority: RELEASE fails exactly when the savepoint no longer
460            // exists, i.e. the body ended the runner's transaction (COMMIT and
461            // ROLLBACK both discard it), whether or not it opened a new one.
462            // Stamping the ledger inside such a foreign transaction would commit
463            // separately from — or after rollback of — the schema work.
464            if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
465                return Err(SqliteStoreError::MigrationBrokeTransaction {
466                    domain: domain.name.to_string(),
467                    version: migration.version,
468                    name: migration.name.to_string(),
469                });
470            }
471        }
472    }
473    verify_current_schema_fingerprint(&tx, domain).map_err(|detail| {
474        SqliteStoreError::SchemaFingerprintMismatch {
475            domain: domain.name.to_string(),
476            version: supported,
477            detail,
478        }
479    })?;
480    tx.execute(
481        "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
482         ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
483        rusqlite::params![domain.name, supported],
484    )?;
485    tx.commit()?;
486
487    Ok(LedgerReport {
488        from_version: current,
489        to_version: supported,
490    })
491}
492
493static EXPECTED_CURRENT_CATALOGS: OnceLock<Mutex<BTreeMap<String, Result<String, String>>>> =
494    OnceLock::new();
495
496/// Verify a current row against the exact catalog built by the current
497/// initializer. The expected side is process-global pure code-derived state;
498/// every actual connection is still read and bound independently.
499fn verify_current_schema_fingerprint(
500    actual: &Connection,
501    domain: &SchemaDomain,
502) -> Result<(), String> {
503    let expected = {
504        let cache = EXPECTED_CURRENT_CATALOGS.get_or_init(|| Mutex::new(BTreeMap::new()));
505        let key = current_catalog_cache_key(domain);
506        let cached = cache
507            .lock()
508            .map_err(|_| "current catalog cache lock is poisoned".to_string())?
509            .get(&key)
510            .cloned();
511        if let Some(cached) = cached {
512            cached?
513        } else {
514            let built = build_current_catalog_fingerprint(domain);
515            cache
516                .lock()
517                .map_err(|_| "current catalog cache lock is poisoned".to_string())?
518                .insert(key, built.clone());
519            built?
520        }
521    };
522    let actual = compact_catalog_fingerprint(actual, domain, domain.owned_objects)?;
523    if actual != expected {
524        return Err(format!(
525            "current owned catalog differs: expected {expected}, found {actual}"
526        ));
527    }
528    Ok(())
529}
530
531/// Bind the pure expected-catalog cache to the complete code-derived domain
532/// identity. Name + version alone is insufficient: tests, embedders, or a
533/// faulty registration can construct two manifests with the same persisted
534/// identity but different initializer code or object ownership.
535fn current_catalog_cache_key(domain: &SchemaDomain) -> String {
536    let mut key = format!(
537        "{}\u{1f}{}\u{1f}{:x}",
538        domain.name,
539        domain.supported_version(),
540        domain.initialize_current as usize
541    );
542    for object in domain.owned_objects {
543        key.push_str(&format!(
544            "\u{1e}current:{}:{}",
545            object.kind.sqlite_name(),
546            object.name
547        ));
548    }
549    for object in domain.retired_objects {
550        key.push_str(&format!(
551            "\u{1e}retired:{}:{}",
552            object.kind.sqlite_name(),
553            object.name
554        ));
555    }
556    key
557}
558
559fn build_current_catalog_fingerprint(domain: &SchemaDomain) -> Result<String, String> {
560    let mut expected =
561        Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
562    let tx = expected
563        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
564        .map_err(|error| format!("begin current oracle: {error}"))?;
565    (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
566    tx.commit()
567        .map_err(|error| format!("commit current oracle: {error}"))?;
568    compact_catalog_fingerprint(&expected, domain, domain.owned_objects)
569}
570
571fn compact_catalog_fingerprint(
572    conn: &Connection,
573    domain: &SchemaDomain,
574    expected_objects: &[SchemaObject],
575) -> Result<String, String> {
576    let all_objects = all_domain_objects(domain);
577    let owned_by_name = all_objects
578        .iter()
579        .map(|object| (object.name, object))
580        .collect::<BTreeMap<_, _>>();
581    let current_by_name = expected_objects
582        .iter()
583        .map(|object| (object.name, object))
584        .collect::<BTreeMap<_, _>>();
585    let mut actual_names = Vec::new();
586    let mut entries = Vec::with_capacity(expected_objects.len());
587    let mut statement = conn
588        .prepare(
589            "SELECT type, name, tbl_name, sql
590             FROM main.sqlite_schema
591             WHERE name NOT LIKE 'sqlite_%'
592             ORDER BY type, name",
593        )
594        .map_err(|error| error.to_string())?;
595    let rows = statement
596        .query_map([], |row| {
597            Ok((
598                row.get::<_, String>(0)?,
599                row.get::<_, String>(1)?,
600                row.get::<_, String>(2)?,
601                row.get::<_, Option<String>>(3)?,
602            ))
603        })
604        .map_err(|error| error.to_string())?;
605    for row in rows {
606        let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
607        if owned_by_name.contains_key(name.as_str()) {
608            actual_names.push((kind.clone(), name.clone()));
609        }
610        if current_by_name.contains_key(name.as_str()) {
611            entries.push(format!(
612                "{kind}\u{1f}{name}\u{1f}{table_name}\u{1f}{}",
613                sql.map(|sql| normalize_schema_sql(&sql))
614                    .unwrap_or_default()
615            ));
616        }
617    }
618    actual_names.sort();
619    let mut expected_names = expected_objects
620        .iter()
621        .map(|object| {
622            (
623                object.kind.sqlite_name().to_string(),
624                object.name.to_string(),
625            )
626        })
627        .collect::<Vec<_>>();
628    expected_names.sort();
629    if actual_names != expected_names {
630        return Err(format!(
631            "owned object set differs: expected {expected_names:?}, found {actual_names:?}"
632        ));
633    }
634
635    entries.sort();
636    Ok(entries.join("\u{1e}"))
637}
638
639fn all_domain_objects(domain: &SchemaDomain) -> Vec<SchemaObject> {
640    domain
641        .owned_objects
642        .iter()
643        .chain(domain.retired_objects)
644        .copied()
645        .collect()
646}
647
648/// Verify an on-disk predecessor against a frozen released schema builder.
649///
650/// The builder is run only in a private in-memory database. The comparison is
651/// structured over `main.sqlite_schema`: exact owned object names/kinds,
652/// normalized CREATE SQL, table xinfo and foreign keys, plus explicit-index
653/// uniqueness/partial flags and xinfo. Foreign co-tenant objects are ignored.
654///
655/// Store crates use this from a [`SchemaPredecessor`] verifier, passing DDL
656/// copied from the released tag rather than current initializer constants.
657pub fn verify_released_schema_fingerprint(
658    actual: &Connection,
659    domain: &SchemaDomain,
660    released_objects: &[SchemaObject],
661    build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
662) -> Result<(), String> {
663    let mut expected = Connection::open_in_memory()
664        .map_err(|error| format!("open fingerprint oracle: {error}"))?;
665    let tx = expected
666        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
667        .map_err(|error| format!("begin fingerprint oracle: {error}"))?;
668    build_released(&tx).map_err(|error| format!("build fingerprint oracle: {error}"))?;
669    tx.commit()
670        .map_err(|error| format!("commit fingerprint oracle: {error}"))?;
671
672    let expected_names = catalog_names(&expected, released_objects)
673        .map_err(|error| format!("read fingerprint oracle: {error}"))?;
674    let mut declared_expected = released_objects
675        .iter()
676        .map(|object| {
677            (
678                object.kind.sqlite_name().to_string(),
679                object.name.to_string(),
680            )
681        })
682        .collect::<Vec<_>>();
683    declared_expected.sort();
684    if expected_names != declared_expected {
685        return Err(format!(
686            "frozen builder produced {expected_names:?}, manifest declares {declared_expected:?}"
687        ));
688    }
689
690    let actual_names = catalog_names(actual, &all_domain_objects(domain))
691        .map_err(|error| format!("read actual catalog: {error}"))?;
692    if actual_names != declared_expected {
693        return Err(format!(
694            "owned object set differs: expected {declared_expected:?}, found {actual_names:?}"
695        ));
696    }
697
698    for object in released_objects {
699        let wanted = catalog_fingerprint(&expected, object)
700            .map_err(|error| format!("fingerprint oracle {}: {error}", object.name))?;
701        let found = catalog_fingerprint(actual, object)
702            .map_err(|error| format!("fingerprint actual {}: {error}", object.name))?;
703        if found != wanted {
704            return Err(format!(
705                "object `{}` differs: expected {wanted:?}, found {found:?}",
706                object.name
707            ));
708        }
709    }
710    Ok(())
711}
712
713#[derive(Debug, PartialEq, Eq)]
714struct CatalogObjectFingerprint {
715    kind: String,
716    name: String,
717    table_name: String,
718    normalized_sql: Option<String>,
719    table_columns: Vec<TableColumnFingerprint>,
720    foreign_keys: Vec<ForeignKeyFingerprint>,
721    index: Option<IndexFingerprint>,
722}
723
724#[derive(Debug, PartialEq, Eq)]
725struct TableColumnFingerprint {
726    cid: i64,
727    name: String,
728    declared_type: String,
729    not_null: bool,
730    default_value: Option<String>,
731    primary_key_position: i64,
732    hidden: i64,
733}
734
735#[derive(Debug, PartialEq, Eq)]
736struct ForeignKeyFingerprint {
737    id: i64,
738    sequence: i64,
739    target_table: String,
740    from_column: String,
741    to_column: Option<String>,
742    on_update: String,
743    on_delete: String,
744    match_clause: String,
745}
746
747#[derive(Debug, PartialEq, Eq)]
748struct IndexFingerprint {
749    unique: bool,
750    origin: String,
751    partial: bool,
752    columns: Vec<IndexColumnFingerprint>,
753}
754
755#[derive(Debug, PartialEq, Eq)]
756struct IndexColumnFingerprint {
757    sequence: i64,
758    column_id: i64,
759    name: Option<String>,
760    descending: bool,
761    collation: Option<String>,
762    key: bool,
763}
764
765fn catalog_names(
766    conn: &Connection,
767    objects: &[SchemaObject],
768) -> Result<Vec<(String, String)>, rusqlite::Error> {
769    let mut found = Vec::new();
770    let mut statement = conn.prepare(
771        "SELECT type, name FROM main.sqlite_schema
772         WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
773         ORDER BY type, name",
774    )?;
775    for object in objects {
776        let rows = statement.query_map([object.name], |row| Ok((row.get(0)?, row.get(1)?)))?;
777        found.extend(rows.collect::<Result<Vec<_>, _>>()?);
778    }
779    found.sort();
780    found.dedup();
781    Ok(found)
782}
783
784fn catalog_fingerprint(
785    conn: &Connection,
786    object: &SchemaObject,
787) -> Result<CatalogObjectFingerprint, rusqlite::Error> {
788    let (kind, name, table_name, sql): (String, String, String, Option<String>) = conn.query_row(
789        "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name = ?1",
790        [object.name],
791        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
792    )?;
793    let (table_columns, foreign_keys) = if kind == "table" || kind == "view" {
794        (
795            table_columns(conn, object.name)?,
796            foreign_keys(conn, object.name)?,
797        )
798    } else {
799        (Vec::new(), Vec::new())
800    };
801    let index = if kind == "index" {
802        Some(index_fingerprint(conn, &table_name, object.name)?)
803    } else {
804        None
805    };
806    Ok(CatalogObjectFingerprint {
807        kind,
808        name,
809        table_name,
810        normalized_sql: sql.map(|sql| normalize_schema_sql(&sql)),
811        table_columns,
812        foreign_keys,
813        index,
814    })
815}
816
817fn table_columns(
818    conn: &Connection,
819    table: &str,
820) -> Result<Vec<TableColumnFingerprint>, rusqlite::Error> {
821    let mut statement = conn.prepare(
822        "SELECT cid, name, type, \"notnull\", dflt_value, pk, hidden
823         FROM pragma_table_xinfo(?1, 'main')
824         ORDER BY cid",
825    )?;
826    let rows = statement.query_map([table], |row| {
827        Ok(TableColumnFingerprint {
828            cid: row.get(0)?,
829            name: row.get(1)?,
830            declared_type: row.get(2)?,
831            not_null: row.get(3)?,
832            default_value: row.get(4)?,
833            primary_key_position: row.get(5)?,
834            hidden: row.get(6)?,
835        })
836    })?;
837    rows.collect()
838}
839
840fn foreign_keys(
841    conn: &Connection,
842    table: &str,
843) -> Result<Vec<ForeignKeyFingerprint>, rusqlite::Error> {
844    let mut statement = conn.prepare(
845        "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete, \"match\"
846         FROM pragma_foreign_key_list(?1, 'main')
847         ORDER BY id, seq",
848    )?;
849    let rows = statement.query_map([table], |row| {
850        Ok(ForeignKeyFingerprint {
851            id: row.get(0)?,
852            sequence: row.get(1)?,
853            target_table: row.get(2)?,
854            from_column: row.get(3)?,
855            to_column: row.get(4)?,
856            on_update: row.get(5)?,
857            on_delete: row.get(6)?,
858            match_clause: row.get(7)?,
859        })
860    })?;
861    rows.collect()
862}
863
864fn index_fingerprint(
865    conn: &Connection,
866    table: &str,
867    index: &str,
868) -> Result<IndexFingerprint, rusqlite::Error> {
869    let (unique, origin, partial): (bool, String, bool) = conn.query_row(
870        "SELECT \"unique\", origin, partial
871         FROM pragma_index_list(?1, 'main')
872         WHERE name = ?2",
873        rusqlite::params![table, index],
874        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
875    )?;
876    let mut statement = conn.prepare(
877        "SELECT seqno, cid, name, desc, coll, key
878         FROM pragma_index_xinfo(?1, 'main')
879         ORDER BY seqno",
880    )?;
881    let columns = statement
882        .query_map([index], |row| {
883            Ok(IndexColumnFingerprint {
884                sequence: row.get(0)?,
885                column_id: row.get(1)?,
886                name: row.get(2)?,
887                descending: row.get(3)?,
888                collation: row.get(4)?,
889                key: row.get(5)?,
890            })
891        })?
892        .collect::<Result<Vec<_>, _>>()?;
893    Ok(IndexFingerprint {
894        unique,
895        origin,
896        partial,
897        columns,
898    })
899}
900
901fn normalize_schema_sql(sql: &str) -> String {
902    sql.split_whitespace()
903        .collect::<Vec<_>>()
904        .join(" ")
905        .replace(" IF NOT EXISTS ", " ")
906}
907
908fn unsupported_predecessor(domain: &SchemaDomain, found: i64) -> SqliteStoreError {
909    SqliteStoreError::UnsupportedSchemaPredecessor {
910        domain: domain.name.to_string(),
911        found,
912        supported: domain.supported_version(),
913        allowed: domain.allowed_existing_versions.to_vec(),
914    }
915}
916
917/// Return owned catalog names already present in `main`.
918///
919/// A wrong-kind collision is included (and annotated with its actual kind)
920/// because object names themselves are the ownership boundary.
921fn find_owned_objects(
922    conn: &Connection,
923    domain: &SchemaDomain,
924) -> Result<Vec<String>, SqliteStoreError> {
925    let mut found = Vec::new();
926    let mut stmt = conn.prepare(
927        "SELECT type, name FROM main.sqlite_schema
928         WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
929         ORDER BY type, name",
930    )?;
931    for expected in domain.owned_objects.iter().chain(domain.retired_objects) {
932        let rows = stmt.query_map([expected.name], |row| {
933            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
934        })?;
935        for row in rows {
936            let (actual_kind, name) = row?;
937            found.push(format!(
938                "{actual_kind}:{name} (expected {})",
939                expected.kind.sqlite_name()
940            ));
941        }
942    }
943    found.sort();
944    found.dedup();
945    Ok(found)
946}
947
948fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
949    let exists = conn
950        .query_row(
951            "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
952            [],
953            |_| Ok(()),
954        )
955        .optional()?
956        .is_some();
957    Ok(exists)
958}
959
960fn malformed(detail: String) -> SqliteStoreError {
961    SqliteStoreError::LedgerMalformed { detail }
962}
963
964/// Validate the pinned ledger shape against `main`'s real catalog.
965///
966/// `domain` must be `TEXT` and the *sole* primary-key column (a composite
967/// key would permit multiple rows per domain); `version` must be
968/// `INTEGER NOT NULL`. Columns beyond the pinned pair are tolerated as long
969/// as they carry no primary-key position, so a future ledger protocol can
970/// extend the table compatibly. The schema-qualified `pragma_table_info`
971/// resolves in `main`, so a TEMP shadow cannot satisfy this check.
972fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
973    let mut stmt = conn.prepare(
974        "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
975    )?;
976    let mut rows = stmt.query([])?;
977    let mut domain_ok = false;
978    let mut version_ok = false;
979    while let Some(row) = rows.next()? {
980        let name: String = row.get(0)?;
981        let decl_type: String = row.get(1)?;
982        let notnull: bool = row.get(2)?;
983        let pk: i64 = row.get(3)?;
984        match name.as_str() {
985            "domain" => {
986                if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
987                    return Err(malformed(format!(
988                        "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
989                         with pk position {pk}"
990                    )));
991                }
992                domain_ok = true;
993            }
994            "version" => {
995                if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
996                    return Err(malformed(format!(
997                        "column `version` must be non-key `INTEGER NOT NULL`, found type \
998                         `{decl_type}` notnull={notnull} pk position {pk}"
999                    )));
1000                }
1001                version_ok = true;
1002            }
1003            other => {
1004                if pk != 0 {
1005                    return Err(malformed(format!(
1006                        "unexpected primary-key column `{other}`"
1007                    )));
1008                }
1009            }
1010        }
1011    }
1012    if !domain_ok || !version_ok {
1013        return Err(malformed(
1014            "table lacks the pinned `domain`/`version` columns".to_string(),
1015        ));
1016    }
1017    Ok(())
1018}
1019
1020/// Read one domain's version, refusing corrupt ledger state typed: more than
1021/// one row per domain (impossible under the validated single-column primary
1022/// key; kept as defense in depth) and non-positive versions (0 is the
1023/// implicit "no row" reading and negatives are meaningless — a stored
1024/// non-positive version is damage to refuse, not an old schema to re-migrate
1025/// over).
1026fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
1027    let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
1028    let mut rows = stmt.query([domain])?;
1029    let Some(row) = rows.next()? else {
1030        return Ok(None);
1031    };
1032    let version: i64 = row.get(0)?;
1033    if rows.next()?.is_some() {
1034        return Err(malformed(format!(
1035            "multiple ledger rows for domain `{domain}`"
1036        )));
1037    }
1038    if version <= 0 {
1039        return Err(malformed(format!(
1040            "domain `{domain}` records non-positive version {version}"
1041        )));
1042    }
1043    Ok(Some(version))
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1048mod tests {
1049    use super::*;
1050    use crate::profile::{ConnectionProfile, open};
1051
1052    fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1053        tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
1054    }
1055
1056    fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1057        let has_column = tx
1058            .prepare("PRAGMA table_info(t1)")?
1059            .query_map([], |row| row.get::<_, String>(1))?
1060            .collect::<Result<Vec<_>, _>>()?
1061            .iter()
1062            .any(|name| name == "y");
1063        if !has_column {
1064            tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
1065        }
1066        Ok(())
1067    }
1068
1069    fn initialize_v2(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1070        create_t1(tx)?;
1071        add_column_guarded(tx)
1072    }
1073
1074    fn initialize_v2_alt(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1075        tx.execute_batch("CREATE TABLE t1 (x INTEGER, z BLOB)")
1076    }
1077
1078    const RELEASED_V1_OBJECTS: &[SchemaObject] = &[SchemaObject {
1079        kind: SchemaObjectKind::Table,
1080        name: "t1",
1081    }];
1082
1083    fn verify_v1(conn: &Connection) -> Result<(), String> {
1084        verify_released_schema_fingerprint(conn, &DOMAIN_V2, RELEASED_V1_OBJECTS, create_t1)
1085    }
1086
1087    const DOMAIN_V1: SchemaDomain = SchemaDomain {
1088        name: "test-domain",
1089        migrations: &[Migration {
1090            version: 1,
1091            name: "base",
1092            apply: create_t1,
1093        }],
1094        initialize_current: create_t1,
1095        allowed_existing_versions: &[1],
1096        released_predecessors: &[],
1097        owned_objects: &[SchemaObject {
1098            kind: SchemaObjectKind::Table,
1099            name: "t1",
1100        }],
1101        retired_objects: &[],
1102    };
1103
1104    const DOMAIN_V2: SchemaDomain = SchemaDomain {
1105        name: "test-domain",
1106        migrations: &[
1107            Migration {
1108                version: 1,
1109                name: "base",
1110                apply: create_t1,
1111            },
1112            Migration {
1113                version: 2,
1114                name: "add-y",
1115                apply: add_column_guarded,
1116            },
1117        ],
1118        initialize_current: initialize_v2,
1119        allowed_existing_versions: &[1, 2],
1120        released_predecessors: &[SchemaPredecessor {
1121            version: 1,
1122            verify: verify_v1,
1123        }],
1124        owned_objects: &[SchemaObject {
1125            kind: SchemaObjectKind::Table,
1126            name: "t1",
1127        }],
1128        retired_objects: &[],
1129    };
1130
1131    const DOMAIN_V2_ALT_INITIALIZER: SchemaDomain = SchemaDomain {
1132        name: "test-domain",
1133        migrations: &[
1134            Migration {
1135                version: 1,
1136                name: "base",
1137                apply: create_t1,
1138            },
1139            Migration {
1140                version: 2,
1141                name: "alt-current",
1142                apply: add_column_guarded,
1143            },
1144        ],
1145        initialize_current: initialize_v2_alt,
1146        allowed_existing_versions: &[2],
1147        released_predecessors: &[],
1148        owned_objects: &[SchemaObject {
1149            kind: SchemaObjectKind::Table,
1150            name: "t1",
1151        }],
1152        retired_objects: &[],
1153    };
1154
1155    fn temp_conn(dir: &tempfile::TempDir) -> Connection {
1156        open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
1157    }
1158
1159    #[test]
1160    fn fresh_file_initializes_current_and_stamps() {
1161        let dir = tempfile::tempdir().expect("tempdir");
1162        let mut conn = temp_conn(&dir);
1163        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
1164        assert_eq!(
1165            report,
1166            LedgerReport {
1167                from_version: 0,
1168                to_version: 2
1169            }
1170        );
1171        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1172        conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
1173            .expect("schema converged");
1174    }
1175
1176    #[test]
1177    fn second_open_is_current_noop() {
1178        let dir = tempfile::tempdir().expect("tempdir");
1179        let mut conn = temp_conn(&dir);
1180        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
1181        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
1182        assert!(!report.migrated());
1183    }
1184
1185    #[test]
1186    fn current_oracle_cache_binds_initializer_and_manifest_not_only_name_version() {
1187        let first_dir = tempfile::tempdir().expect("first tempdir");
1188        let mut first = temp_conn(&first_dir);
1189        apply_domain_migrations(&mut first, &DOMAIN_V2).expect("first current");
1190
1191        let second_dir = tempfile::tempdir().expect("second tempdir");
1192        let mut second = temp_conn(&second_dir);
1193        apply_domain_migrations(&mut second, &DOMAIN_V2_ALT_INITIALIZER).expect("alt current");
1194        let columns: Vec<String> = second
1195            .prepare("PRAGMA table_info(t1)")
1196            .expect("prepare")
1197            .query_map([], |row| row.get(1))
1198            .expect("query")
1199            .collect::<Result<_, _>>()
1200            .expect("columns");
1201        assert_eq!(columns, vec!["x", "z"]);
1202    }
1203
1204    #[test]
1205    fn current_row_with_partial_catalog_is_refused_before_noop() {
1206        let dir = tempfile::tempdir().expect("tempdir");
1207        let mut conn = temp_conn(&dir);
1208        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
1209        conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_partial TEXT")
1210            .expect("partial candidate mutation");
1211        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current shape");
1212        assert!(matches!(
1213            err,
1214            SqliteStoreError::SchemaFingerprintMismatch { version: 2, .. }
1215        ));
1216        assert_eq!(
1217            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
1218            Some(2)
1219        );
1220    }
1221
1222    #[test]
1223    fn upgrade_applies_only_pending() {
1224        let dir = tempfile::tempdir().expect("tempdir");
1225        let mut conn = temp_conn(&dir);
1226        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
1227        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
1228        assert_eq!(
1229            report,
1230            LedgerReport {
1231                from_version: 1,
1232                to_version: 2
1233            }
1234        );
1235    }
1236
1237    #[test]
1238    fn allowed_version_with_wrong_catalog_is_refused_without_migration() {
1239        let dir = tempfile::tempdir().expect("tempdir");
1240        let mut conn = temp_conn(&dir);
1241        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
1242        conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_only TEXT")
1243            .expect("candidate shape");
1244        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse fingerprint");
1245        assert!(matches!(
1246            err,
1247            SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
1248        ));
1249        assert_eq!(
1250            domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
1251            Some(1),
1252            "fingerprint refusal advanced the ledger"
1253        );
1254        let columns: Vec<String> = conn
1255            .prepare("PRAGMA table_info(t1)")
1256            .expect("prepare")
1257            .query_map([], |row| row.get(1))
1258            .expect("query")
1259            .collect::<Result<_, _>>()
1260            .expect("columns");
1261        assert_eq!(columns, vec!["x", "candidate_only"]);
1262    }
1263
1264    #[test]
1265    fn pre_floor_and_gap_versions_are_refused_without_mutation() {
1266        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1267            Ok(())
1268        }
1269        fn verify_v2(_conn: &Connection) -> Result<(), String> {
1270            Ok(())
1271        }
1272        const DOMAIN_V3_FLOOR_2: SchemaDomain = SchemaDomain {
1273            name: "floor-domain",
1274            migrations: &[
1275                Migration {
1276                    version: 1,
1277                    name: "base",
1278                    apply: no_op,
1279                },
1280                Migration {
1281                    version: 2,
1282                    name: "released-floor",
1283                    apply: no_op,
1284                },
1285                Migration {
1286                    version: 3,
1287                    name: "current",
1288                    apply: no_op,
1289                },
1290            ],
1291            initialize_current: no_op,
1292            allowed_existing_versions: &[2, 3],
1293            released_predecessors: &[SchemaPredecessor {
1294                version: 2,
1295                verify: verify_v2,
1296            }],
1297            owned_objects: &[],
1298            retired_objects: &[],
1299        };
1300        const DOMAIN_V4_GAP_3: SchemaDomain = SchemaDomain {
1301            name: "gap-domain",
1302            migrations: &[
1303                Migration {
1304                    version: 1,
1305                    name: "old",
1306                    apply: no_op,
1307                },
1308                Migration {
1309                    version: 2,
1310                    name: "released-floor",
1311                    apply: no_op,
1312                },
1313                Migration {
1314                    version: 3,
1315                    name: "unreleased-candidate",
1316                    apply: no_op,
1317                },
1318                Migration {
1319                    version: 4,
1320                    name: "current",
1321                    apply: no_op,
1322                },
1323            ],
1324            initialize_current: no_op,
1325            allowed_existing_versions: &[2, 4],
1326            released_predecessors: &[SchemaPredecessor {
1327                version: 2,
1328                verify: verify_v2,
1329            }],
1330            owned_objects: &[],
1331            retired_objects: &[],
1332        };
1333        let dir = tempfile::tempdir().expect("tempdir");
1334        let mut conn = temp_conn(&dir);
1335        conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
1336        conn.execute(
1337            "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
1338            [DOMAIN_V3_FLOOR_2.name],
1339        )
1340        .expect("pre-floor row");
1341        let err =
1342            apply_domain_migrations(&mut conn, &DOMAIN_V3_FLOOR_2).expect_err("refuse pre-floor");
1343        assert!(matches!(
1344            err,
1345            SqliteStoreError::UnsupportedSchemaPredecessor { found: 1, .. }
1346        ));
1347        assert_eq!(
1348            domain_version(&conn, DOMAIN_V3_FLOOR_2.name).expect("ledger"),
1349            Some(1)
1350        );
1351
1352        conn.execute(
1353            "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 3)",
1354            [DOMAIN_V4_GAP_3.name],
1355        )
1356        .expect("gap row");
1357        let err = apply_domain_migrations(&mut conn, &DOMAIN_V4_GAP_3).expect_err("refuse gap");
1358        assert!(matches!(
1359            err,
1360            SqliteStoreError::UnsupportedSchemaPredecessor { found: 3, .. }
1361        ));
1362        assert_eq!(
1363            domain_version(&conn, DOMAIN_V4_GAP_3.name).expect("ledger"),
1364            Some(3)
1365        );
1366    }
1367
1368    #[test]
1369    fn unledgered_owned_objects_are_refused_without_mutation() {
1370        let dir = tempfile::tempdir().expect("tempdir");
1371        let mut conn = temp_conn(&dir);
1372        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
1373            .expect("unknown unledgered ddl");
1374        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse");
1375        assert!(matches!(
1376            err,
1377            SqliteStoreError::UnledgeredDomainObjects { .. }
1378        ));
1379        assert!(
1380            !ledger_table_exists(&conn).expect("ledger presence"),
1381            "eligibility refusal must not create the ledger"
1382        );
1383        let columns: Vec<String> = conn
1384            .prepare("PRAGMA table_info(t1)")
1385            .expect("prepare")
1386            .query_map([], |row| row.get(1))
1387            .expect("query")
1388            .collect::<Result<_, _>>()
1389            .expect("columns");
1390        assert_eq!(columns, vec!["x"], "refusal mutated unknown schema");
1391    }
1392
1393    #[test]
1394    fn fresh_domain_ignores_foreign_cotenant_objects() {
1395        let dir = tempfile::tempdir().expect("tempdir");
1396        let mut conn = temp_conn(&dir);
1397        conn.execute_batch("CREATE TABLE foreign_table (value TEXT)")
1398            .expect("foreign ddl");
1399        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("fresh domain");
1400        assert_eq!(
1401            report,
1402            LedgerReport {
1403                from_version: 0,
1404                to_version: 2
1405            }
1406        );
1407        conn.execute("INSERT INTO foreign_table VALUES ('kept')", [])
1408            .expect("foreign object survives");
1409    }
1410
1411    #[test]
1412    fn future_version_is_refused_before_any_mutation() {
1413        let dir = tempfile::tempdir().expect("tempdir");
1414        let mut conn = temp_conn(&dir);
1415        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
1416        // An older binary knows only v1.
1417        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
1418        match err {
1419            SqliteStoreError::SchemaFromTheFuture {
1420                domain,
1421                found,
1422                supported,
1423            } => {
1424                assert_eq!(domain, "test-domain");
1425                assert_eq!(found, 2);
1426                assert_eq!(supported, 1);
1427            }
1428            other => panic!("wrong error: {other}"),
1429        }
1430        // Nothing moved.
1431        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1432    }
1433
1434    #[test]
1435    fn foreign_domain_rows_are_untouched() {
1436        let dir = tempfile::tempdir().expect("tempdir");
1437        let mut conn = temp_conn(&dir);
1438        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
1439        conn.execute(
1440            "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
1441            [],
1442        )
1443        .expect("foreign row");
1444        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
1445        let foreign: i64 = conn
1446            .query_row(
1447                "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
1448                [],
1449                |r| r.get(0),
1450            )
1451            .expect("foreign row survives");
1452        assert_eq!(foreign, 7);
1453    }
1454
1455    #[test]
1456    fn invalid_migration_list_is_refused_without_touching_the_file() {
1457        const BAD: SchemaDomain = SchemaDomain {
1458            name: "bad-domain",
1459            migrations: &[Migration {
1460                version: 3,
1461                name: "gap",
1462                apply: create_t1,
1463            }],
1464            initialize_current: create_t1,
1465            allowed_existing_versions: &[3],
1466            released_predecessors: &[],
1467            owned_objects: &[],
1468            retired_objects: &[],
1469        };
1470        let dir = tempfile::tempdir().expect("tempdir");
1471        let mut conn = temp_conn(&dir);
1472        let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
1473        assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
1474        assert!(!ledger_table_exists(&conn).expect("check"));
1475    }
1476
1477    #[test]
1478    fn failed_migration_rolls_back_atomically() {
1479        fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1480            tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
1481            tx.execute_batch("THIS IS NOT SQL")
1482        }
1483        fn initialize_failing(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1484            create_t1(tx)?;
1485            fail(tx)
1486        }
1487        const FAILING: SchemaDomain = SchemaDomain {
1488            name: "failing-domain",
1489            migrations: &[
1490                Migration {
1491                    version: 1,
1492                    name: "base",
1493                    apply: create_t1,
1494                },
1495                Migration {
1496                    version: 2,
1497                    name: "explodes",
1498                    apply: fail,
1499                },
1500            ],
1501            initialize_current: initialize_failing,
1502            allowed_existing_versions: &[1, 2],
1503            released_predecessors: &[SchemaPredecessor {
1504                version: 1,
1505                verify: verify_v1,
1506            }],
1507            owned_objects: &[
1508                SchemaObject {
1509                    kind: SchemaObjectKind::Table,
1510                    name: "t1",
1511                },
1512                SchemaObject {
1513                    kind: SchemaObjectKind::Table,
1514                    name: "half_done",
1515                },
1516            ],
1517            retired_objects: &[],
1518        };
1519        let dir = tempfile::tempdir().expect("tempdir");
1520        let mut conn = temp_conn(&dir);
1521        let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
1522        assert!(matches!(
1523            err,
1524            SqliteStoreError::MigrationFailed { version: 2, .. }
1525        ));
1526        // Atomic: neither the v1 table, the half-done table, nor a ledger row
1527        // survives.
1528        assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
1529        let tables: Vec<String> = conn
1530            .prepare(
1531                "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
1532            )
1533            .expect("prepare")
1534            .query_map([], |r| r.get(0))
1535            .expect("query")
1536            .collect::<Result<_, _>>()
1537            .expect("rows");
1538        assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
1539    }
1540
1541    #[test]
1542    fn malformed_ledger_shape_is_refused_not_healed() {
1543        let dir = tempfile::tempdir().expect("tempdir");
1544        let mut conn = temp_conn(&dir);
1545        // A foreign table wearing the ledger's name.
1546        conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
1547            .expect("foreign ddl");
1548        let err = domain_version(&conn, "test-domain").expect_err("refuse read");
1549        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1550        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
1551        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1552        // Refused, not healed: the foreign table is untouched and unstamped.
1553        let count: i64 = conn
1554            .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
1555            .expect("foreign table survives");
1556        assert_eq!(count, 0);
1557    }
1558
1559    #[test]
1560    fn non_positive_versions_are_refused_not_healed() {
1561        for bad_version in [0i64, -3] {
1562            let dir = tempfile::tempdir().expect("tempdir");
1563            let mut conn = temp_conn(&dir);
1564            conn.execute_batch(
1565                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
1566            )
1567            .expect("ledger ddl");
1568            conn.execute(
1569                "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
1570                [bad_version],
1571            )
1572            .expect("seed bad version");
1573            let err = domain_version(&conn, "test-domain").expect_err("refuse read");
1574            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1575            let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
1576            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
1577            // The bad row must survive untouched for forensics.
1578            let stored: i64 = conn
1579                .query_row(
1580                    "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
1581                    [],
1582                    |r| r.get(0),
1583                )
1584                .expect("row survives");
1585            assert_eq!(stored, bad_version);
1586        }
1587    }
1588
1589    #[test]
1590    fn duplicate_domain_rows_are_refused() {
1591        let dir = tempfile::tempdir().expect("tempdir");
1592        let conn = temp_conn(&dir);
1593        // No primary key: shape validation would already refuse this table;
1594        // the row-cardinality guard is exercised directly as defense in
1595        // depth.
1596        conn.execute_batch(
1597            "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
1598             INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
1599             INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
1600        )
1601        .expect("seed duplicates");
1602        let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
1603        match err {
1604            SqliteStoreError::LedgerMalformed { detail } => {
1605                assert!(detail.contains("multiple ledger rows"), "{detail}");
1606            }
1607            other => panic!("wrong error: {other}"),
1608        }
1609    }
1610
1611    #[test]
1612    fn temp_shadowing_cannot_hijack_the_ledger() {
1613        let dir = tempfile::tempdir().expect("tempdir");
1614        let mut conn = temp_conn(&dir);
1615        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
1616        // A TEMP shadow claiming a future version: unqualified reads would
1617        // see 999 and refuse; the main-qualified ledger keeps reading truth.
1618        conn.execute_batch(
1619            "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
1620             INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
1621        )
1622        .expect("temp shadow");
1623        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
1624        let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
1625        assert!(!report.migrated());
1626    }
1627
1628    #[test]
1629    fn migration_that_ends_the_transaction_is_refused_unstamped() {
1630        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1631            Ok(())
1632        }
1633        fn verify_empty_predecessor(_conn: &Connection) -> Result<(), String> {
1634            Ok(())
1635        }
1636        fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1637            tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
1638        }
1639        fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1640            tx.execute_batch("ROLLBACK")
1641        }
1642        // The re-BEGIN variants leave autocommit false at the custody check:
1643        // only the savepoint detects that the runner's transaction is gone
1644        // and the ledger stamp would land in a foreign one.
1645        fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1646            tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
1647        }
1648        fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1649            tx.execute_batch("ROLLBACK; BEGIN")
1650        }
1651        const COMMITS: SchemaDomain = SchemaDomain {
1652            name: "custody-commit",
1653            migrations: &[
1654                Migration {
1655                    version: 1,
1656                    name: "base",
1657                    apply: no_op,
1658                },
1659                Migration {
1660                    version: 2,
1661                    name: "commits-underneath",
1662                    apply: commits_underneath,
1663                },
1664            ],
1665            initialize_current: no_op,
1666            allowed_existing_versions: &[1, 2],
1667            released_predecessors: &[SchemaPredecessor {
1668                version: 1,
1669                verify: verify_empty_predecessor,
1670            }],
1671            owned_objects: &[],
1672            retired_objects: &[],
1673        };
1674        const ROLLS_BACK: SchemaDomain = SchemaDomain {
1675            name: "custody-rollback",
1676            migrations: &[
1677                Migration {
1678                    version: 1,
1679                    name: "base",
1680                    apply: no_op,
1681                },
1682                Migration {
1683                    version: 2,
1684                    name: "rolls-back-underneath",
1685                    apply: rolls_back_underneath,
1686                },
1687            ],
1688            initialize_current: no_op,
1689            allowed_existing_versions: &[1, 2],
1690            released_predecessors: &[SchemaPredecessor {
1691                version: 1,
1692                verify: verify_empty_predecessor,
1693            }],
1694            owned_objects: &[],
1695            retired_objects: &[],
1696        };
1697        const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
1698            name: "custody-commit-begin",
1699            migrations: &[
1700                Migration {
1701                    version: 1,
1702                    name: "base",
1703                    apply: no_op,
1704                },
1705                Migration {
1706                    version: 2,
1707                    name: "commits-then-begins",
1708                    apply: commits_then_begins,
1709                },
1710            ],
1711            initialize_current: no_op,
1712            allowed_existing_versions: &[1, 2],
1713            released_predecessors: &[SchemaPredecessor {
1714                version: 1,
1715                verify: verify_empty_predecessor,
1716            }],
1717            owned_objects: &[],
1718            retired_objects: &[],
1719        };
1720        const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
1721            name: "custody-rollback-begin",
1722            migrations: &[
1723                Migration {
1724                    version: 1,
1725                    name: "base",
1726                    apply: no_op,
1727                },
1728                Migration {
1729                    version: 2,
1730                    name: "rolls-back-then-begins",
1731                    apply: rolls_back_then_begins,
1732                },
1733            ],
1734            initialize_current: no_op,
1735            allowed_existing_versions: &[1, 2],
1736            released_predecessors: &[SchemaPredecessor {
1737                version: 1,
1738                verify: verify_empty_predecessor,
1739            }],
1740            owned_objects: &[],
1741            retired_objects: &[],
1742        };
1743        for (domain, expected_name) in [
1744            (&COMMITS, "commits-underneath"),
1745            (&ROLLS_BACK, "rolls-back-underneath"),
1746            (&COMMITS_THEN_BEGINS, "commits-then-begins"),
1747            (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
1748        ] {
1749            let dir = tempfile::tempdir().expect("tempdir");
1750            let mut conn = temp_conn(&dir);
1751            conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
1752            conn.execute(
1753                "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
1754                [domain.name],
1755            )
1756            .expect("released predecessor");
1757            let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
1758            match err {
1759                SqliteStoreError::MigrationBrokeTransaction {
1760                    domain: err_domain,
1761                    version,
1762                    name,
1763                } => {
1764                    assert_eq!(err_domain, domain.name);
1765                    assert_eq!(version, 2);
1766                    assert_eq!(name, expected_name);
1767                }
1768                other => panic!("wrong error: {other}"),
1769            }
1770            // The new stamp never landed: custody broke before the ledger
1771            // update, so the authenticated predecessor remains authoritative.
1772            assert_eq!(domain_version(&conn, domain.name).expect("read"), Some(1));
1773        }
1774    }
1775
1776    #[test]
1777    fn initializer_that_ends_the_transaction_is_refused_unstamped() {
1778        fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1779            tx.execute_batch("CREATE TABLE escaped_initializer (x INTEGER); COMMIT")
1780        }
1781        const COMMITS: SchemaDomain = SchemaDomain {
1782            name: "initializer-custody-commit",
1783            migrations: &[Migration {
1784                version: 1,
1785                name: "base",
1786                apply: commits_underneath,
1787            }],
1788            initialize_current: commits_underneath,
1789            allowed_existing_versions: &[1],
1790            released_predecessors: &[],
1791            owned_objects: &[SchemaObject {
1792                kind: SchemaObjectKind::Table,
1793                name: "escaped_initializer",
1794            }],
1795            retired_objects: &[],
1796        };
1797        let dir = tempfile::tempdir().expect("tempdir");
1798        let mut conn = temp_conn(&dir);
1799        let err = apply_domain_migrations(&mut conn, &COMMITS).expect_err("custody violation");
1800        match err {
1801            SqliteStoreError::MigrationBrokeTransaction {
1802                domain,
1803                version,
1804                name,
1805            } => {
1806                assert_eq!(domain, COMMITS.name);
1807                assert_eq!(version, 1);
1808                assert_eq!(name, "initialize-current");
1809            }
1810            other => panic!("wrong error: {other}"),
1811        }
1812        assert_eq!(domain_version(&conn, COMMITS.name).expect("read"), None);
1813    }
1814
1815    #[test]
1816    fn initializer_using_its_own_savepoints_keeps_custody() {
1817        // A body may nest its own savepoints; custody only trips when the
1818        // runner's enclosing transaction (and with it the custody savepoint)
1819        // is gone.
1820        fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
1821            tx.execute_batch(
1822                "SAVEPOINT body_sp;
1823                 CREATE TABLE sp_t (x INTEGER);
1824                 RELEASE SAVEPOINT body_sp",
1825            )
1826        }
1827        const NESTED: SchemaDomain = SchemaDomain {
1828            name: "custody-nested-savepoint",
1829            migrations: &[Migration {
1830                version: 1,
1831                name: "nests-savepoints",
1832                apply: nests_savepoints,
1833            }],
1834            initialize_current: nests_savepoints,
1835            allowed_existing_versions: &[1],
1836            released_predecessors: &[],
1837            owned_objects: &[SchemaObject {
1838                kind: SchemaObjectKind::Table,
1839                name: "sp_t",
1840            }],
1841            retired_objects: &[],
1842        };
1843        let dir = tempfile::tempdir().expect("tempdir");
1844        let mut conn = temp_conn(&dir);
1845        let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
1846        assert_eq!(report.to_version, 1);
1847        assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
1848    }
1849
1850    #[test]
1851    fn schema_preflight_passes_fresh_and_current_refuses_future() {
1852        let dir = tempfile::tempdir().expect("tempdir");
1853        let mut conn = temp_conn(&dir);
1854        preflight_schema_eligibility(&conn, &DOMAIN_V1).expect("no ledger yet");
1855        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
1856        preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("current");
1857        let err =
1858            preflight_schema_eligibility(&conn, &DOMAIN_V1).expect_err("future for old binary");
1859        assert!(matches!(
1860            err,
1861            SqliteStoreError::SchemaFromTheFuture {
1862                found: 2,
1863                supported: 1,
1864                ..
1865            }
1866        ));
1867    }
1868
1869    #[test]
1870    fn concurrent_opens_race_safely() {
1871        let dir = tempfile::tempdir().expect("tempdir");
1872        let path = dir.path().join("db.sqlite3");
1873        let mut handles = Vec::new();
1874        for _ in 0..8 {
1875            let path = path.clone();
1876            handles.push(std::thread::spawn(move || {
1877                let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
1878                apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
1879            }));
1880        }
1881        let mut migrated = 0;
1882        for handle in handles {
1883            let report = handle.join().expect("thread");
1884            assert_eq!(report.to_version, 2);
1885            if report.migrated() {
1886                migrated += 1;
1887            }
1888        }
1889        assert!(migrated >= 1, "someone must have migrated");
1890        let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
1891        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
1892    }
1893}