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//! An already-current domain is verified in a read-only snapshot first;
35//! it needs no writer reservation. If migration is needed, that snapshot is
36//! dropped before BEGIN IMMEDIATE and eligibility is re-read from scratch.
37//!
38//! # Compatibility floor
39//!
40//! A missing domain row is accepted only when none of that domain's declared
41//! objects exist. That is a fresh domain (possibly in a file containing
42//! foreign co-tenant domains), so its dedicated `initialize_current`
43//! function may build the current shape directly. A missing row plus an
44//! owned table, index, trigger, or view is refused as
45//! [`SqliteStoreError::UnledgeredDomainObjects`]; this runner never infers a
46//! version from ambient DDL or stamps an unauthenticated historical shape.
47//!
48//! A present row may be current or one of the exact released predecessor
49//! versions declared by the domain. Pre-floor versions and gaps are refused
50//! as [`SqliteStoreError::UnsupportedSchemaPredecessor`]. Eligibility is
51//! re-established under the same `BEGIN IMMEDIATE` transaction as the DDL
52//! and ledger update. The ledger table itself is not created until after that
53//! decision, so a refusal leaves both schema and ledger unchanged.
54//!
55//! Foreign domain rows (other stores co-tenanting the same file) are never
56//! read or written; the ledger keys strictly by domain name.
57
58use rusqlite::{Connection, OptionalExtension, Transaction};
59use std::collections::BTreeMap;
60use std::sync::{Mutex, OnceLock};
61
62use crate::error::{BridgeEligibility, CurrentCatalogUnavailability, SqliteStoreError};
63
64const CREATE_LEDGER_SQL: &str = "CREATE TABLE IF NOT EXISTS main.meerkat_schema (
65    domain TEXT PRIMARY KEY,
66    version INTEGER NOT NULL
67)";
68
69/// Custody marker established inside the runner's transaction immediately
70/// before each migration body. A savepoint is discarded when its enclosing
71/// transaction ends — by COMMIT or ROLLBACK alike — so it survives the body
72/// exactly when the runner's transaction does.
73const CUSTODY_SAVEPOINT_SQL: &str = "SAVEPOINT meerkat_migration_custody";
74const CUSTODY_RELEASE_SQL: &str = "RELEASE SAVEPOINT meerkat_migration_custody";
75
76/// One schema migration step for a domain.
77#[derive(Debug)]
78pub struct Migration {
79    /// Target version this migration brings the domain to. Versions are
80    /// contiguous and start at 1.
81    pub version: i64,
82    /// Stable human-readable name (shows up in errors and reports).
83    pub name: &'static str,
84    /// The migration body. Runs inside the runner's IMMEDIATE transaction;
85    /// it must not end that transaction (nested savepoints of its own are
86    /// fine). Bodies lifted from historical upgrade functions keep their
87    /// internal idempotence guards.
88    pub apply: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
89}
90
91/// Frozen verifier for one released predecessor version.
92#[derive(Debug)]
93pub struct SchemaPredecessor {
94    pub version: i64,
95    pub verify: fn(&Connection) -> Result<(), String>,
96}
97
98/// SQLite catalog object kind owned by a schema domain.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum SchemaObjectKind {
101    Table,
102    Index,
103    Trigger,
104    View,
105}
106
107impl SchemaObjectKind {
108    fn sqlite_name(self) -> &'static str {
109        match self {
110            Self::Table => "table",
111            Self::Index => "index",
112            Self::Trigger => "trigger",
113            Self::View => "view",
114        }
115    }
116}
117
118/// One exact `main.sqlite_schema` object name owned by a domain.
119///
120/// Names are the eligibility boundary, not merely documentation: any object
121/// using one of these names makes an unledgered domain non-fresh, including
122/// an object of the wrong kind. The expected kind is retained for validation
123/// and health-visible diagnostics.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct SchemaObject {
126    pub kind: SchemaObjectKind,
127    pub name: &'static str,
128}
129
130/// A store's schema domain: its ledger name plus the ordered migration list.
131#[derive(Debug)]
132pub struct SchemaDomain {
133    /// Ledger key. Kebab-case, stable forever (it is persisted in files).
134    pub name: &'static str,
135    /// Ordered migrations, versions contiguous from 1.
136    pub migrations: &'static [Migration],
137    /// Initialize a genuinely fresh domain directly at the current schema.
138    ///
139    /// This is intentionally separate from historical upgrades. A current
140    /// base initializer may already contain objects that a released
141    /// predecessor transition creates or rebuilds; replaying the transition
142    /// on fresh state would either collide or weaken strict collision
143    /// detection with idempotent DDL.
144    pub initialize_current: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
145    /// Exact existing released versions that this binary may open. The
146    /// current supported version is included for an explicit manifest even
147    /// though it needs no migration.
148    pub allowed_existing_versions: &'static [i64],
149    /// Exact catalog verifiers for every allowed version below current.
150    pub released_predecessors: &'static [SchemaPredecessor],
151    /// Exact source versions the explicit offline bridge may infer for an
152    /// unledgered file of this domain.
153    ///
154    /// This is an authority boundary, not a convenience: catalog equality
155    /// alone cannot prove whether a data-only migration ran, so a version
156    /// absent from this list is never inferred even when its DDL fingerprint
157    /// matches. It lives on the domain rather than at the call site because
158    /// two answers to "which release wrote this catalog" is exactly the defect
159    /// this field exists to prevent: [`Self::bridge_eligibility`] decides what
160    /// an operator is told, [`bridge_unledgered_domain`] decides what runs,
161    /// and both read this one list.
162    pub bridge_recoverable_versions: &'static [i64],
163    /// Complete set of catalog objects owned by this domain across its
164    /// current schema. Foreign co-tenant objects are deliberately absent.
165    pub owned_objects: &'static [SchemaObject],
166    /// Names owned by supported predecessors but intentionally absent from
167    /// the current schema. They remain reserved for fresh-domain detection
168    /// and predecessor fingerprints.
169    pub retired_objects: &'static [SchemaObject],
170}
171
172impl SchemaDomain {
173    /// Highest version this binary knows for the domain.
174    pub fn supported_version(&self) -> i64 {
175        self.migrations.last().map_or(0, |m| m.version)
176    }
177
178    fn validate(&self) -> Result<(), SqliteStoreError> {
179        for (idx, migration) in self.migrations.iter().enumerate() {
180            let expected = idx as i64 + 1;
181            if migration.version != expected {
182                return Err(SqliteStoreError::InvalidMigrationList {
183                    domain: self.name.to_string(),
184                    detail: format!(
185                        "migration at position {idx} has version {}, expected {expected} \
186                         (versions must be contiguous from 1)",
187                        migration.version
188                    ),
189                });
190            }
191        }
192        let supported = self.supported_version();
193        let mut previous = None;
194        for &version in self.allowed_existing_versions {
195            if version <= 0 || version > supported {
196                return Err(SqliteStoreError::InvalidMigrationList {
197                    domain: self.name.to_string(),
198                    detail: format!(
199                        "allowed existing version {version} is outside 1..={supported}"
200                    ),
201                });
202            }
203            if previous.is_some_and(|value| value >= version) {
204                return Err(SqliteStoreError::InvalidMigrationList {
205                    domain: self.name.to_string(),
206                    detail: "allowed existing versions must be strictly increasing".to_string(),
207                });
208            }
209            previous = Some(version);
210        }
211        if !self.allowed_existing_versions.contains(&supported) {
212            return Err(SqliteStoreError::InvalidMigrationList {
213                domain: self.name.to_string(),
214                detail: format!(
215                    "allowed existing versions must explicitly include current version {supported}"
216                ),
217            });
218        }
219        for &version in self
220            .allowed_existing_versions
221            .iter()
222            .filter(|&&version| version < supported)
223        {
224            let matches = self
225                .released_predecessors
226                .iter()
227                .filter(|predecessor| predecessor.version == version)
228                .count();
229            if matches != 1 {
230                return Err(SqliteStoreError::InvalidMigrationList {
231                    domain: self.name.to_string(),
232                    detail: format!(
233                        "allowed predecessor version {version} must have exactly one frozen \
234                         verifier, found {matches}"
235                    ),
236                });
237            }
238        }
239        // The bridge's own source list is validated here, once, so that the
240        // eligibility answer an operator is shown and the bridge that later
241        // runs cannot be reading two differently-shaped lists. An empty list
242        // is legal and load-bearing: it declares that no offline bridge
243        // recovers this domain, and the eligibility oracle then says so
244        // instead of naming a command that would never touch the file.
245        // Membership in `allowed_existing_versions` is deliberately not
246        // required: those are versions this binary may open from a *ledger*
247        // row, while a bridged file has none and is identified from its
248        // catalog, so a source below the oldest openable version is normal.
249        validate_recoverable_source_versions(self, supported, self.bridge_recoverable_versions)?;
250        for predecessor in self.released_predecessors {
251            if predecessor.version >= supported
252                || !self
253                    .allowed_existing_versions
254                    .contains(&predecessor.version)
255            {
256                return Err(SqliteStoreError::InvalidMigrationList {
257                    domain: self.name.to_string(),
258                    detail: format!(
259                        "fingerprint verifier for version {} is not an allowed predecessor",
260                        predecessor.version
261                    ),
262                });
263            }
264        }
265        for (idx, object) in self
266            .owned_objects
267            .iter()
268            .chain(self.retired_objects)
269            .enumerate()
270        {
271            if object.name.is_empty() || object.name == "meerkat_schema" {
272                return Err(SqliteStoreError::InvalidMigrationList {
273                    domain: self.name.to_string(),
274                    detail: format!(
275                        "owned object at position {idx} has reserved or empty name `{}`",
276                        object.name
277                    ),
278                });
279            }
280            if self
281                .owned_objects
282                .iter()
283                .chain(self.retired_objects)
284                .take(idx)
285                .any(|prior| prior.name == object.name)
286            {
287                return Err(SqliteStoreError::InvalidMigrationList {
288                    domain: self.name.to_string(),
289                    detail: format!("owned object name `{}` is duplicated", object.name),
290                });
291            }
292        }
293        Ok(())
294    }
295
296    fn accepts_existing_version(&self, version: i64) -> bool {
297        self.allowed_existing_versions.contains(&version)
298    }
299
300    /// The exact source version [`bridge_unledgered_domain`] would
301    /// authenticate `conn`'s unledgered catalog as, if any.
302    ///
303    /// This asks the question through the very code the bridge asks it with
304    /// (`authenticate_unledgered_source_version`), against the same
305    /// caller-authorized source list ([`Self::bridge_recoverable_versions`]).
306    /// Answering it a second, similar way is what produced the defect this
307    /// replaces: an oracle that consulted only the frozen verifiers called a
308    /// realm unrecoverable while the bridge, which also accepts an exact
309    /// code-derived migration prefix, recovered it on the next command.
310    ///
311    /// SCOPE, because a remedy sentence is built from this: it answers about
312    /// the file's **catalog shape only**. It does not read, decode, or admit a
313    /// single durable record, so it can never promise that every record will
314    /// be carried forward. An ambiguous or unmatched catalog answers `None`,
315    /// exactly as the bridge refuses one.
316    ///
317    /// What makes the narrower answer safe to act on is the bridge's own
318    /// contract: a preparation callback refuses **per record**
319    /// ([`MaintenanceRecordRefusal`]) rather than per domain, so an
320    /// authenticated catalog does land, and any record that could not come
321    /// across is named in [`MaintenanceBridgeReport::refused`] instead of
322    /// costing the operator the rest of the file.
323    pub fn bridgeable_source_version(&self, conn: &Connection) -> Option<i64> {
324        if self.validate().is_err() {
325            return None;
326        }
327        authenticate_unledgered_source_version(
328            conn,
329            self,
330            self.supported_version(),
331            self.bridge_recoverable_versions,
332            Vec::new,
333        )
334        .ok()
335        .map(|matched| matched.version)
336    }
337
338    /// Whether the explicit offline bridge can authenticate `conn`'s catalog
339    /// for this domain. Catalog shape only; see
340    /// [`Self::bridgeable_source_version`] for what this deliberately does
341    /// not check.
342    pub fn bridge_eligibility(&self, conn: &Connection) -> BridgeEligibility {
343        match self.bridgeable_source_version(conn) {
344            Some(_) => BridgeEligibility::CatalogAuthenticated,
345            None => BridgeEligibility::Unrecognized,
346        }
347    }
348
349    /// Verify the catalog of a file whose ledger row stamps `version`. For the
350    /// current version a mismatch is the typed
351    /// [`SqliteStoreError::CurrentSchemaMismatch`] naming the differing
352    /// objects (falling back to the fingerprint detail when no object-level
353    /// difference explains it); predecessors are verified as usual.
354    fn verify_stamped_version(
355        &self,
356        conn: &Connection,
357        version: i64,
358    ) -> Result<(), SqliteStoreError> {
359        if version != self.supported_version() {
360            return self.verify_predecessor(conn, version);
361        }
362        verify_current_schema_fingerprint(conn, self).map_err(|detail| {
363            match current_catalog_diff(conn, self) {
364                Ok(diff) if !diff.is_empty() => SqliteStoreError::CurrentSchemaMismatch {
365                    domain: self.name.to_string(),
366                    version,
367                    missing_objects: diff.missing,
368                    unexpected_objects: diff.unexpected,
369                    changed_objects: diff.changed,
370                },
371                _ => SqliteStoreError::SchemaFingerprintMismatch {
372                    domain: self.name.to_string(),
373                    version,
374                    detail,
375                },
376            }
377        })
378    }
379
380    fn verify_predecessor(&self, conn: &Connection, version: i64) -> Result<(), SqliteStoreError> {
381        if version == self.supported_version() {
382            return verify_current_schema_fingerprint(conn, self).map_err(|detail| {
383                SqliteStoreError::SchemaFingerprintMismatch {
384                    domain: self.name.to_string(),
385                    version,
386                    detail,
387                }
388            });
389        }
390        let predecessor = self
391            .released_predecessors
392            .iter()
393            .find(|predecessor| predecessor.version == version)
394            .ok_or_else(|| unsupported_predecessor(self, version))?;
395        (predecessor.verify)(conn).map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
396            domain: self.name.to_string(),
397            version,
398            detail,
399        })
400    }
401}
402
403/// Outcome of [`apply_domain_migrations`].
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub struct LedgerReport {
406    /// Version found before this call (0 = no ledger row).
407    pub from_version: i64,
408    /// Version after this call.
409    pub to_version: i64,
410}
411
412impl LedgerReport {
413    /// True when this call applied at least one migration.
414    pub fn migrated(&self) -> bool {
415        self.to_version > self.from_version
416    }
417}
418
419/// One durable record an explicit maintenance preparation could not carry
420/// forward.
421///
422/// A refusal is per-record, never per-realm: the callback keeps the record's
423/// bytes exactly as it found them, carries every record it can, and names
424/// this one so the operator learns what did not come across and why. A
425/// preparation that answers "this single record is unrepresentable" by
426/// aborting the whole domain would cost the operator every other record in
427/// the file, which is the failure this type exists to prevent.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct MaintenanceRecordRefusal {
430    /// Domain-scoped identity of the record, as its owning store names it.
431    pub record: String,
432    /// Why this record could not be carried forward.
433    pub reason: String,
434}
435
436/// Result returned by an explicit maintenance preparation callback.
437#[derive(Debug, Clone, Default, PartialEq, Eq)]
438pub struct MaintenancePrepareReport {
439    /// Number of durable records rewritten by the callback.
440    pub changed: usize,
441    /// Records the callback deliberately left untouched, each with the
442    /// reason it could not be carried forward. Empty means every record the
443    /// callback inspected came across.
444    pub refused: Vec<MaintenanceRecordRefusal>,
445}
446
447impl MaintenancePrepareReport {
448    /// Report `changed` rewritten records with nothing refused.
449    pub fn rewrote(changed: usize) -> Self {
450        Self {
451            changed,
452            refused: Vec::new(),
453        }
454    }
455}
456
457/// Outcome of [`bridge_unledgered_domain`].
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct MaintenanceBridgeReport {
460    /// Authenticated schema version before maintenance.
461    pub from_version: i64,
462    /// Schema version after maintenance.
463    pub to_version: i64,
464    /// Number of records rewritten by the optional preparation callback.
465    pub prepared: usize,
466    /// Records the preparation callback left untouched, named with reasons.
467    /// The domain still landed: these records were carried across the schema
468    /// bridge in their existing bytes, not rewritten and not deleted.
469    pub refused: Vec<MaintenanceRecordRefusal>,
470}
471
472impl MaintenanceBridgeReport {
473    /// True when this call advanced the schema ledger.
474    pub fn migrated(&self) -> bool {
475        self.to_version > self.from_version
476    }
477
478    /// True when this call advanced the schema or rewrote durable records.
479    pub fn changed(&self) -> bool {
480        self.migrated() || self.prepared > 0
481    }
482}
483
484/// Read a domain's ledger version without applying anything.
485///
486/// `Ok(None)` means the file has no ledger table or no row for the domain.
487/// It says nothing about eligibility: an owning store separately proves that
488/// the domain owns zero objects before treating it as fresh.
489/// A ledger table that fails the pinned-shape or version validation yields
490/// [`SqliteStoreError::LedgerMalformed`], never a healed reading.
491///
492/// To decide anything on the version, use [`preflight_domain_version`]: it
493/// returns the version the schema eligibility check verified, read in the
494/// same snapshot as the check.
495pub fn domain_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
496    if !ledger_table_exists(conn)? {
497        return Ok(None);
498    }
499    validate_ledger_shape(conn)?;
500    read_version(conn, domain)
501}
502
503/// Run `read` with every read it makes in one snapshot of `conn`.
504///
505/// Outside a transaction the reads share one DEFERRED read transaction. In
506/// WAL mode it keeps the snapshot its first read takes; in rollback-journal
507/// mode it holds the SHARED lock until it ends. A schema change another
508/// connection commits meanwhile is therefore seen whole or not at all. The
509/// transaction only reads and has ended when this returns, before a caller's
510/// mutating pragma. A caller already inside a transaction has its snapshot
511/// already, so the reads run in that transaction and none is nested.
512fn with_read_snapshot<T>(
513    conn: &Connection,
514    read: impl FnOnce(&Connection) -> Result<T, SqliteStoreError>,
515) -> Result<T, SqliteStoreError> {
516    if !conn.is_autocommit() {
517        return read(conn);
518    }
519    let snapshot = conn.unchecked_transaction()?;
520    let result = read(&snapshot);
521    let ended = snapshot.rollback();
522    let value = result?;
523    ended?;
524    Ok(value)
525}
526
527/// Establish read-only schema eligibility before a profile's mutating
528/// pragmas: current and released predecessor rows must match their exact
529/// catalog fingerprints; future, pre-floor, gap, and unledgered-owned shapes
530/// are refused.
531///
532/// This is the [`crate::profile::OpenOptions::schema_preflight`] hook: the
533/// Primary profile runs it before its mutating pragmas so an old binary
534/// leaves an ineligible database's logical content unmodified. Reading the
535/// ledger of a WAL-mode file over a read-write connection may still touch
536/// its `-wal`/`-shm` sidecars
537/// ([`crate::profile::WriteContact::ReadOnlyWalSidecars`]); the main
538/// database file itself is not written. A missing row passes only when the
539/// domain owns zero catalog objects; the pinned in-transaction re-check in
540/// [`apply_domain_migrations`] remains the migration-time authority.
541///
542/// The ledger and the catalog it judges are read in one snapshot, so a
543/// domain that another process creates meanwhile is seen whole (its row and
544/// its objects) or not at all, never as objects without a row.
545pub fn preflight_schema_eligibility(
546    conn: &Connection,
547    domain: &SchemaDomain,
548) -> Result<(), SqliteStoreError> {
549    preflight_domain_version(conn, domain).map(|_| ())
550}
551
552/// [`preflight_schema_eligibility`], returning the ledger version it found:
553/// `Ok(None)` is a fresh domain (no ledger row and no owned objects).
554///
555/// A caller that decides on the version, for example whether a lazily
556/// provisioned domain exists yet, decides on exactly what was checked,
557/// never on a later read of a row the check did not see.
558pub fn preflight_domain_version(
559    conn: &Connection,
560    domain: &SchemaDomain,
561) -> Result<Option<i64>, SqliteStoreError> {
562    domain.validate()?;
563    let supported = domain.supported_version();
564    with_read_snapshot(conn, |conn| {
565        let found = domain_version(conn, domain.name)?;
566        after_preflight_ledger_read();
567        match found {
568            Some(found) if found > supported => Err(SqliteStoreError::SchemaFromTheFuture {
569                domain: domain.name.to_string(),
570                found,
571                supported,
572            }),
573            Some(found) if !domain.accepts_existing_version(found) => {
574                Err(unsupported_predecessor(domain, found))
575            }
576            Some(found) => domain
577                .verify_stamped_version(conn, found)
578                .map(|()| Some(found)),
579            None => {
580                let objects = find_owned_objects(conn, domain)?;
581                if objects.is_empty() {
582                    Ok(None)
583                } else {
584                    Err(SqliteStoreError::UnledgeredDomainObjects {
585                        domain: domain.name.to_string(),
586                        objects,
587                        bridgeable: domain.bridge_eligibility(conn),
588                    })
589                }
590            }
591        }
592    })
593}
594
595#[cfg(any(test, feature = "test-support"))]
596std::thread_local! {
597    static PREFLIGHT_PROBE: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
598        const { std::cell::RefCell::new(None) };
599}
600
601/// Test support: run `probe` once, on this thread, at the next schema
602/// preflight's point between its ledger read and the reads that follow it
603/// (the owned-object scan or the stamped catalog's verification). A test
604/// commits a concurrent schema change inside exactly that window.
605#[cfg(any(test, feature = "test-support"))]
606#[doc(hidden)]
607pub fn arm_preflight_probe_for_test(probe: impl FnOnce() + 'static) {
608    PREFLIGHT_PROBE.with(|slot| *slot.borrow_mut() = Some(Box::new(probe)));
609}
610
611fn after_preflight_ledger_read() {
612    #[cfg(any(test, feature = "test-support"))]
613    if let Some(probe) = PREFLIGHT_PROBE.with(|slot| slot.borrow_mut().take()) {
614        probe();
615    }
616}
617
618/// Check an already installed current schema against the expected catalog
619/// this process prepared, without building it. Ordinary store open prepares
620/// that immutable catalog; a catalog not prepared (or a poisoned cache) is
621/// refused. The check waits for the expected-catalog cache mutex, a leaf
622/// lock: no other lock is acquired while its guard is held, so callers may
623/// already hold native custody or a BEGIN IMMEDIATE transaction outside it.
624/// This administrative path never migrates or adopts older records. The
625/// ledger version and the catalog are read in one snapshot: the caller's own
626/// transaction when it holds one, otherwise a read transaction of its own.
627pub fn try_preflight_current_schema(
628    conn: &Connection,
629    domain: &SchemaDomain,
630) -> Result<(), SqliteStoreError> {
631    domain.validate()?;
632    let supported = domain.supported_version();
633    with_read_snapshot(conn, |conn| {
634        let found = domain_version(conn, domain.name)?;
635        after_preflight_ledger_read();
636        match found {
637            Some(found) if found > supported => {
638                return Err(SqliteStoreError::SchemaFromTheFuture {
639                    domain: domain.name.to_string(),
640                    found,
641                    supported,
642                });
643            }
644            Some(found) if found == supported => {}
645            _ => {
646                return Err(SqliteStoreError::OpenRefused {
647                    path: std::path::PathBuf::from(conn.path().unwrap_or(":memory:")),
648                    profile: "online-existing-writer",
649                    detail: "administration requires the installed current schema".to_string(),
650                });
651            }
652        }
653        let expected = try_cached_current_catalog(
654            EXPECTED_CURRENT_CATALOGS.get(),
655            &current_catalog_cache_key(domain),
656        )
657        .map_err(|cause| SqliteStoreError::CurrentCatalogUnavailable {
658            domain: domain.name.to_string(),
659            cause,
660        })?
661        .map_err(|detail| SqliteStoreError::SchemaFingerprintMismatch {
662            domain: domain.name.to_string(),
663            version: supported,
664            detail,
665        })?;
666        let actual = compact_catalog_fingerprint(conn, domain, domain.owned_objects);
667        if actual.as_ref().is_ok_and(|actual| actual == &expected) {
668            return Ok(());
669        }
670        let detail = actual.map_or_else(
671            |error| error,
672            |actual| format!("current owned catalog differs: expected {expected}, found {actual}"),
673        );
674        // Preserve the canonical typed mismatch and its object-level diagnostics.
675        // As in ordinary open, the private in-memory oracle runs only on refusal.
676        Err(match current_catalog_diff(conn, domain) {
677            Ok(diff) if !diff.is_empty() => SqliteStoreError::CurrentSchemaMismatch {
678                domain: domain.name.to_string(),
679                version: supported,
680                missing_objects: diff.missing,
681                unexpected_objects: diff.unexpected,
682                changed_objects: diff.changed,
683            },
684            _ => SqliteStoreError::SchemaFingerprintMismatch {
685                domain: domain.name.to_string(),
686                version: supported,
687                detail,
688            },
689        })
690    })
691}
692
693/// Bring `domain` up to date in the file behind `conn`, per the pinned
694/// protocol. Returns the version movement.
695///
696/// The current-version no-op is authenticated in one read-only snapshot.
697/// Migration eligibility is re-established under one IMMEDIATE transaction;
698/// the read snapshot is never upgraded to a writer. A future or unsupported
699/// version is refused before any schema or ledger mutation.
700pub fn apply_domain_migrations(
701    conn: &mut Connection,
702    domain: &SchemaDomain,
703) -> Result<LedgerReport, SqliteStoreError> {
704    domain.validate()?;
705    let supported = domain.supported_version();
706
707    {
708        let read = conn.transaction()?;
709        let is_current = domain_version(&read, domain.name)? == Some(supported);
710        if is_current {
711            domain.verify_stamped_version(&read, supported)?;
712        }
713        read.rollback()?;
714        if is_current {
715            return Ok(LedgerReport {
716                from_version: supported,
717                to_version: supported,
718            });
719        }
720    }
721
722    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
723    // Establish eligibility inside the write transaction. No ledger or
724    // domain DDL has run yet.
725    let current = if ledger_table_exists(&tx)? {
726        validate_ledger_shape(&tx)?;
727        read_version(&tx, domain.name)?
728    } else {
729        None
730    };
731    if let Some(found) = current {
732        if found > supported {
733            return Err(SqliteStoreError::SchemaFromTheFuture {
734                domain: domain.name.to_string(),
735                found,
736                supported,
737            });
738        }
739        if !domain.accepts_existing_version(found) {
740            return Err(unsupported_predecessor(domain, found));
741        }
742        domain.verify_stamped_version(&tx, found)?;
743    } else {
744        let objects = find_owned_objects(&tx, domain)?;
745        if !objects.is_empty() {
746            return Err(SqliteStoreError::UnledgeredDomainObjects {
747                domain: domain.name.to_string(),
748                objects,
749                bridgeable: domain.bridge_eligibility(&tx),
750            });
751        }
752    }
753    let current = current.unwrap_or(0);
754    if current == supported {
755        tx.rollback()?;
756        return Ok(LedgerReport {
757            from_version: current,
758            to_version: current,
759        });
760    }
761
762    // Eligibility is now pinned by the IMMEDIATE transaction. Only now may
763    // the runner materialize its ledger table.
764    if !ledger_table_exists(&tx)? {
765        tx.execute_batch(CREATE_LEDGER_SQL)?;
766        validate_ledger_shape(&tx)?;
767    }
768
769    if current == 0 {
770        tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
771        (domain.initialize_current)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
772            domain: domain.name.to_string(),
773            version: supported,
774            name: "initialize-current".to_string(),
775            source,
776        })?;
777        if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
778            return Err(SqliteStoreError::MigrationBrokeTransaction {
779                domain: domain.name.to_string(),
780                version: supported,
781                name: "initialize-current".to_string(),
782            });
783        }
784    } else {
785        for migration in domain.migrations.iter().filter(|m| m.version > current) {
786            tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
787            (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
788                domain: domain.name.to_string(),
789                version: migration.version,
790                name: migration.name.to_string(),
791                source,
792            })?;
793            // The `&Transaction` handed to the body cannot type-prevent COMMIT /
794            // ROLLBACK statements, so custody is verified instead. Autocommit
795            // going true is the cheap first line, but it misses a body that
796            // ended the transaction and then re-BEGAN one; the savepoint is the
797            // authority: RELEASE fails exactly when the savepoint no longer
798            // exists, i.e. the body ended the runner's transaction (COMMIT and
799            // ROLLBACK both discard it), whether or not it opened a new one.
800            // Stamping the ledger inside such a foreign transaction would commit
801            // separately from — or after rollback of — the schema work.
802            if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
803                return Err(SqliteStoreError::MigrationBrokeTransaction {
804                    domain: domain.name.to_string(),
805                    version: migration.version,
806                    name: migration.name.to_string(),
807                });
808            }
809        }
810    }
811    verify_current_schema_fingerprint(&tx, domain).map_err(|detail| {
812        SqliteStoreError::SchemaFingerprintMismatch {
813            domain: domain.name.to_string(),
814            version: supported,
815            detail,
816        }
817    })?;
818    tx.execute(
819        "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
820         ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
821        rusqlite::params![domain.name, supported],
822    )?;
823    verify_ledger_stamp(&tx, domain.name, supported)?;
824    tx.commit()?;
825
826    Ok(LedgerReport {
827        from_version: current,
828        to_version: supported,
829    })
830}
831
832/// Offline preparation callback retained under the ledger migration transaction.
833pub type MaintenancePrepareFn =
834    for<'connection> fn(
835        &Transaction<'connection>,
836    ) -> Result<MaintenancePrepareReport, rusqlite::Error>;
837
838/// Explicitly authenticate and migrate an unledgered historical domain.
839///
840/// This is an offline maintenance bridge, not an ambient-open fallback.
841/// Under one `BEGIN IMMEDIATE` transaction it identifies an owned catalog as
842/// exactly one caller-authorized, code-derived migration prefix or frozen
843/// released-predecessor catalog, runs `prepare` when supplied, applies the
844/// remaining registered migrations, verifies both the exact target prefix
845/// and the domain's ordinary target verifier, and only then creates and
846/// stamps the ledger row. The callback and every migration retain transaction
847/// custody.
848///
849/// `recoverable_source_versions` is an explicit authority boundary for
850/// unledgered inference. Catalog equality alone cannot prove whether a
851/// data-only migration ran, so prefixes absent from this list are never
852/// inferred even when their DDL fingerprint matches. A registered frozen
853/// predecessor verifier is an additional exact source oracle for its version;
854/// a version that matches both its generated prefix and frozen verifier is
855/// counted once. Existing eligible rows below the target are upgraded. A row
856/// already at the target is a verified no-op when no preparation callback is
857/// supplied; with a callback, its data preparation, target verification, and
858/// unchanged ledger stamp are committed atomically. A missing row with no
859/// owned objects is also a no-op so normal fresh-domain initialization remains
860/// the sole owner of that case. Unknown and ambiguous catalogs are refused
861/// without mutation.
862pub fn bridge_unledgered_domain(
863    conn: &mut Connection,
864    domain: &SchemaDomain,
865    target_version: i64,
866    recoverable_source_versions: &[i64],
867    prepare: Option<MaintenancePrepareFn>,
868) -> Result<MaintenanceBridgeReport, SqliteStoreError> {
869    domain.validate()?;
870    let supported = domain.supported_version();
871    if target_version > supported {
872        return Err(SqliteStoreError::SchemaFromTheFuture {
873            domain: domain.name.to_string(),
874            found: target_version,
875            supported,
876        });
877    }
878    if !domain.accepts_existing_version(target_version) {
879        return Err(unsupported_predecessor(domain, target_version));
880    }
881    validate_recoverable_source_versions(domain, target_version, recoverable_source_versions)?;
882
883    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
884    let current = if ledger_table_exists(&tx)? {
885        validate_ledger_shape(&tx)?;
886        read_version(&tx, domain.name)?
887    } else {
888        None
889    };
890
891    let mut inferred_oracles = None;
892    let from_version = if let Some(found) = current {
893        if found > target_version {
894            return Err(SqliteStoreError::SchemaFromTheFuture {
895                domain: domain.name.to_string(),
896                found,
897                supported: target_version,
898            });
899        }
900        if !domain.accepts_existing_version(found) {
901            return Err(unsupported_predecessor(domain, found));
902        }
903        domain.verify_stamped_version(&tx, found)?;
904        if found == target_version && prepare.is_none() {
905            return Ok(MaintenanceBridgeReport {
906                from_version: found,
907                to_version: found,
908                prepared: 0,
909                refused: Vec::new(),
910            });
911        }
912        found
913    } else {
914        let objects = find_owned_objects(&tx, domain)?;
915        if objects.is_empty() {
916            return Ok(MaintenanceBridgeReport {
917                from_version: 0,
918                to_version: 0,
919                prepared: 0,
920                refused: Vec::new(),
921            });
922        }
923
924        let matched = authenticate_unledgered_source_version(
925            &tx,
926            domain,
927            target_version,
928            recoverable_source_versions,
929            || objects.clone(),
930        )?;
931        inferred_oracles = Some(matched.oracles);
932        matched.version
933    };
934
935    let oracles = match inferred_oracles {
936        Some(oracles) => oracles,
937        None => build_migration_prefix_oracles(domain, target_version)?,
938    };
939    validate_domain_trigger_isolation(&tx, domain, from_version)?;
940
941    let (prepared, refused) = match prepare {
942        Some(prepare) => {
943            let report =
944                run_with_custody(&tx, domain, from_version, "maintenance-prepare", prepare)?;
945            (report.changed, report.refused)
946        }
947        None => (0, Vec::new()),
948    };
949    for migration in domain
950        .migrations
951        .iter()
952        .filter(|migration| migration.version > from_version && migration.version <= target_version)
953    {
954        run_with_custody(
955            &tx,
956            domain,
957            migration.version,
958            migration.name,
959            migration.apply,
960        )?;
961    }
962
963    let target = oracles
964        .iter()
965        .find_map(|(version, fingerprint)| (*version == target_version).then_some(fingerprint))
966        .ok_or_else(|| SqliteStoreError::InvalidMigrationList {
967            domain: domain.name.to_string(),
968            detail: format!(
969                "migration-prefix oracle did not produce requested target version {target_version}"
970            ),
971        })?;
972    let converged = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
973        SqliteStoreError::SchemaFingerprintMismatch {
974            domain: domain.name.to_string(),
975            version: target_version,
976            detail,
977        }
978    })?;
979    if &converged != target {
980        return Err(SqliteStoreError::SchemaFingerprintMismatch {
981            domain: domain.name.to_string(),
982            version: target_version,
983            detail: format!(
984                "migration-prefix catalog differs: expected {target:?}, found {converged:?}"
985            ),
986        });
987    }
988    domain.verify_predecessor(&tx, target_version)?;
989
990    if current != Some(target_version) {
991        if !ledger_table_exists(&tx)? {
992            tx.execute_batch(CREATE_LEDGER_SQL)?;
993            validate_ledger_shape(&tx)?;
994        }
995        tx.execute(
996            "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, ?2)
997             ON CONFLICT(domain) DO UPDATE SET version = excluded.version",
998            rusqlite::params![domain.name, target_version],
999        )?;
1000    }
1001    verify_ledger_stamp(&tx, domain.name, target_version)?;
1002    tx.commit()?;
1003
1004    Ok(MaintenanceBridgeReport {
1005        from_version,
1006        to_version: target_version,
1007        prepared,
1008        refused,
1009    })
1010}
1011
1012fn validate_recoverable_source_versions(
1013    domain: &SchemaDomain,
1014    target_version: i64,
1015    versions: &[i64],
1016) -> Result<(), SqliteStoreError> {
1017    let mut previous = None;
1018    for &version in versions {
1019        if version <= 0 || version > target_version {
1020            return Err(SqliteStoreError::InvalidMigrationList {
1021                domain: domain.name.to_string(),
1022                detail: format!(
1023                    "recoverable source version {version} is outside 1..={target_version}"
1024                ),
1025            });
1026        }
1027        if previous.is_some_and(|prior| prior >= version) {
1028            return Err(SqliteStoreError::InvalidMigrationList {
1029                domain: domain.name.to_string(),
1030                detail: "recoverable source versions must be strictly increasing".to_string(),
1031            });
1032        }
1033        previous = Some(version);
1034    }
1035    Ok(())
1036}
1037
1038fn verify_ledger_stamp(
1039    conn: &Connection,
1040    domain: &str,
1041    expected: i64,
1042) -> Result<(), SqliteStoreError> {
1043    let found = read_version(conn, domain)?;
1044    if found != Some(expected) {
1045        return Err(malformed(format!(
1046            "domain `{domain}` stamp did not persist exact version {expected}; found {found:?}"
1047        )));
1048    }
1049    Ok(())
1050}
1051
1052fn validate_domain_trigger_isolation(
1053    conn: &Connection,
1054    domain: &SchemaDomain,
1055    source_version: i64,
1056) -> Result<(), SqliteStoreError> {
1057    let all_objects = all_domain_objects(domain);
1058    let allowed_triggers = all_objects
1059        .iter()
1060        .filter(|object| object.kind == SchemaObjectKind::Trigger)
1061        .map(|object| object.name)
1062        .collect::<Vec<_>>();
1063    let mut statement = conn
1064        .prepare(
1065            "SELECT 'main', name FROM main.sqlite_schema
1066             WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
1067             UNION ALL
1068             SELECT 'temp', name FROM temp.sqlite_schema
1069             WHERE type = 'trigger' AND tbl_name = ?1 COLLATE NOCASE
1070             ORDER BY 1, 2",
1071        )
1072        .map_err(SqliteStoreError::Sqlite)?;
1073    let mut refused = Vec::new();
1074    for target in all_objects.iter().filter(|object| {
1075        matches!(
1076            object.kind,
1077            SchemaObjectKind::Table | SchemaObjectKind::View
1078        )
1079    }) {
1080        let rows = statement
1081            .query_map([target.name], |row| {
1082                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1083            })
1084            .map_err(SqliteStoreError::Sqlite)?;
1085        for row in rows {
1086            let (schema, trigger) = row.map_err(SqliteStoreError::Sqlite)?;
1087            let declared = schema == "main"
1088                && allowed_triggers
1089                    .iter()
1090                    .any(|allowed| allowed.eq_ignore_ascii_case(&trigger));
1091            if !declared {
1092                refused.push(format!("{schema}.{trigger} on {}", target.name));
1093            }
1094        }
1095    }
1096    refused.sort();
1097    refused.dedup();
1098    if !refused.is_empty() {
1099        return Err(SqliteStoreError::SchemaFingerprintMismatch {
1100            domain: domain.name.to_string(),
1101            version: source_version,
1102            detail: format!(
1103                "undeclared or TEMP triggers can intercept maintenance writes: {refused:?}"
1104            ),
1105        });
1106    }
1107    Ok(())
1108}
1109
1110fn run_with_custody<T>(
1111    tx: &Transaction<'_>,
1112    domain: &SchemaDomain,
1113    version: i64,
1114    name: &str,
1115    body: fn(&Transaction<'_>) -> Result<T, rusqlite::Error>,
1116) -> Result<T, SqliteStoreError> {
1117    tx.execute_batch(CUSTODY_SAVEPOINT_SQL)?;
1118    let body_result = body(tx);
1119    if tx.is_autocommit() || tx.execute_batch(CUSTODY_RELEASE_SQL).is_err() {
1120        return Err(SqliteStoreError::MigrationBrokeTransaction {
1121            domain: domain.name.to_string(),
1122            version,
1123            name: name.to_string(),
1124        });
1125    }
1126    body_result.map_err(|source| SqliteStoreError::MigrationFailed {
1127        domain: domain.name.to_string(),
1128        version,
1129        name: name.to_string(),
1130        source,
1131    })
1132}
1133
1134/// One unledgered catalog identified as exactly one authorized source version,
1135/// with the prefix oracles the identification was made against.
1136struct AuthenticatedUnledgeredSource {
1137    version: i64,
1138    oracles: Vec<(i64, DomainCatalogFingerprint)>,
1139}
1140
1141/// Identify an unledgered catalog as exactly one caller-authorized source
1142/// version, or refuse it.
1143///
1144/// THIS IS THE ONLY ANSWER. Both the bridge that migrates the file and the
1145/// eligibility oracle that decides what an operator is told call it, because a
1146/// realm the bridge recovers being described to that operator as unrecoverable
1147/// (or the reverse) is a defect neither side can detect on its own. Two
1148/// evidence sources are accepted and must agree on a single version: an exact
1149/// code-derived migration-prefix fingerprint, and a registered frozen
1150/// predecessor verifier. Anything unmatched or ambiguous is refused, so the
1151/// message and the command fail together.
1152///
1153/// `objects` is called only to describe a refusal, so a caller that just wants
1154/// the yes/no answer pays nothing to obtain a list it will discard.
1155fn authenticate_unledgered_source_version(
1156    conn: &Connection,
1157    domain: &SchemaDomain,
1158    target_version: i64,
1159    recoverable_source_versions: &[i64],
1160    objects: impl FnOnce() -> Vec<String>,
1161) -> Result<AuthenticatedUnledgeredSource, SqliteStoreError> {
1162    if recoverable_source_versions.is_empty() {
1163        // A domain with no offline bridge is answered without replaying a
1164        // single migration, and the operator is told the bridge will not help
1165        // rather than being sent to a command that would never touch the file.
1166        return Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1167            domain: domain.name.to_string(),
1168            target_version,
1169            objects: objects(),
1170        });
1171    }
1172    let oracles = build_migration_prefix_oracles(domain, target_version)?;
1173    let actual = domain_catalog_fingerprint(conn, domain).map_err(|detail| {
1174        SqliteStoreError::UnledgeredSchemaNoMatch {
1175            domain: domain.name.to_string(),
1176            target_version,
1177            objects: vec![detail],
1178        }
1179    })?;
1180    let mut matches = oracles
1181        .iter()
1182        .filter_map(|(version, fingerprint)| {
1183            (recoverable_source_versions.contains(version) && fingerprint == &actual)
1184                .then_some(*version)
1185        })
1186        .collect::<Vec<_>>();
1187    // A frozen predecessor verifier may intentionally authenticate more
1188    // than one exact released physical catalog for the same logical
1189    // version. This covers pre-ledger stores whose idempotent opener grew
1190    // new tables without a version marker. It remains fail-closed: only a
1191    // caller-authorized version with a registered frozen verifier can add
1192    // a match, and duplicate evidence for the same version is collapsed
1193    // before ambiguity is judged.
1194    for predecessor in domain.released_predecessors.iter().filter(|predecessor| {
1195        recoverable_source_versions.contains(&predecessor.version)
1196            && predecessor.version <= target_version
1197    }) {
1198        if (predecessor.verify)(conn).is_ok() && !matches.contains(&predecessor.version) {
1199            matches.push(predecessor.version);
1200        }
1201    }
1202    matches.sort_unstable();
1203    match matches.as_slice() {
1204        [version] => Ok(AuthenticatedUnledgeredSource {
1205            version: *version,
1206            oracles,
1207        }),
1208        [] => Err(SqliteStoreError::UnledgeredSchemaNoMatch {
1209            domain: domain.name.to_string(),
1210            target_version,
1211            objects: objects(),
1212        }),
1213        _ => Err(SqliteStoreError::UnledgeredSchemaAmbiguous {
1214            domain: domain.name.to_string(),
1215            target_version,
1216            matches,
1217        }),
1218    }
1219}
1220
1221fn build_migration_prefix_oracles(
1222    domain: &SchemaDomain,
1223    target_version: i64,
1224) -> Result<Vec<(i64, DomainCatalogFingerprint)>, SqliteStoreError> {
1225    let mut expected = Connection::open_in_memory().map_err(SqliteStoreError::Sqlite)?;
1226    let tx = expected
1227        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1228        .map_err(SqliteStoreError::Sqlite)?;
1229    let mut oracles = Vec::with_capacity(target_version as usize);
1230    for migration in domain
1231        .migrations
1232        .iter()
1233        .filter(|migration| migration.version <= target_version)
1234    {
1235        (migration.apply)(&tx).map_err(|source| SqliteStoreError::MigrationFailed {
1236            domain: domain.name.to_string(),
1237            version: migration.version,
1238            name: format!("migration-prefix-oracle:{}", migration.name),
1239            source,
1240        })?;
1241        let fingerprint = domain_catalog_fingerprint(&tx, domain).map_err(|detail| {
1242            SqliteStoreError::InvalidMigrationList {
1243                domain: domain.name.to_string(),
1244                detail: format!(
1245                    "migration-prefix oracle version {} is inconsistent with ownership: {detail}",
1246                    migration.version
1247                ),
1248            }
1249        })?;
1250        oracles.push((migration.version, fingerprint));
1251    }
1252    Ok(oracles)
1253}
1254
1255#[derive(Debug, PartialEq, Eq)]
1256struct DomainCatalogFingerprint {
1257    names: Vec<(String, String)>,
1258    objects: Vec<CatalogObjectFingerprint>,
1259}
1260
1261fn domain_catalog_fingerprint(
1262    conn: &Connection,
1263    domain: &SchemaDomain,
1264) -> Result<DomainCatalogFingerprint, String> {
1265    let all_objects = all_domain_objects(domain);
1266    let names = catalog_names(conn, &all_objects).map_err(|error| error.to_string())?;
1267    let declared = all_objects
1268        .iter()
1269        .map(|object| (object.name, object))
1270        .collect::<BTreeMap<_, _>>();
1271    let mut objects = Vec::with_capacity(names.len());
1272    for (kind, name) in &names {
1273        let Some(object) = declared.get(name.as_str()) else {
1274            return Err(format!("undeclared owned object `{name}`"));
1275        };
1276        if kind != object.kind.sqlite_name() {
1277            return Err(format!(
1278                "owned object `{name}` has kind `{kind}`, expected `{}`",
1279                object.kind.sqlite_name()
1280            ));
1281        }
1282        objects.push(
1283            catalog_fingerprint(conn, object)
1284                .map_err(|error| format!("fingerprint object `{name}`: {error}"))?,
1285        );
1286    }
1287    Ok(DomainCatalogFingerprint { names, objects })
1288}
1289
1290type CurrentCatalogCache = Mutex<BTreeMap<String, Result<String, String>>>;
1291
1292static EXPECTED_CURRENT_CATALOGS: OnceLock<CurrentCatalogCache> = OnceLock::new();
1293
1294/// Read one prepared expected catalog, keeping absent preparation and poison
1295/// apart.
1296///
1297/// The cache mutex is a process-global leaf lock: it is held only for one map
1298/// lookup or insert (the expected catalog is built outside it) and no other
1299/// lock is acquired while its guard is held, so callers may already hold
1300/// native custody or a BEGIN IMMEDIATE transaction outside it. Waiting for it cannot invert a
1301/// lock order, while refusing on contention made online administration fail
1302/// whenever another store in the process was opening. It still refuses an
1303/// unbuilt or missing catalog.
1304fn try_cached_current_catalog(
1305    cache: Option<&CurrentCatalogCache>,
1306    key: &str,
1307) -> Result<Result<String, String>, CurrentCatalogUnavailability> {
1308    let cache = cache.ok_or(CurrentCatalogUnavailability::NotPrepared)?;
1309    let entries = cache
1310        .lock()
1311        .map_err(|_| CurrentCatalogUnavailability::Poisoned)?;
1312    entries
1313        .get(key)
1314        .cloned()
1315        .ok_or(CurrentCatalogUnavailability::NotPrepared)
1316}
1317
1318/// Verify a current row against the exact catalog built by the current
1319/// initializer. The expected side is process-global pure code-derived state;
1320/// every actual connection is still read and bound independently.
1321fn verify_current_schema_fingerprint(
1322    actual: &Connection,
1323    domain: &SchemaDomain,
1324) -> Result<(), String> {
1325    let expected = {
1326        let cache = EXPECTED_CURRENT_CATALOGS.get_or_init(|| Mutex::new(BTreeMap::new()));
1327        let key = current_catalog_cache_key(domain);
1328        let cached = cache
1329            .lock()
1330            .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1331            .get(&key)
1332            .cloned();
1333        if let Some(cached) = cached {
1334            cached?
1335        } else {
1336            let built = build_current_catalog_fingerprint(domain);
1337            cache
1338                .lock()
1339                .map_err(|_| "current catalog cache lock is poisoned".to_string())?
1340                .insert(key, built.clone());
1341            built?
1342        }
1343    };
1344    let actual = compact_catalog_fingerprint(actual, domain, domain.owned_objects)?;
1345    if actual != expected {
1346        return Err(format!(
1347            "current owned catalog differs: expected {expected}, found {actual}"
1348        ));
1349    }
1350    Ok(())
1351}
1352
1353/// Bind the pure expected-catalog cache to the complete code-derived domain
1354/// identity. Name + version alone is insufficient: tests, embedders, or a
1355/// faulty registration can construct two manifests with the same persisted
1356/// identity but different initializer code or object ownership.
1357fn current_catalog_cache_key(domain: &SchemaDomain) -> String {
1358    let mut key = format!(
1359        "{}\u{1f}{}\u{1f}{:x}",
1360        domain.name,
1361        domain.supported_version(),
1362        domain.initialize_current as usize
1363    );
1364    for object in domain.owned_objects {
1365        key.push_str(&format!(
1366            "\u{1e}current:{}:{}",
1367            object.kind.sqlite_name(),
1368            object.name
1369        ));
1370    }
1371    for object in domain.retired_objects {
1372        key.push_str(&format!(
1373            "\u{1e}retired:{}:{}",
1374            object.kind.sqlite_name(),
1375            object.name
1376        ));
1377    }
1378    key
1379}
1380
1381/// Object-level difference between a file's domain-owned catalog and the
1382/// domain's current schema, each object named `kind:name`.
1383#[derive(Debug, Default)]
1384struct CurrentCatalogDiff {
1385    missing: Vec<String>,
1386    unexpected: Vec<String>,
1387    changed: Vec<String>,
1388}
1389
1390impl CurrentCatalogDiff {
1391    fn is_empty(&self) -> bool {
1392        self.missing.is_empty() && self.unexpected.is_empty() && self.changed.is_empty()
1393    }
1394}
1395
1396/// Diagnose a current-version fingerprint mismatch. Runs only on the refusal
1397/// path: the expected side is the domain's own initializer in a private
1398/// in-memory database, compared entry by entry with the file's owned and
1399/// retired objects (normalized CREATE SQL, as in the fingerprint).
1400fn current_catalog_diff(
1401    actual: &Connection,
1402    domain: &SchemaDomain,
1403) -> Result<CurrentCatalogDiff, String> {
1404    let mut oracle =
1405        Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1406    let tx = oracle
1407        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1408        .map_err(|error| format!("begin current oracle: {error}"))?;
1409    (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1410    tx.commit()
1411        .map_err(|error| format!("commit current oracle: {error}"))?;
1412    let expected = domain_catalog_entries(&oracle, domain)?;
1413    let found = domain_catalog_entries(actual, domain)?;
1414    let mut diff = CurrentCatalogDiff::default();
1415    for (object, entry) in &expected {
1416        match found.get(object) {
1417            None => diff.missing.push(object.clone()),
1418            Some(found_entry) if found_entry != entry => diff.changed.push(object.clone()),
1419            Some(_) => {}
1420        }
1421    }
1422    for object in found.keys() {
1423        if !expected.contains_key(object) {
1424            diff.unexpected.push(object.clone());
1425        }
1426    }
1427    Ok(diff)
1428}
1429
1430/// `kind:name` -> normalized definition for every object in `conn` whose name
1431/// the domain owns now or owned in a supported predecessor.
1432fn domain_catalog_entries(
1433    conn: &Connection,
1434    domain: &SchemaDomain,
1435) -> Result<BTreeMap<String, String>, String> {
1436    let names = all_domain_objects(domain)
1437        .into_iter()
1438        .map(|object| object.name)
1439        .collect::<std::collections::BTreeSet<_>>();
1440    let mut statement = conn
1441        .prepare(
1442            "SELECT type, name, tbl_name, sql
1443             FROM main.sqlite_schema
1444             WHERE name NOT LIKE 'sqlite_%'
1445             ORDER BY type, name",
1446        )
1447        .map_err(|error| error.to_string())?;
1448    let rows = statement
1449        .query_map([], |row| {
1450            Ok((
1451                row.get::<_, String>(0)?,
1452                row.get::<_, String>(1)?,
1453                row.get::<_, String>(2)?,
1454                row.get::<_, Option<String>>(3)?,
1455            ))
1456        })
1457        .map_err(|error| error.to_string())?;
1458    let mut entries = BTreeMap::new();
1459    for row in rows {
1460        let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1461        if names.contains(name.as_str()) {
1462            entries.insert(
1463                format!("{kind}:{name}"),
1464                format!(
1465                    "{table_name}\u{1f}{}",
1466                    sql.map(|sql| normalize_schema_sql(&sql))
1467                        .unwrap_or_default()
1468                ),
1469            );
1470        }
1471    }
1472    Ok(entries)
1473}
1474
1475fn build_current_catalog_fingerprint(domain: &SchemaDomain) -> Result<String, String> {
1476    let mut expected =
1477        Connection::open_in_memory().map_err(|error| format!("open current oracle: {error}"))?;
1478    let tx = expected
1479        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1480        .map_err(|error| format!("begin current oracle: {error}"))?;
1481    (domain.initialize_current)(&tx).map_err(|error| format!("build current oracle: {error}"))?;
1482    tx.commit()
1483        .map_err(|error| format!("commit current oracle: {error}"))?;
1484    compact_catalog_fingerprint(&expected, domain, domain.owned_objects)
1485}
1486
1487fn compact_catalog_fingerprint(
1488    conn: &Connection,
1489    domain: &SchemaDomain,
1490    expected_objects: &[SchemaObject],
1491) -> Result<String, String> {
1492    let all_objects = all_domain_objects(domain);
1493    let owned_by_name = all_objects
1494        .iter()
1495        .map(|object| (object.name, object))
1496        .collect::<BTreeMap<_, _>>();
1497    let current_by_name = expected_objects
1498        .iter()
1499        .map(|object| (object.name, object))
1500        .collect::<BTreeMap<_, _>>();
1501    let mut actual_names = Vec::new();
1502    let mut entries = Vec::with_capacity(expected_objects.len());
1503    let mut statement = conn
1504        .prepare(
1505            "SELECT type, name, tbl_name, sql
1506             FROM main.sqlite_schema
1507             WHERE name NOT LIKE 'sqlite_%'
1508             ORDER BY type, name",
1509        )
1510        .map_err(|error| error.to_string())?;
1511    let rows = statement
1512        .query_map([], |row| {
1513            Ok((
1514                row.get::<_, String>(0)?,
1515                row.get::<_, String>(1)?,
1516                row.get::<_, String>(2)?,
1517                row.get::<_, Option<String>>(3)?,
1518            ))
1519        })
1520        .map_err(|error| error.to_string())?;
1521    for row in rows {
1522        let (kind, name, table_name, sql) = row.map_err(|error| error.to_string())?;
1523        if owned_by_name.contains_key(name.as_str()) {
1524            actual_names.push((kind.clone(), name.clone()));
1525        }
1526        if current_by_name.contains_key(name.as_str()) {
1527            entries.push(format!(
1528                "{kind}\u{1f}{name}\u{1f}{table_name}\u{1f}{}",
1529                sql.map(|sql| normalize_schema_sql(&sql))
1530                    .unwrap_or_default()
1531            ));
1532        }
1533    }
1534    actual_names.sort();
1535    let mut expected_names = expected_objects
1536        .iter()
1537        .map(|object| {
1538            (
1539                object.kind.sqlite_name().to_string(),
1540                object.name.to_string(),
1541            )
1542        })
1543        .collect::<Vec<_>>();
1544    expected_names.sort();
1545    if actual_names != expected_names {
1546        return Err(format!(
1547            "owned object set differs: expected {expected_names:?}, found {actual_names:?}"
1548        ));
1549    }
1550
1551    entries.sort();
1552    Ok(entries.join("\u{1e}"))
1553}
1554
1555fn all_domain_objects(domain: &SchemaDomain) -> Vec<SchemaObject> {
1556    domain
1557        .owned_objects
1558        .iter()
1559        .chain(domain.retired_objects)
1560        .copied()
1561        .collect()
1562}
1563
1564/// Verify an on-disk predecessor against a frozen released schema builder.
1565///
1566/// The builder is run only in a private in-memory database. The comparison is
1567/// structured over `main.sqlite_schema`: exact owned object names/kinds,
1568/// normalized CREATE SQL, table xinfo and foreign keys, plus explicit-index
1569/// uniqueness/partial flags and xinfo. Foreign co-tenant objects are ignored.
1570///
1571/// Store crates use this from a [`SchemaPredecessor`] verifier, passing DDL
1572/// copied from the released tag rather than current initializer constants.
1573pub fn verify_released_schema_fingerprint(
1574    actual: &Connection,
1575    domain: &SchemaDomain,
1576    released_objects: &[SchemaObject],
1577    build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1578) -> Result<(), String> {
1579    verify_released_schema(
1580        actual,
1581        domain,
1582        released_objects,
1583        build_released,
1584        ReleasedSchemaComparison::ExactText,
1585    )
1586}
1587
1588/// Verify an on-disk predecessor against a frozen released schema builder by
1589/// exact physical structure, ignoring the stored CREATE text.
1590///
1591/// Same authority boundary as [`verify_released_schema_fingerprint`] (private
1592/// in-memory builder, exact owned object names/kinds, foreign co-tenant
1593/// objects ignored), but the normalized `sqlite_schema` SQL is deliberately
1594/// excluded from the comparison. Pre-ledger binaries re-issued their DDL
1595/// across releases, so lexical drift the whitespace normalizer keeps (for
1596/// example identifier quoting) is not release-stable evidence for those
1597/// catalogs.
1598/// Everything PRAGMA-visible remains exact: table xinfo (column order, names,
1599/// declared types, NOT NULL, defaults, primary-key positions), foreign keys,
1600/// and explicit-index uniqueness/partiality plus xinfo (column order,
1601/// direction, collation). Text-only clauses such as CHECK constraints are
1602/// invisible here; register this verifier only for released schemas that had
1603/// none.
1604pub fn verify_released_schema_structure(
1605    actual: &Connection,
1606    domain: &SchemaDomain,
1607    released_objects: &[SchemaObject],
1608    build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1609) -> Result<(), String> {
1610    verify_released_schema(
1611        actual,
1612        domain,
1613        released_objects,
1614        build_released,
1615        ReleasedSchemaComparison::StructureOnly,
1616    )
1617}
1618
1619/// How a frozen released catalog is compared against an on-disk one.
1620#[derive(Clone, Copy)]
1621enum ReleasedSchemaComparison {
1622    /// Structure plus the normalized `sqlite_schema` CREATE text.
1623    ExactText,
1624    /// Structure only; the stored CREATE text is not evidence.
1625    StructureOnly,
1626}
1627
1628fn verify_released_schema(
1629    actual: &Connection,
1630    domain: &SchemaDomain,
1631    released_objects: &[SchemaObject],
1632    build_released: fn(&Transaction<'_>) -> Result<(), rusqlite::Error>,
1633    comparison: ReleasedSchemaComparison,
1634) -> Result<(), String> {
1635    let mut expected = Connection::open_in_memory()
1636        .map_err(|error| format!("open fingerprint oracle: {error}"))?;
1637    let tx = expected
1638        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
1639        .map_err(|error| format!("begin fingerprint oracle: {error}"))?;
1640    build_released(&tx).map_err(|error| format!("build fingerprint oracle: {error}"))?;
1641    tx.commit()
1642        .map_err(|error| format!("commit fingerprint oracle: {error}"))?;
1643
1644    let expected_names = catalog_names(&expected, released_objects)
1645        .map_err(|error| format!("read fingerprint oracle: {error}"))?;
1646    let mut declared_expected = released_objects
1647        .iter()
1648        .map(|object| {
1649            (
1650                object.kind.sqlite_name().to_string(),
1651                object.name.to_string(),
1652            )
1653        })
1654        .collect::<Vec<_>>();
1655    declared_expected.sort();
1656    if expected_names != declared_expected {
1657        return Err(format!(
1658            "frozen builder produced {expected_names:?}, manifest declares {declared_expected:?}"
1659        ));
1660    }
1661
1662    let actual_names = catalog_names(actual, &all_domain_objects(domain))
1663        .map_err(|error| format!("read actual catalog: {error}"))?;
1664    if actual_names != declared_expected {
1665        return Err(format!(
1666            "owned object set differs: expected {declared_expected:?}, found {actual_names:?}"
1667        ));
1668    }
1669
1670    for object in released_objects {
1671        let mut wanted = catalog_fingerprint(&expected, object)
1672            .map_err(|error| format!("fingerprint oracle {}: {error}", object.name))?;
1673        let mut found = catalog_fingerprint(actual, object)
1674            .map_err(|error| format!("fingerprint actual {}: {error}", object.name))?;
1675        if matches!(comparison, ReleasedSchemaComparison::StructureOnly) {
1676            wanted.normalized_sql = None;
1677            found.normalized_sql = None;
1678        }
1679        if found != wanted {
1680            return Err(format!(
1681                "object `{}` differs: expected {wanted:?}, found {found:?}",
1682                object.name
1683            ));
1684        }
1685    }
1686    Ok(())
1687}
1688
1689#[derive(Debug, PartialEq, Eq)]
1690struct CatalogObjectFingerprint {
1691    kind: String,
1692    name: String,
1693    table_name: String,
1694    normalized_sql: Option<String>,
1695    table_columns: Vec<TableColumnFingerprint>,
1696    foreign_keys: Vec<ForeignKeyFingerprint>,
1697    index: Option<IndexFingerprint>,
1698}
1699
1700#[derive(Debug, PartialEq, Eq)]
1701struct TableColumnFingerprint {
1702    cid: i64,
1703    name: String,
1704    declared_type: String,
1705    not_null: bool,
1706    default_value: Option<String>,
1707    primary_key_position: i64,
1708    hidden: i64,
1709}
1710
1711#[derive(Debug, PartialEq, Eq)]
1712struct ForeignKeyFingerprint {
1713    id: i64,
1714    sequence: i64,
1715    target_table: String,
1716    from_column: String,
1717    to_column: Option<String>,
1718    on_update: String,
1719    on_delete: String,
1720    match_clause: String,
1721}
1722
1723#[derive(Debug, PartialEq, Eq)]
1724struct IndexFingerprint {
1725    unique: bool,
1726    origin: String,
1727    partial: bool,
1728    columns: Vec<IndexColumnFingerprint>,
1729}
1730
1731#[derive(Debug, PartialEq, Eq)]
1732struct IndexColumnFingerprint {
1733    sequence: i64,
1734    column_id: i64,
1735    name: Option<String>,
1736    descending: bool,
1737    collation: Option<String>,
1738    key: bool,
1739}
1740
1741fn catalog_names(
1742    conn: &Connection,
1743    objects: &[SchemaObject],
1744) -> Result<Vec<(String, String)>, rusqlite::Error> {
1745    let mut found = Vec::new();
1746    let mut statement = conn.prepare(
1747        "SELECT type, name FROM main.sqlite_schema
1748         WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
1749         ORDER BY type, name",
1750    )?;
1751    for object in objects {
1752        let rows = statement.query_map([object.name], |row| Ok((row.get(0)?, row.get(1)?)))?;
1753        found.extend(rows.collect::<Result<Vec<_>, _>>()?);
1754    }
1755    found.sort();
1756    found.dedup();
1757    Ok(found)
1758}
1759
1760fn catalog_fingerprint(
1761    conn: &Connection,
1762    object: &SchemaObject,
1763) -> Result<CatalogObjectFingerprint, rusqlite::Error> {
1764    let (kind, name, table_name, sql): (String, String, String, Option<String>) = conn.query_row(
1765        "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name = ?1",
1766        [object.name],
1767        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1768    )?;
1769    let (table_columns, foreign_keys) = if kind == "table" || kind == "view" {
1770        (
1771            table_columns(conn, object.name)?,
1772            foreign_keys(conn, object.name)?,
1773        )
1774    } else {
1775        (Vec::new(), Vec::new())
1776    };
1777    let index = if kind == "index" {
1778        Some(index_fingerprint(conn, &table_name, object.name)?)
1779    } else {
1780        None
1781    };
1782    Ok(CatalogObjectFingerprint {
1783        kind,
1784        name,
1785        table_name,
1786        normalized_sql: sql.map(|sql| normalize_schema_sql(&sql)),
1787        table_columns,
1788        foreign_keys,
1789        index,
1790    })
1791}
1792
1793fn table_columns(
1794    conn: &Connection,
1795    table: &str,
1796) -> Result<Vec<TableColumnFingerprint>, rusqlite::Error> {
1797    let mut statement = conn.prepare(
1798        "SELECT cid, name, type, \"notnull\", dflt_value, pk, hidden
1799         FROM pragma_table_xinfo(?1, 'main')
1800         ORDER BY cid",
1801    )?;
1802    let rows = statement.query_map([table], |row| {
1803        Ok(TableColumnFingerprint {
1804            cid: row.get(0)?,
1805            name: row.get(1)?,
1806            declared_type: row.get(2)?,
1807            not_null: row.get(3)?,
1808            default_value: row.get(4)?,
1809            primary_key_position: row.get(5)?,
1810            hidden: row.get(6)?,
1811        })
1812    })?;
1813    rows.collect()
1814}
1815
1816fn foreign_keys(
1817    conn: &Connection,
1818    table: &str,
1819) -> Result<Vec<ForeignKeyFingerprint>, rusqlite::Error> {
1820    let mut statement = conn.prepare(
1821        "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete, \"match\"
1822         FROM pragma_foreign_key_list(?1, 'main')
1823         ORDER BY id, seq",
1824    )?;
1825    let rows = statement.query_map([table], |row| {
1826        Ok(ForeignKeyFingerprint {
1827            id: row.get(0)?,
1828            sequence: row.get(1)?,
1829            target_table: row.get(2)?,
1830            from_column: row.get(3)?,
1831            to_column: row.get(4)?,
1832            on_update: row.get(5)?,
1833            on_delete: row.get(6)?,
1834            match_clause: row.get(7)?,
1835        })
1836    })?;
1837    rows.collect()
1838}
1839
1840fn index_fingerprint(
1841    conn: &Connection,
1842    table: &str,
1843    index: &str,
1844) -> Result<IndexFingerprint, rusqlite::Error> {
1845    let (unique, origin, partial): (bool, String, bool) = conn.query_row(
1846        "SELECT \"unique\", origin, partial
1847         FROM pragma_index_list(?1, 'main')
1848         WHERE name = ?2",
1849        rusqlite::params![table, index],
1850        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1851    )?;
1852    let mut statement = conn.prepare(
1853        "SELECT seqno, cid, name, desc, coll, key
1854         FROM pragma_index_xinfo(?1, 'main')
1855         ORDER BY seqno",
1856    )?;
1857    let columns = statement
1858        .query_map([index], |row| {
1859            Ok(IndexColumnFingerprint {
1860                sequence: row.get(0)?,
1861                column_id: row.get(1)?,
1862                name: row.get(2)?,
1863                descending: row.get(3)?,
1864                collation: row.get(4)?,
1865                key: row.get(5)?,
1866            })
1867        })?
1868        .collect::<Result<Vec<_>, _>>()?;
1869    Ok(IndexFingerprint {
1870        unique,
1871        origin,
1872        partial,
1873        columns,
1874    })
1875}
1876
1877fn normalize_schema_sql(sql: &str) -> String {
1878    #[derive(Clone, Copy)]
1879    enum LexState {
1880        Normal,
1881        SingleQuoted,
1882        DoubleQuoted,
1883        BacktickQuoted,
1884        BracketQuoted,
1885        LineComment,
1886        BlockComment,
1887    }
1888
1889    let bytes = sql.as_bytes();
1890    let mut collapsed = Vec::with_capacity(bytes.len());
1891    let mut state = LexState::Normal;
1892    let mut index = 0;
1893    while index < bytes.len() {
1894        let byte = bytes[index];
1895        match state {
1896            LexState::Normal => {
1897                if byte.is_ascii_whitespace() {
1898                    if collapsed.last().is_some_and(|last| *last != b' ') {
1899                        collapsed.push(b' ');
1900                    }
1901                } else {
1902                    collapsed.push(byte);
1903                    state = match byte {
1904                        b'\'' => LexState::SingleQuoted,
1905                        b'"' => LexState::DoubleQuoted,
1906                        b'`' => LexState::BacktickQuoted,
1907                        b'[' => LexState::BracketQuoted,
1908                        b'-' if bytes.get(index + 1) == Some(&b'-') => LexState::LineComment,
1909                        b'/' if bytes.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1910                        _ => LexState::Normal,
1911                    };
1912                }
1913            }
1914            LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
1915                collapsed.push(byte);
1916                let delimiter = match state {
1917                    LexState::SingleQuoted => b'\'',
1918                    LexState::DoubleQuoted => b'"',
1919                    LexState::BacktickQuoted => b'`',
1920                    _ => unreachable!(),
1921                };
1922                if byte == delimiter {
1923                    if bytes.get(index + 1) == Some(&delimiter) {
1924                        index += 1;
1925                        collapsed.push(delimiter);
1926                    } else {
1927                        state = LexState::Normal;
1928                    }
1929                }
1930            }
1931            LexState::BracketQuoted => {
1932                collapsed.push(byte);
1933                if byte == b']' {
1934                    if bytes.get(index + 1) == Some(&b']') {
1935                        index += 1;
1936                        collapsed.push(b']');
1937                    } else {
1938                        state = LexState::Normal;
1939                    }
1940                }
1941            }
1942            LexState::LineComment => {
1943                collapsed.push(byte);
1944                if byte == b'\n' || byte == b'\r' {
1945                    state = LexState::Normal;
1946                }
1947            }
1948            LexState::BlockComment => {
1949                collapsed.push(byte);
1950                if byte == b'*' && bytes.get(index + 1) == Some(&b'/') {
1951                    index += 1;
1952                    collapsed.push(b'/');
1953                    state = LexState::Normal;
1954                }
1955            }
1956        }
1957        index += 1;
1958    }
1959
1960    const IF_NOT_EXISTS: &[u8] = b"IF NOT EXISTS";
1961    let mut normalized = Vec::with_capacity(collapsed.len());
1962    let mut state = LexState::Normal;
1963    let mut index = 0;
1964    while index < collapsed.len() {
1965        let byte = collapsed[index];
1966        if matches!(state, LexState::Normal)
1967            && collapsed
1968                .get(index..index + IF_NOT_EXISTS.len())
1969                .is_some_and(|candidate| candidate.eq_ignore_ascii_case(IF_NOT_EXISTS))
1970            && (index == 0 || !is_sql_identifier_byte(collapsed[index - 1]))
1971            && collapsed
1972                .get(index + IF_NOT_EXISTS.len())
1973                .is_none_or(|after| !is_sql_identifier_byte(*after))
1974        {
1975            index += IF_NOT_EXISTS.len();
1976            if collapsed.get(index) == Some(&b' ') {
1977                index += 1;
1978            }
1979            continue;
1980        }
1981        normalized.push(byte);
1982        match state {
1983            LexState::Normal => {
1984                state = match byte {
1985                    b'\'' => LexState::SingleQuoted,
1986                    b'"' => LexState::DoubleQuoted,
1987                    b'`' => LexState::BacktickQuoted,
1988                    b'[' => LexState::BracketQuoted,
1989                    b'-' if collapsed.get(index + 1) == Some(&b'-') => LexState::LineComment,
1990                    b'/' if collapsed.get(index + 1) == Some(&b'*') => LexState::BlockComment,
1991                    _ => LexState::Normal,
1992                };
1993            }
1994            LexState::SingleQuoted | LexState::DoubleQuoted | LexState::BacktickQuoted => {
1995                let delimiter = match state {
1996                    LexState::SingleQuoted => b'\'',
1997                    LexState::DoubleQuoted => b'"',
1998                    LexState::BacktickQuoted => b'`',
1999                    _ => unreachable!(),
2000                };
2001                if byte == delimiter {
2002                    if collapsed.get(index + 1) == Some(&delimiter) {
2003                        index += 1;
2004                        normalized.push(delimiter);
2005                    } else {
2006                        state = LexState::Normal;
2007                    }
2008                }
2009            }
2010            LexState::BracketQuoted => {
2011                if byte == b']' {
2012                    if collapsed.get(index + 1) == Some(&b']') {
2013                        index += 1;
2014                        normalized.push(b']');
2015                    } else {
2016                        state = LexState::Normal;
2017                    }
2018                }
2019            }
2020            LexState::LineComment => {
2021                if byte == b'\n' || byte == b'\r' {
2022                    state = LexState::Normal;
2023                }
2024            }
2025            LexState::BlockComment => {
2026                if byte == b'*' && collapsed.get(index + 1) == Some(&b'/') {
2027                    index += 1;
2028                    normalized.push(b'/');
2029                    state = LexState::Normal;
2030                }
2031            }
2032        }
2033        index += 1;
2034    }
2035    String::from_utf8(normalized).unwrap_or_else(|_| sql.to_string())
2036}
2037
2038fn is_sql_identifier_byte(byte: u8) -> bool {
2039    byte.is_ascii_alphanumeric() || byte == b'_'
2040}
2041
2042fn unsupported_predecessor(domain: &SchemaDomain, found: i64) -> SqliteStoreError {
2043    SqliteStoreError::UnsupportedSchemaPredecessor {
2044        domain: domain.name.to_string(),
2045        found,
2046        supported: domain.supported_version(),
2047        allowed: domain.allowed_existing_versions.to_vec(),
2048    }
2049}
2050
2051/// Return owned catalog names already present in `main`.
2052///
2053/// A wrong-kind collision is included (and annotated with its actual kind)
2054/// because object names themselves are the ownership boundary.
2055fn find_owned_objects(
2056    conn: &Connection,
2057    domain: &SchemaDomain,
2058) -> Result<Vec<String>, SqliteStoreError> {
2059    let mut found = Vec::new();
2060    let mut stmt = conn.prepare(
2061        "SELECT type, name FROM main.sqlite_schema
2062         WHERE name = ?1 AND name NOT LIKE 'sqlite_%'
2063         ORDER BY type, name",
2064    )?;
2065    for expected in domain.owned_objects.iter().chain(domain.retired_objects) {
2066        let rows = stmt.query_map([expected.name], |row| {
2067            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2068        })?;
2069        for row in rows {
2070            let (actual_kind, name) = row?;
2071            found.push(format!(
2072                "{actual_kind}:{name} (expected {})",
2073                expected.kind.sqlite_name()
2074            ));
2075        }
2076    }
2077    found.sort();
2078    found.dedup();
2079    Ok(found)
2080}
2081
2082fn ledger_table_exists(conn: &Connection) -> Result<bool, SqliteStoreError> {
2083    let exists = conn
2084        .query_row(
2085            "SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'meerkat_schema'",
2086            [],
2087            |_| Ok(()),
2088        )
2089        .optional()?
2090        .is_some();
2091    Ok(exists)
2092}
2093
2094fn malformed(detail: String) -> SqliteStoreError {
2095    SqliteStoreError::LedgerMalformed { detail }
2096}
2097
2098/// Validate the pinned ledger shape against `main`'s real catalog.
2099///
2100/// `domain` must be `TEXT` and the *sole* primary-key column (a composite
2101/// key would permit multiple rows per domain); `version` must be
2102/// `INTEGER NOT NULL`. Columns beyond the pinned pair are tolerated as long
2103/// as they carry no primary-key position, so a future ledger protocol can
2104/// extend the table compatibly. The schema-qualified `pragma_table_info`
2105/// resolves in `main`, so a TEMP shadow cannot satisfy this check.
2106fn validate_ledger_shape(conn: &Connection) -> Result<(), SqliteStoreError> {
2107    let mut stmt = conn.prepare(
2108        "SELECT name, type, \"notnull\", pk FROM pragma_table_info('meerkat_schema', 'main')",
2109    )?;
2110    let mut rows = stmt.query([])?;
2111    let mut domain_ok = false;
2112    let mut version_ok = false;
2113    while let Some(row) = rows.next()? {
2114        let name: String = row.get(0)?;
2115        let decl_type: String = row.get(1)?;
2116        let notnull: bool = row.get(2)?;
2117        let pk: i64 = row.get(3)?;
2118        match name.as_str() {
2119            "domain" => {
2120                if !decl_type.eq_ignore_ascii_case("TEXT") || pk != 1 {
2121                    return Err(malformed(format!(
2122                        "column `domain` must be `TEXT PRIMARY KEY`, found type `{decl_type}` \
2123                         with pk position {pk}"
2124                    )));
2125                }
2126                domain_ok = true;
2127            }
2128            "version" => {
2129                if !decl_type.eq_ignore_ascii_case("INTEGER") || !notnull || pk != 0 {
2130                    return Err(malformed(format!(
2131                        "column `version` must be non-key `INTEGER NOT NULL`, found type \
2132                         `{decl_type}` notnull={notnull} pk position {pk}"
2133                    )));
2134                }
2135                version_ok = true;
2136            }
2137            other => {
2138                if pk != 0 {
2139                    return Err(malformed(format!(
2140                        "unexpected primary-key column `{other}`"
2141                    )));
2142                }
2143            }
2144        }
2145    }
2146    if !domain_ok || !version_ok {
2147        return Err(malformed(
2148            "table lacks the pinned `domain`/`version` columns".to_string(),
2149        ));
2150    }
2151    let mut trigger_stmt = conn.prepare(
2152        "SELECT 'main', name FROM main.sqlite_schema
2153         WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2154         UNION ALL
2155         SELECT 'temp', name FROM temp.sqlite_schema
2156         WHERE type = 'trigger' AND tbl_name = 'meerkat_schema' COLLATE NOCASE
2157         ORDER BY 1, 2",
2158    )?;
2159    let triggers = trigger_stmt
2160        .query_map([], |row| {
2161            Ok(format!(
2162                "{}.{}",
2163                row.get::<_, String>(0)?,
2164                row.get::<_, String>(1)?
2165            ))
2166        })?
2167        .collect::<Result<Vec<_>, _>>()?;
2168    if !triggers.is_empty() {
2169        return Err(malformed(format!(
2170            "table has attached triggers {triggers:?}; ledger writes must be isolated"
2171        )));
2172    }
2173    Ok(())
2174}
2175
2176/// Read one domain's version, refusing corrupt ledger state typed: more than
2177/// one row per domain (impossible under the validated single-column primary
2178/// key; kept as defense in depth) and non-positive versions (0 is the
2179/// implicit "no row" reading and negatives are meaningless — a stored
2180/// non-positive version is damage to refuse, not an old schema to re-migrate
2181/// over).
2182fn read_version(conn: &Connection, domain: &str) -> Result<Option<i64>, SqliteStoreError> {
2183    let mut stmt = conn.prepare("SELECT version FROM main.meerkat_schema WHERE domain = ?1")?;
2184    let mut rows = stmt.query([domain])?;
2185    let Some(row) = rows.next()? else {
2186        return Ok(None);
2187    };
2188    let version: i64 = row.get(0)?;
2189    if rows.next()?.is_some() {
2190        return Err(malformed(format!(
2191            "multiple ledger rows for domain `{domain}`"
2192        )));
2193    }
2194    if version <= 0 {
2195        return Err(malformed(format!(
2196            "domain `{domain}` records non-positive version {version}"
2197        )));
2198    }
2199    Ok(Some(version))
2200}
2201
2202#[cfg(test)]
2203#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2204mod tests {
2205    use super::*;
2206    use crate::profile::{ConnectionProfile, open};
2207
2208    #[test]
2209    fn catalog_lookup_keeps_unprepared_and_poisoned_apart() {
2210        assert_eq!(
2211            try_cached_current_catalog(None, "domain"),
2212            Err(CurrentCatalogUnavailability::NotPrepared)
2213        );
2214        let cache = CurrentCatalogCache::default();
2215        assert_eq!(
2216            try_cached_current_catalog(Some(&cache), "domain"),
2217            Err(CurrentCatalogUnavailability::NotPrepared)
2218        );
2219        cache
2220            .lock()
2221            .unwrap()
2222            .insert("domain".to_string(), Ok("catalog".to_string()));
2223        assert_eq!(
2224            try_cached_current_catalog(Some(&cache), "domain"),
2225            Ok(Ok("catalog".to_string()))
2226        );
2227        let poisoned = std::sync::Arc::new(CurrentCatalogCache::default());
2228        let holder = std::sync::Arc::clone(&poisoned);
2229        let _ = std::thread::spawn(move || {
2230            let _held = holder.lock().unwrap();
2231            panic!("poison the catalog cache");
2232        })
2233        .join();
2234        assert_eq!(
2235            try_cached_current_catalog(Some(&poisoned), "domain"),
2236            Err(CurrentCatalogUnavailability::Poisoned)
2237        );
2238    }
2239
2240    fn create_t1(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2241        tx.execute_batch("CREATE TABLE IF NOT EXISTS t1 (x INTEGER)")
2242    }
2243
2244    fn add_column_guarded(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2245        let has_column = tx
2246            .prepare("PRAGMA table_info(t1)")?
2247            .query_map([], |row| row.get::<_, String>(1))?
2248            .collect::<Result<Vec<_>, _>>()?
2249            .iter()
2250            .any(|name| name == "y");
2251        if !has_column {
2252            tx.execute_batch("ALTER TABLE t1 ADD COLUMN y TEXT")?;
2253        }
2254        Ok(())
2255    }
2256
2257    fn initialize_v2(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2258        create_t1(tx)?;
2259        add_column_guarded(tx)
2260    }
2261
2262    fn initialize_v2_alt(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2263        tx.execute_batch("CREATE TABLE t1 (x INTEGER, z BLOB)")
2264    }
2265
2266    const RELEASED_V1_OBJECTS: &[SchemaObject] = &[SchemaObject {
2267        kind: SchemaObjectKind::Table,
2268        name: "t1",
2269    }];
2270
2271    fn verify_v1(conn: &Connection) -> Result<(), String> {
2272        verify_released_schema_fingerprint(conn, &DOMAIN_V2, RELEASED_V1_OBJECTS, create_t1)
2273    }
2274
2275    const DOMAIN_V1: SchemaDomain = SchemaDomain {
2276        name: "test-domain",
2277        migrations: &[Migration {
2278            version: 1,
2279            name: "base",
2280            apply: create_t1,
2281        }],
2282        initialize_current: create_t1,
2283        allowed_existing_versions: &[1],
2284        bridge_recoverable_versions: &[1],
2285        released_predecessors: &[],
2286        owned_objects: &[SchemaObject {
2287            kind: SchemaObjectKind::Table,
2288            name: "t1",
2289        }],
2290        retired_objects: &[],
2291    };
2292
2293    const DOMAIN_V2: SchemaDomain = SchemaDomain {
2294        name: "test-domain",
2295        migrations: &[
2296            Migration {
2297                version: 1,
2298                name: "base",
2299                apply: create_t1,
2300            },
2301            Migration {
2302                version: 2,
2303                name: "add-y",
2304                apply: add_column_guarded,
2305            },
2306        ],
2307        initialize_current: initialize_v2,
2308        allowed_existing_versions: &[1, 2],
2309        bridge_recoverable_versions: &[1],
2310        released_predecessors: &[SchemaPredecessor {
2311            version: 1,
2312            verify: verify_v1,
2313        }],
2314        owned_objects: &[SchemaObject {
2315            kind: SchemaObjectKind::Table,
2316            name: "t1",
2317        }],
2318        retired_objects: &[],
2319    };
2320
2321    const DOMAIN_V2_ALT_INITIALIZER: SchemaDomain = SchemaDomain {
2322        name: "test-domain",
2323        migrations: &[
2324            Migration {
2325                version: 1,
2326                name: "base",
2327                apply: create_t1,
2328            },
2329            Migration {
2330                version: 2,
2331                name: "alt-current",
2332                apply: add_column_guarded,
2333            },
2334        ],
2335        initialize_current: initialize_v2_alt,
2336        allowed_existing_versions: &[2],
2337        bridge_recoverable_versions: &[1],
2338        released_predecessors: &[],
2339        owned_objects: &[SchemaObject {
2340            kind: SchemaObjectKind::Table,
2341            name: "t1",
2342        }],
2343        retired_objects: &[],
2344    };
2345
2346    fn temp_conn(dir: &tempfile::TempDir) -> Connection {
2347        open(&dir.path().join("db.sqlite3"), ConnectionProfile::PRIMARY).expect("open")
2348    }
2349
2350    #[test]
2351    fn structure_verifier_ignores_create_text_but_rejects_shape_drift() {
2352        const STRUCTURE_OBJECTS: &[SchemaObject] = &[
2353            SchemaObject {
2354                kind: SchemaObjectKind::Table,
2355                name: "t1",
2356            },
2357            SchemaObject {
2358                kind: SchemaObjectKind::Index,
2359                name: "t1_idx",
2360            },
2361        ];
2362        fn build_frozen(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2363            tx.execute_batch(
2364                "CREATE TABLE IF NOT EXISTS t1 (x INTEGER NOT NULL);
2365                 CREATE INDEX IF NOT EXISTS t1_idx ON t1(x DESC);",
2366            )
2367        }
2368        const STRUCTURE_DOMAIN: SchemaDomain = SchemaDomain {
2369            name: "structure-domain",
2370            migrations: &[Migration {
2371                version: 1,
2372                name: "base",
2373                apply: build_frozen,
2374            }],
2375            initialize_current: build_frozen,
2376            allowed_existing_versions: &[1],
2377            bridge_recoverable_versions: &[1],
2378            released_predecessors: &[],
2379            owned_objects: STRUCTURE_OBJECTS,
2380            retired_objects: &[],
2381        };
2382        let make = |ddl: &str| {
2383            let conn = Connection::open_in_memory().expect("in-memory fixture");
2384            conn.execute_batch(ddl).expect("fixture ddl");
2385            conn
2386        };
2387
2388        // Identifier quoting survives in the stored CREATE text (unlike the
2389        // IF NOT EXISTS clause, which SQLite strips before storing), so this
2390        // catalog is lexically distinct but structurally identical.
2391        let quoted = make(
2392            "CREATE TABLE \"t1\" (x INTEGER NOT NULL);
2393             CREATE INDEX t1_idx ON t1(x DESC);",
2394        );
2395        verify_released_schema_structure(
2396            &quoted,
2397            &STRUCTURE_DOMAIN,
2398            STRUCTURE_OBJECTS,
2399            build_frozen,
2400        )
2401        .expect("CREATE-text drift alone must pass the structural verifier");
2402        verify_released_schema_fingerprint(
2403            &quoted,
2404            &STRUCTURE_DOMAIN,
2405            STRUCTURE_OBJECTS,
2406            build_frozen,
2407        )
2408        .expect_err("the exact-text verifier must still reject the same catalog");
2409
2410        let wrong_type = make(
2411            "CREATE TABLE t1 (x TEXT NOT NULL);
2412             CREATE INDEX t1_idx ON t1(x DESC);",
2413        );
2414        verify_released_schema_structure(
2415            &wrong_type,
2416            &STRUCTURE_DOMAIN,
2417            STRUCTURE_OBJECTS,
2418            build_frozen,
2419        )
2420        .expect_err("column type drift must fail structurally");
2421
2422        let wrong_index = make(
2423            "CREATE TABLE t1 (x INTEGER NOT NULL);
2424             CREATE INDEX t1_idx ON t1(x ASC);",
2425        );
2426        verify_released_schema_structure(
2427            &wrong_index,
2428            &STRUCTURE_DOMAIN,
2429            STRUCTURE_OBJECTS,
2430            build_frozen,
2431        )
2432        .expect_err("index direction drift must fail structurally");
2433
2434        let missing_index = make("CREATE TABLE t1 (x INTEGER NOT NULL);");
2435        verify_released_schema_structure(
2436            &missing_index,
2437            &STRUCTURE_DOMAIN,
2438            STRUCTURE_OBJECTS,
2439            build_frozen,
2440        )
2441        .expect_err("a missing owned index must fail the object-set check");
2442    }
2443
2444    #[test]
2445    fn fresh_file_initializes_current_and_stamps() {
2446        let dir = tempfile::tempdir().expect("tempdir");
2447        let mut conn = temp_conn(&dir);
2448        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply");
2449        assert_eq!(
2450            report,
2451            LedgerReport {
2452                from_version: 0,
2453                to_version: 2
2454            }
2455        );
2456        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
2457        conn.execute("INSERT INTO t1 (x, y) VALUES (1, 'a')", [])
2458            .expect("schema converged");
2459    }
2460
2461    #[test]
2462    fn second_open_is_current_noop() {
2463        let dir = tempfile::tempdir().expect("tempdir");
2464        let mut conn = temp_conn(&dir);
2465        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("first");
2466        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("second");
2467        assert!(!report.migrated());
2468    }
2469
2470    #[test]
2471    fn current_oracle_cache_binds_initializer_and_manifest_not_only_name_version() {
2472        let first_dir = tempfile::tempdir().expect("first tempdir");
2473        let mut first = temp_conn(&first_dir);
2474        apply_domain_migrations(&mut first, &DOMAIN_V2).expect("first current");
2475
2476        let second_dir = tempfile::tempdir().expect("second tempdir");
2477        let mut second = temp_conn(&second_dir);
2478        apply_domain_migrations(&mut second, &DOMAIN_V2_ALT_INITIALIZER).expect("alt current");
2479        let columns: Vec<String> = second
2480            .prepare("PRAGMA table_info(t1)")
2481            .expect("prepare")
2482            .query_map([], |row| row.get(1))
2483            .expect("query")
2484            .collect::<Result<_, _>>()
2485            .expect("columns");
2486        assert_eq!(columns, vec!["x", "z"]);
2487    }
2488
2489    #[test]
2490    fn current_row_with_partial_catalog_is_refused_before_noop() {
2491        let dir = tempfile::tempdir().expect("tempdir");
2492        let mut conn = temp_conn(&dir);
2493        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2494        conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_partial TEXT")
2495            .expect("partial candidate mutation");
2496        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current shape");
2497        match err {
2498            SqliteStoreError::CurrentSchemaMismatch {
2499                version: 2,
2500                missing_objects,
2501                unexpected_objects,
2502                changed_objects,
2503                ..
2504            } => {
2505                assert!(missing_objects.is_empty(), "{missing_objects:?}");
2506                assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2507                assert_eq!(changed_objects, vec!["table:t1".to_string()]);
2508            }
2509            other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2510        }
2511        assert_eq!(
2512            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2513            Some(2)
2514        );
2515    }
2516
2517    #[test]
2518    fn current_row_without_its_owned_objects_names_them() {
2519        // A file stamped with this domain's current version by a build that
2520        // reused the number for a different schema: the owned table is absent.
2521        let dir = tempfile::tempdir().expect("tempdir");
2522        let mut conn = temp_conn(&dir);
2523        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("current");
2524        conn.execute_batch("DROP TABLE t1; CREATE TABLE unrelated_candidate (x INTEGER)")
2525            .expect("foreign-shaped current");
2526        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse current row");
2527        match err {
2528            SqliteStoreError::CurrentSchemaMismatch {
2529                version: 2,
2530                missing_objects,
2531                unexpected_objects,
2532                changed_objects,
2533                ..
2534            } => {
2535                assert_eq!(missing_objects, vec!["table:t1".to_string()]);
2536                // Foreign co-tenant objects are not this domain's business.
2537                assert!(unexpected_objects.is_empty(), "{unexpected_objects:?}");
2538                assert!(changed_objects.is_empty(), "{changed_objects:?}");
2539            }
2540            other => panic!("expected a typed current-schema mismatch, got {other:?}"),
2541        }
2542    }
2543
2544    #[test]
2545    fn upgrade_applies_only_pending() {
2546        let dir = tempfile::tempdir().expect("tempdir");
2547        let mut conn = temp_conn(&dir);
2548        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2549        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("v2");
2550        assert_eq!(
2551            report,
2552            LedgerReport {
2553                from_version: 1,
2554                to_version: 2
2555            }
2556        );
2557    }
2558
2559    #[test]
2560    fn allowed_version_with_wrong_catalog_is_refused_without_migration() {
2561        let dir = tempfile::tempdir().expect("tempdir");
2562        let mut conn = temp_conn(&dir);
2563        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("v1");
2564        conn.execute_batch("ALTER TABLE t1 ADD COLUMN candidate_only TEXT")
2565            .expect("candidate shape");
2566        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse fingerprint");
2567        assert!(matches!(
2568            err,
2569            SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
2570        ));
2571        assert_eq!(
2572            domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
2573            Some(1),
2574            "fingerprint refusal advanced the ledger"
2575        );
2576        let columns: Vec<String> = conn
2577            .prepare("PRAGMA table_info(t1)")
2578            .expect("prepare")
2579            .query_map([], |row| row.get(1))
2580            .expect("query")
2581            .collect::<Result<_, _>>()
2582            .expect("columns");
2583        assert_eq!(columns, vec!["x", "candidate_only"]);
2584    }
2585
2586    #[test]
2587    fn pre_floor_and_gap_versions_are_refused_without_mutation() {
2588        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2589            Ok(())
2590        }
2591        fn verify_v2(_conn: &Connection) -> Result<(), String> {
2592            Ok(())
2593        }
2594        const DOMAIN_V3_FLOOR_2: SchemaDomain = SchemaDomain {
2595            name: "floor-domain",
2596            migrations: &[
2597                Migration {
2598                    version: 1,
2599                    name: "base",
2600                    apply: no_op,
2601                },
2602                Migration {
2603                    version: 2,
2604                    name: "released-floor",
2605                    apply: no_op,
2606                },
2607                Migration {
2608                    version: 3,
2609                    name: "current",
2610                    apply: no_op,
2611                },
2612            ],
2613            initialize_current: no_op,
2614            allowed_existing_versions: &[2, 3],
2615            bridge_recoverable_versions: &[1],
2616            released_predecessors: &[SchemaPredecessor {
2617                version: 2,
2618                verify: verify_v2,
2619            }],
2620            owned_objects: &[],
2621            retired_objects: &[],
2622        };
2623        const DOMAIN_V4_GAP_3: SchemaDomain = SchemaDomain {
2624            name: "gap-domain",
2625            migrations: &[
2626                Migration {
2627                    version: 1,
2628                    name: "old",
2629                    apply: no_op,
2630                },
2631                Migration {
2632                    version: 2,
2633                    name: "released-floor",
2634                    apply: no_op,
2635                },
2636                Migration {
2637                    version: 3,
2638                    name: "unreleased-candidate",
2639                    apply: no_op,
2640                },
2641                Migration {
2642                    version: 4,
2643                    name: "current",
2644                    apply: no_op,
2645                },
2646            ],
2647            initialize_current: no_op,
2648            allowed_existing_versions: &[2, 4],
2649            bridge_recoverable_versions: &[1],
2650            released_predecessors: &[SchemaPredecessor {
2651                version: 2,
2652                verify: verify_v2,
2653            }],
2654            owned_objects: &[],
2655            retired_objects: &[],
2656        };
2657        let dir = tempfile::tempdir().expect("tempdir");
2658        let mut conn = temp_conn(&dir);
2659        conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
2660        conn.execute(
2661            "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
2662            [DOMAIN_V3_FLOOR_2.name],
2663        )
2664        .expect("pre-floor row");
2665        let err =
2666            apply_domain_migrations(&mut conn, &DOMAIN_V3_FLOOR_2).expect_err("refuse pre-floor");
2667        assert!(matches!(
2668            err,
2669            SqliteStoreError::UnsupportedSchemaPredecessor { found: 1, .. }
2670        ));
2671        assert_eq!(
2672            domain_version(&conn, DOMAIN_V3_FLOOR_2.name).expect("ledger"),
2673            Some(1)
2674        );
2675
2676        conn.execute(
2677            "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 3)",
2678            [DOMAIN_V4_GAP_3.name],
2679        )
2680        .expect("gap row");
2681        let err = apply_domain_migrations(&mut conn, &DOMAIN_V4_GAP_3).expect_err("refuse gap");
2682        assert!(matches!(
2683            err,
2684            SqliteStoreError::UnsupportedSchemaPredecessor { found: 3, .. }
2685        ));
2686        assert_eq!(
2687            domain_version(&conn, DOMAIN_V4_GAP_3.name).expect("ledger"),
2688            Some(3)
2689        );
2690    }
2691
2692    #[test]
2693    fn unledgered_owned_objects_are_refused_without_mutation() {
2694        let dir = tempfile::tempdir().expect("tempdir");
2695        let mut conn = temp_conn(&dir);
2696        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2697            .expect("unknown unledgered ddl");
2698        let err = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect_err("refuse");
2699        assert!(matches!(
2700            err,
2701            SqliteStoreError::UnledgeredDomainObjects { .. }
2702        ));
2703        assert!(
2704            !ledger_table_exists(&conn).expect("ledger presence"),
2705            "eligibility refusal must not create the ledger"
2706        );
2707        let columns: Vec<String> = conn
2708            .prepare("PRAGMA table_info(t1)")
2709            .expect("prepare")
2710            .query_map([], |row| row.get(1))
2711            .expect("query")
2712            .collect::<Result<_, _>>()
2713            .expect("columns");
2714        assert_eq!(columns, vec!["x"], "refusal mutated unknown schema");
2715    }
2716
2717    #[test]
2718    fn maintenance_bridge_authenticates_exact_v1_and_migrates_to_v2() {
2719        let dir = tempfile::tempdir().expect("tempdir");
2720        let mut conn = temp_conn(&dir);
2721        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2722            .expect("historical unledgered v1");
2723
2724        let report =
2725            bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("bridge");
2726        assert_eq!(
2727            report,
2728            MaintenanceBridgeReport {
2729                from_version: 1,
2730                to_version: 2,
2731                prepared: 0,
2732                refused: Vec::new(),
2733            }
2734        );
2735        assert_eq!(
2736            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2737            Some(2)
2738        );
2739        let columns = conn
2740            .prepare("PRAGMA table_info(t1)")
2741            .expect("prepare")
2742            .query_map([], |row| row.get::<_, String>(1))
2743            .expect("query")
2744            .collect::<Result<Vec<_>, _>>()
2745            .expect("columns");
2746        assert_eq!(columns, vec!["x", "y"]);
2747
2748        let second = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
2749            .expect("idempotent bridge");
2750        assert_eq!(
2751            second,
2752            MaintenanceBridgeReport {
2753                from_version: 2,
2754                to_version: 2,
2755                prepared: 0,
2756                refused: Vec::new(),
2757            }
2758        );
2759    }
2760
2761    #[test]
2762    fn maintenance_bridge_prepares_and_upgrades_existing_v1_row() {
2763        fn normalize_existing(
2764            tx: &Transaction<'_>,
2765        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2766            let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2767            Ok(MaintenancePrepareReport::rewrote(changed))
2768        }
2769
2770        let dir = tempfile::tempdir().expect("tempdir");
2771        let mut conn = temp_conn(&dir);
2772        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("ledgered v1");
2773        conn.execute("INSERT INTO t1 (x) VALUES (7)", [])
2774            .expect("historical data");
2775
2776        let report =
2777            bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_existing))
2778                .expect("bridge ledgered predecessor");
2779        assert_eq!(
2780            report,
2781            MaintenanceBridgeReport {
2782                from_version: 1,
2783                to_version: 2,
2784                prepared: 1,
2785                refused: Vec::new(),
2786            }
2787        );
2788        assert_eq!(
2789            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2790            Some(2)
2791        );
2792        assert_eq!(
2793            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2794                .expect("prepared row"),
2795            8
2796        );
2797        conn.execute("INSERT INTO t1 (x, y) VALUES (9, 'migrated')", [])
2798            .expect("v2 shape");
2799    }
2800
2801    #[test]
2802    fn maintenance_bridge_prepares_existing_target_and_reports_durable_changes() {
2803        fn normalize_target(
2804            tx: &Transaction<'_>,
2805        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2806            let changed = tx.execute("UPDATE t1 SET x = x + 1", [])?;
2807            Ok(MaintenancePrepareReport::rewrote(changed))
2808        }
2809        fn mutate_then_fail(
2810            tx: &Transaction<'_>,
2811        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2812            tx.execute("UPDATE t1 SET x = 99", [])?;
2813            tx.execute_batch("THIS IS NOT SQL")?;
2814            Ok(MaintenancePrepareReport::rewrote(1))
2815        }
2816        fn rolls_back_then_begins_and_fails(
2817            tx: &Transaction<'_>,
2818        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
2819            tx.execute_batch("UPDATE t1 SET x = 99; ROLLBACK; BEGIN; THIS IS NOT SQL")?;
2820            Ok(MaintenancePrepareReport::rewrote(1))
2821        }
2822
2823        let dir = tempfile::tempdir().expect("tempdir");
2824        let mut conn = temp_conn(&dir);
2825        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("ledgered target");
2826        conn.execute("INSERT INTO t1 (x, y) VALUES (7, 'target')", [])
2827            .expect("target data");
2828
2829        let report =
2830            bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(normalize_target))
2831                .expect("prepare target");
2832        assert_eq!(
2833            report,
2834            MaintenanceBridgeReport {
2835                from_version: 2,
2836                to_version: 2,
2837                prepared: 1,
2838                refused: Vec::new(),
2839            }
2840        );
2841        assert!(!report.migrated());
2842        assert!(report.changed());
2843        assert_eq!(
2844            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2845                .expect("prepared target row"),
2846            8
2847        );
2848        assert_eq!(
2849            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2850            Some(2)
2851        );
2852
2853        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
2854            .expect_err("target prepare failure");
2855        assert!(matches!(
2856            err,
2857            SqliteStoreError::MigrationFailed { ref name, .. }
2858                if name == "maintenance-prepare"
2859        ));
2860        assert_eq!(
2861            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2862                .expect("row after rollback"),
2863            8
2864        );
2865
2866        let err = bridge_unledgered_domain(
2867            &mut conn,
2868            &DOMAIN_V2,
2869            2,
2870            &[1],
2871            Some(rolls_back_then_begins_and_fails),
2872        )
2873        .expect_err("target custody loss");
2874        assert!(matches!(
2875            err,
2876            SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
2877                if name == "maintenance-prepare"
2878        ));
2879        assert_eq!(
2880            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
2881                .expect("row after custody refusal"),
2882            8
2883        );
2884        assert_eq!(
2885            domain_version(&conn, DOMAIN_V2.name).expect("ledger"),
2886            Some(2)
2887        );
2888    }
2889
2890    #[test]
2891    fn maintenance_bridge_refuses_target_that_only_matches_migration_oracle() {
2892        let dir = tempfile::tempdir().expect("tempdir");
2893        let mut conn = temp_conn(&dir);
2894        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2895            .expect("historical unledgered v1");
2896
2897        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2_ALT_INITIALIZER, 2, &[1], None)
2898            .expect_err("ordinary target verifier must reject drift");
2899        assert!(matches!(
2900            err,
2901            SqliteStoreError::SchemaFingerprintMismatch { version: 2, .. }
2902        ));
2903        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2904        let columns = conn
2905            .prepare("PRAGMA table_info(t1)")
2906            .expect("prepare")
2907            .query_map([], |row| row.get::<_, String>(1))
2908            .expect("query")
2909            .collect::<Result<Vec<_>, _>>()
2910            .expect("columns");
2911        assert_eq!(
2912            columns,
2913            vec!["x"],
2914            "target-verifier refusal did not roll back"
2915        );
2916    }
2917
2918    #[test]
2919    fn maintenance_bridge_refuses_catalog_outside_source_allowlist_across_data_only_gap() {
2920        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
2921            Ok(())
2922        }
2923        const DATA_ONLY_GAP: SchemaDomain = SchemaDomain {
2924            name: "data-only-gap-domain",
2925            migrations: &[
2926                Migration {
2927                    version: 1,
2928                    name: "base",
2929                    apply: create_t1,
2930                },
2931                Migration {
2932                    version: 2,
2933                    name: "data-only",
2934                    apply: no_op,
2935                },
2936                Migration {
2937                    version: 3,
2938                    name: "add-y",
2939                    apply: add_column_guarded,
2940                },
2941            ],
2942            initialize_current: initialize_v2,
2943            allowed_existing_versions: &[3],
2944            bridge_recoverable_versions: &[1],
2945            released_predecessors: &[],
2946            owned_objects: &[SchemaObject {
2947                kind: SchemaObjectKind::Table,
2948                name: "t1",
2949            }],
2950            retired_objects: &[],
2951        };
2952
2953        let dir = tempfile::tempdir().expect("tempdir");
2954        let mut conn = temp_conn(&dir);
2955        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
2956            .expect("v1-or-v2 catalog");
2957        let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[3], None)
2958            .expect_err("excluded historical prefixes must not be inferred");
2959        assert!(matches!(
2960            err,
2961            SqliteStoreError::UnledgeredSchemaNoMatch { .. }
2962        ));
2963        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2964        let columns = conn
2965            .prepare("PRAGMA table_info(t1)")
2966            .expect("prepare")
2967            .query_map([], |row| row.get::<_, String>(1))
2968            .expect("query")
2969            .collect::<Result<Vec<_>, _>>()
2970            .expect("columns");
2971        assert_eq!(columns, vec!["x"]);
2972
2973        let err = bridge_unledgered_domain(&mut conn, &DATA_ONLY_GAP, 3, &[2, 1], None)
2974            .expect_err("unordered source authority must be refused");
2975        assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
2976    }
2977
2978    #[test]
2979    fn maintenance_bridge_leaves_fresh_domain_for_normal_initializer() {
2980        let dir = tempfile::tempdir().expect("tempdir");
2981        let mut conn = temp_conn(&dir);
2982        let report =
2983            bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None).expect("fresh no-op");
2984        assert_eq!(
2985            report,
2986            MaintenanceBridgeReport {
2987                from_version: 0,
2988                to_version: 0,
2989                prepared: 0,
2990                refused: Vec::new(),
2991            }
2992        );
2993        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
2994        assert!(
2995            find_owned_objects(&conn, &DOMAIN_V2)
2996                .expect("objects")
2997                .is_empty()
2998        );
2999    }
3000
3001    #[test]
3002    fn maintenance_bridge_refuses_malformed_historical_shape_without_mutation() {
3003        let dir = tempfile::tempdir().expect("tempdir");
3004        let mut conn = temp_conn(&dir);
3005        conn.execute_batch("CREATE TABLE t1 (x INTEGER, candidate_only BLOB)")
3006            .expect("candidate schema");
3007
3008        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3009            .expect_err("refuse unauthenticated shape");
3010        assert!(matches!(
3011            err,
3012            SqliteStoreError::UnledgeredSchemaNoMatch {
3013                target_version: 2,
3014                ..
3015            }
3016        ));
3017        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3018        let columns = conn
3019            .prepare("PRAGMA table_info(t1)")
3020            .expect("prepare")
3021            .query_map([], |row| row.get::<_, String>(1))
3022            .expect("query")
3023            .collect::<Result<Vec<_>, _>>()
3024            .expect("columns");
3025        assert_eq!(columns, vec!["x", "candidate_only"]);
3026    }
3027
3028    #[test]
3029    fn maintenance_bridge_fingerprint_preserves_sql_literal_whitespace() {
3030        fn create_exact(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3031            tx.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a  b'))")
3032        }
3033        const LITERAL_DOMAIN: SchemaDomain = SchemaDomain {
3034            name: "literal-fingerprint-domain",
3035            migrations: &[Migration {
3036                version: 1,
3037                name: "base",
3038                apply: create_exact,
3039            }],
3040            initialize_current: create_exact,
3041            allowed_existing_versions: &[1],
3042            bridge_recoverable_versions: &[1],
3043            released_predecessors: &[],
3044            owned_objects: &[SchemaObject {
3045                kind: SchemaObjectKind::Table,
3046                name: "literal_t",
3047            }],
3048            retired_objects: &[],
3049        };
3050
3051        let dir = tempfile::tempdir().expect("tempdir");
3052        let mut conn = temp_conn(&dir);
3053        conn.execute_batch("CREATE TABLE literal_t (x TEXT CHECK(x <> 'a b'))")
3054            .expect("semantic mismatch");
3055        let err = bridge_unledgered_domain(&mut conn, &LITERAL_DOMAIN, 1, &[1], None)
3056            .expect_err("literal whitespace must remain fingerprint-significant");
3057        assert!(matches!(
3058            err,
3059            SqliteStoreError::UnledgeredSchemaNoMatch { .. }
3060        ));
3061        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3062
3063        assert_eq!(
3064            normalize_schema_sql("CREATE  TABLE IF NOT EXISTS t (x CHECK(x <> 'IF NOT  EXISTS'))"),
3065            "CREATE TABLE t (x CHECK(x <> 'IF NOT  EXISTS'))"
3066        );
3067    }
3068
3069    #[test]
3070    fn maintenance_bridge_rolls_back_prepare_failure() {
3071        fn mutate_then_fail(
3072            tx: &Transaction<'_>,
3073        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3074            tx.execute("UPDATE t1 SET x = 99", [])?;
3075            tx.execute_batch("THIS IS NOT SQL")?;
3076            Ok(MaintenancePrepareReport::rewrote(1))
3077        }
3078
3079        let dir = tempfile::tempdir().expect("tempdir");
3080        let mut conn = temp_conn(&dir);
3081        conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3082            .expect("historical unledgered v1");
3083
3084        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(mutate_then_fail))
3085            .expect_err("prepare must fail");
3086        assert!(matches!(
3087            err,
3088            SqliteStoreError::MigrationFailed { ref name, .. }
3089                if name == "maintenance-prepare"
3090        ));
3091        assert_eq!(
3092            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3093                .expect("original row"),
3094            7
3095        );
3096        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3097        let columns = conn
3098            .prepare("PRAGMA table_info(t1)")
3099            .expect("prepare")
3100            .query_map([], |row| row.get::<_, String>(1))
3101            .expect("query")
3102            .collect::<Result<Vec<_>, _>>()
3103            .expect("columns");
3104        assert_eq!(columns, vec!["x"]);
3105    }
3106
3107    #[test]
3108    fn maintenance_bridge_reports_custody_loss_before_callback_error() {
3109        fn commits_then_fails(
3110            tx: &Transaction<'_>,
3111        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3112            tx.execute_batch("UPDATE t1 SET x = 99; COMMIT; THIS IS NOT SQL")?;
3113            Ok(MaintenancePrepareReport::rewrote(1))
3114        }
3115
3116        let dir = tempfile::tempdir().expect("tempdir");
3117        let mut conn = temp_conn(&dir);
3118        conn.execute_batch("CREATE TABLE t1 (x INTEGER); INSERT INTO t1 VALUES (7)")
3119            .expect("historical unledgered v1");
3120        let err =
3121            bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(commits_then_fails))
3122                .expect_err("custody must dominate callback error");
3123        assert!(matches!(
3124            err,
3125            SqliteStoreError::MigrationBrokeTransaction { ref name, .. }
3126                if name == "maintenance-prepare"
3127        ));
3128        assert_eq!(domain_version(&conn, DOMAIN_V2.name).expect("ledger"), None);
3129    }
3130
3131    #[test]
3132    fn maintenance_bridge_refuses_undeclared_trigger_on_owned_table_before_prepare() {
3133        fn prepare_successor(
3134            tx: &Transaction<'_>,
3135        ) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3136            let changed = tx.execute("UPDATE t1 SET x = 8 WHERE x = 7", [])?;
3137            Ok(MaintenancePrepareReport::rewrote(changed))
3138        }
3139
3140        let dir = tempfile::tempdir().expect("tempdir");
3141        let mut conn = temp_conn(&dir);
3142        conn.execute_batch(
3143            "CREATE TABLE t1 (x INTEGER);
3144             INSERT INTO t1 VALUES (7);
3145             CREATE TRIGGER replace_prepared_successor
3146             AFTER UPDATE ON t1
3147             BEGIN
3148                 UPDATE t1 SET x = 99 WHERE rowid = NEW.rowid;
3149             END",
3150        )
3151        .expect("intercepting trigger");
3152
3153        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], Some(prepare_successor))
3154            .expect_err("undeclared trigger must be refused before prepare");
3155        assert!(matches!(
3156            err,
3157            SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3158        ));
3159        assert_eq!(
3160            conn.query_row("SELECT x FROM t1", [], |row| row.get::<_, i64>(0))
3161                .expect("original row"),
3162            7
3163        );
3164        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3165    }
3166
3167    #[test]
3168    fn maintenance_bridge_refuses_undeclared_instead_of_trigger_on_owned_view_before_prepare() {
3169        fn create_owned_view(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3170            tx.execute_batch(
3171                "CREATE TABLE view_base (x INTEGER);
3172                 CREATE VIEW owned_view AS SELECT x FROM view_base",
3173            )
3174        }
3175        fn prepare_view(tx: &Transaction<'_>) -> Result<MaintenancePrepareReport, rusqlite::Error> {
3176            let changed = tx.execute("UPDATE owned_view SET x = 8 WHERE x = 7", [])?;
3177            Ok(MaintenancePrepareReport::rewrote(changed))
3178        }
3179        const VIEW_DOMAIN: SchemaDomain = SchemaDomain {
3180            name: "view-bridge-domain",
3181            migrations: &[Migration {
3182                version: 1,
3183                name: "base",
3184                apply: create_owned_view,
3185            }],
3186            initialize_current: create_owned_view,
3187            allowed_existing_versions: &[1],
3188            bridge_recoverable_versions: &[1],
3189            released_predecessors: &[],
3190            owned_objects: &[
3191                SchemaObject {
3192                    kind: SchemaObjectKind::Table,
3193                    name: "view_base",
3194                },
3195                SchemaObject {
3196                    kind: SchemaObjectKind::View,
3197                    name: "owned_view",
3198                },
3199            ],
3200            retired_objects: &[],
3201        };
3202
3203        let dir = tempfile::tempdir().expect("tempdir");
3204        let mut conn = temp_conn(&dir);
3205        conn.execute_batch(
3206            "CREATE TABLE view_base (x INTEGER);
3207             CREATE VIEW owned_view AS SELECT x FROM view_base;
3208             INSERT INTO view_base VALUES (7);
3209             CREATE TRIGGER replace_view_update
3210             INSTEAD OF UPDATE ON owned_view
3211             BEGIN
3212                 UPDATE view_base SET x = 99 WHERE x = OLD.x;
3213             END",
3214        )
3215        .expect("intercepting view trigger");
3216
3217        let err = bridge_unledgered_domain(&mut conn, &VIEW_DOMAIN, 1, &[1], Some(prepare_view))
3218            .expect_err("undeclared view trigger must be refused before prepare");
3219        assert!(matches!(
3220            err,
3221            SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
3222        ));
3223        assert_eq!(
3224            conn.query_row("SELECT x FROM view_base", [], |row| row.get::<_, i64>(0))
3225                .expect("original row"),
3226            7
3227        );
3228        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3229    }
3230
3231    #[test]
3232    fn maintenance_bridge_refuses_ambiguous_prefix_without_mutation() {
3233        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3234            Ok(())
3235        }
3236        const AMBIGUOUS: SchemaDomain = SchemaDomain {
3237            name: "ambiguous-bridge-domain",
3238            migrations: &[
3239                Migration {
3240                    version: 1,
3241                    name: "base",
3242                    apply: create_t1,
3243                },
3244                Migration {
3245                    version: 2,
3246                    name: "data-only",
3247                    apply: no_op,
3248                },
3249            ],
3250            initialize_current: create_t1,
3251            allowed_existing_versions: &[2],
3252            bridge_recoverable_versions: &[1],
3253            released_predecessors: &[],
3254            owned_objects: &[SchemaObject {
3255                kind: SchemaObjectKind::Table,
3256                name: "t1",
3257            }],
3258            retired_objects: &[],
3259        };
3260
3261        let dir = tempfile::tempdir().expect("tempdir");
3262        let mut conn = temp_conn(&dir);
3263        conn.execute_batch("CREATE TABLE t1 (x INTEGER)")
3264            .expect("ambiguous schema");
3265        let err = bridge_unledgered_domain(&mut conn, &AMBIGUOUS, 2, &[1, 2], None)
3266            .expect_err("refuse ambiguity");
3267        assert!(matches!(
3268            err,
3269            SqliteStoreError::UnledgeredSchemaAmbiguous { ref matches, .. }
3270                if matches == &[1, 2]
3271        ));
3272        assert!(!ledger_table_exists(&conn).expect("ledger presence"));
3273    }
3274
3275    #[test]
3276    fn maintenance_bridge_refuses_malformed_ledger_before_owned_schema_contact() {
3277        let dir = tempfile::tempdir().expect("tempdir");
3278        let mut conn = temp_conn(&dir);
3279        conn.execute_batch(
3280            "CREATE TABLE t1 (x INTEGER);
3281             CREATE TABLE meerkat_schema (domain TEXT, version INTEGER)",
3282        )
3283        .expect("malformed ledger");
3284        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3285            .expect_err("refuse malformed ledger");
3286        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3287        let columns = conn
3288            .prepare("PRAGMA table_info(t1)")
3289            .expect("prepare")
3290            .query_map([], |row| row.get::<_, String>(1))
3291            .expect("query")
3292            .collect::<Result<Vec<_>, _>>()
3293            .expect("columns");
3294        assert_eq!(columns, vec!["x"]);
3295    }
3296
3297    #[test]
3298    fn maintenance_bridge_refuses_mixed_case_trigger_that_mutates_foreign_ledger_row() {
3299        let dir = tempfile::tempdir().expect("tempdir");
3300        let mut conn = temp_conn(&dir);
3301        conn.execute_batch(
3302            "CREATE TABLE t1 (x INTEGER);
3303             CREATE TABLE meerkat_schema (
3304                 domain TEXT PRIMARY KEY,
3305                 version INTEGER NOT NULL
3306             );
3307             INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7);
3308             CREATE TRIGGER mutate_foreign_schema_row
3309             AFTER INSERT ON MEERKAT_SCHEMA
3310             BEGIN
3311                 UPDATE MEERKAT_SCHEMA
3312                 SET version = 999
3313                 WHERE domain = 'foreign-domain';
3314             END",
3315        )
3316        .expect("hostile ledger trigger");
3317
3318        let err = bridge_unledgered_domain(&mut conn, &DOMAIN_V2, 2, &[1], None)
3319            .expect_err("mixed-case ledger trigger must be refused");
3320        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3321        let row_count = conn
3322            .query_row(
3323                "SELECT COUNT(*) FROM main.meerkat_schema WHERE domain = ?1",
3324                [DOMAIN_V2.name],
3325                |row| row.get::<_, i64>(0),
3326            )
3327            .expect("raw ledger count");
3328        assert_eq!(row_count, 0);
3329        let foreign_version = conn
3330            .query_row(
3331                "SELECT version FROM main.meerkat_schema WHERE domain = 'foreign-domain'",
3332                [],
3333                |row| row.get::<_, i64>(0),
3334            )
3335            .expect("foreign ledger row");
3336        assert_eq!(foreign_version, 7);
3337        let columns = conn
3338            .prepare("PRAGMA table_info(t1)")
3339            .expect("prepare")
3340            .query_map([], |row| row.get::<_, String>(1))
3341            .expect("query")
3342            .collect::<Result<Vec<_>, _>>()
3343            .expect("columns");
3344        assert_eq!(
3345            columns,
3346            vec!["x"],
3347            "failed stamp did not roll back migration"
3348        );
3349    }
3350
3351    #[test]
3352    fn fresh_domain_ignores_foreign_cotenant_objects() {
3353        let dir = tempfile::tempdir().expect("tempdir");
3354        let mut conn = temp_conn(&dir);
3355        conn.execute_batch("CREATE TABLE foreign_table (value TEXT)")
3356            .expect("foreign ddl");
3357        let report = apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("fresh domain");
3358        assert_eq!(
3359            report,
3360            LedgerReport {
3361                from_version: 0,
3362                to_version: 2
3363            }
3364        );
3365        conn.execute("INSERT INTO foreign_table VALUES ('kept')", [])
3366            .expect("foreign object survives");
3367    }
3368
3369    #[test]
3370    fn future_version_is_refused_before_any_mutation() {
3371        let dir = tempfile::tempdir().expect("tempdir");
3372        let mut conn = temp_conn(&dir);
3373        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3374        // An older binary knows only v1.
3375        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse");
3376        match err {
3377            SqliteStoreError::SchemaFromTheFuture {
3378                domain,
3379                found,
3380                supported,
3381            } => {
3382                assert_eq!(domain, "test-domain");
3383                assert_eq!(found, 2);
3384                assert_eq!(supported, 1);
3385            }
3386            other => panic!("wrong error: {other}"),
3387        }
3388        // Nothing moved.
3389        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3390    }
3391
3392    #[test]
3393    fn foreign_domain_rows_are_untouched() {
3394        let dir = tempfile::tempdir().expect("tempdir");
3395        let mut conn = temp_conn(&dir);
3396        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("mine");
3397        conn.execute(
3398            "INSERT INTO meerkat_schema (domain, version) VALUES ('foreign-domain', 7)",
3399            [],
3400        )
3401        .expect("foreign row");
3402        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("noop");
3403        let foreign: i64 = conn
3404            .query_row(
3405                "SELECT version FROM meerkat_schema WHERE domain = 'foreign-domain'",
3406                [],
3407                |r| r.get(0),
3408            )
3409            .expect("foreign row survives");
3410        assert_eq!(foreign, 7);
3411    }
3412
3413    #[test]
3414    fn invalid_migration_list_is_refused_without_touching_the_file() {
3415        const BAD: SchemaDomain = SchemaDomain {
3416            name: "bad-domain",
3417            migrations: &[Migration {
3418                version: 3,
3419                name: "gap",
3420                apply: create_t1,
3421            }],
3422            initialize_current: create_t1,
3423            allowed_existing_versions: &[3],
3424            bridge_recoverable_versions: &[1],
3425            released_predecessors: &[],
3426            owned_objects: &[],
3427            retired_objects: &[],
3428        };
3429        let dir = tempfile::tempdir().expect("tempdir");
3430        let mut conn = temp_conn(&dir);
3431        let err = apply_domain_migrations(&mut conn, &BAD).expect_err("refuse");
3432        assert!(matches!(err, SqliteStoreError::InvalidMigrationList { .. }));
3433        assert!(!ledger_table_exists(&conn).expect("check"));
3434    }
3435
3436    #[test]
3437    fn failed_migration_rolls_back_atomically() {
3438        fn fail(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3439            tx.execute_batch("CREATE TABLE half_done (x INTEGER)")?;
3440            tx.execute_batch("THIS IS NOT SQL")
3441        }
3442        fn initialize_failing(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3443            create_t1(tx)?;
3444            fail(tx)
3445        }
3446        const FAILING: SchemaDomain = SchemaDomain {
3447            name: "failing-domain",
3448            migrations: &[
3449                Migration {
3450                    version: 1,
3451                    name: "base",
3452                    apply: create_t1,
3453                },
3454                Migration {
3455                    version: 2,
3456                    name: "explodes",
3457                    apply: fail,
3458                },
3459            ],
3460            initialize_current: initialize_failing,
3461            allowed_existing_versions: &[1, 2],
3462            bridge_recoverable_versions: &[1],
3463            released_predecessors: &[SchemaPredecessor {
3464                version: 1,
3465                verify: verify_v1,
3466            }],
3467            owned_objects: &[
3468                SchemaObject {
3469                    kind: SchemaObjectKind::Table,
3470                    name: "t1",
3471                },
3472                SchemaObject {
3473                    kind: SchemaObjectKind::Table,
3474                    name: "half_done",
3475                },
3476            ],
3477            retired_objects: &[],
3478        };
3479        let dir = tempfile::tempdir().expect("tempdir");
3480        let mut conn = temp_conn(&dir);
3481        let err = apply_domain_migrations(&mut conn, &FAILING).expect_err("must fail");
3482        assert!(matches!(
3483            err,
3484            SqliteStoreError::MigrationFailed { version: 2, .. }
3485        ));
3486        // Atomic: neither the v1 table, the half-done table, nor a ledger row
3487        // survives.
3488        assert_eq!(domain_version(&conn, "failing-domain").expect("read"), None);
3489        let tables: Vec<String> = conn
3490            .prepare(
3491                "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('t1','half_done')",
3492            )
3493            .expect("prepare")
3494            .query_map([], |r| r.get(0))
3495            .expect("query")
3496            .collect::<Result<_, _>>()
3497            .expect("rows");
3498        assert!(tables.is_empty(), "rollback left tables behind: {tables:?}");
3499    }
3500
3501    #[test]
3502    fn malformed_ledger_shape_is_refused_not_healed() {
3503        let dir = tempfile::tempdir().expect("tempdir");
3504        let mut conn = temp_conn(&dir);
3505        // A foreign table wearing the ledger's name.
3506        conn.execute_batch("CREATE TABLE meerkat_schema (x INTEGER)")
3507            .expect("foreign ddl");
3508        let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3509        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3510        let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3511        assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3512        // Refused, not healed: the foreign table is untouched and unstamped.
3513        let count: i64 = conn
3514            .query_row("SELECT COUNT(*) FROM meerkat_schema", [], |r| r.get(0))
3515            .expect("foreign table survives");
3516        assert_eq!(count, 0);
3517    }
3518
3519    #[test]
3520    fn non_positive_versions_are_refused_not_healed() {
3521        for bad_version in [0i64, -3] {
3522            let dir = tempfile::tempdir().expect("tempdir");
3523            let mut conn = temp_conn(&dir);
3524            conn.execute_batch(
3525                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL)",
3526            )
3527            .expect("ledger ddl");
3528            conn.execute(
3529                "INSERT INTO meerkat_schema (domain, version) VALUES ('test-domain', ?1)",
3530                [bad_version],
3531            )
3532            .expect("seed bad version");
3533            let err = domain_version(&conn, "test-domain").expect_err("refuse read");
3534            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3535            let err = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect_err("refuse migrate");
3536            assert!(matches!(err, SqliteStoreError::LedgerMalformed { .. }));
3537            // The bad row must survive untouched for forensics.
3538            let stored: i64 = conn
3539                .query_row(
3540                    "SELECT version FROM meerkat_schema WHERE domain = 'test-domain'",
3541                    [],
3542                    |r| r.get(0),
3543                )
3544                .expect("row survives");
3545            assert_eq!(stored, bad_version);
3546        }
3547    }
3548
3549    #[test]
3550    fn duplicate_domain_rows_are_refused() {
3551        let dir = tempfile::tempdir().expect("tempdir");
3552        let conn = temp_conn(&dir);
3553        // No primary key: shape validation would already refuse this table;
3554        // the row-cardinality guard is exercised directly as defense in
3555        // depth.
3556        conn.execute_batch(
3557            "CREATE TABLE meerkat_schema (domain TEXT, version INTEGER NOT NULL);
3558             INSERT INTO meerkat_schema VALUES ('dup-domain', 1);
3559             INSERT INTO meerkat_schema VALUES ('dup-domain', 2);",
3560        )
3561        .expect("seed duplicates");
3562        let err = read_version(&conn, "dup-domain").expect_err("refuse duplicates");
3563        match err {
3564            SqliteStoreError::LedgerMalformed { detail } => {
3565                assert!(detail.contains("multiple ledger rows"), "{detail}");
3566            }
3567            other => panic!("wrong error: {other}"),
3568        }
3569    }
3570
3571    #[test]
3572    fn temp_shadowing_cannot_hijack_the_ledger() {
3573        let dir = tempfile::tempdir().expect("tempdir");
3574        let mut conn = temp_conn(&dir);
3575        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("stamp v1");
3576        // A TEMP shadow claiming a future version: unqualified reads would
3577        // see 999 and refuse; the main-qualified ledger keeps reading truth.
3578        conn.execute_batch(
3579            "CREATE TEMP TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
3580             INSERT INTO temp.meerkat_schema VALUES ('test-domain', 999);",
3581        )
3582        .expect("temp shadow");
3583        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(1));
3584        let report = apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("noop against main");
3585        assert!(!report.migrated());
3586    }
3587
3588    #[test]
3589    fn migration_that_ends_the_transaction_is_refused_unstamped() {
3590        fn no_op(_tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3591            Ok(())
3592        }
3593        fn verify_empty_predecessor(_conn: &Connection) -> Result<(), String> {
3594            Ok(())
3595        }
3596        fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3597            tx.execute_batch("CREATE TABLE escaped_commit (x INTEGER); COMMIT")
3598        }
3599        fn rolls_back_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3600            tx.execute_batch("ROLLBACK")
3601        }
3602        // The re-BEGIN variants leave autocommit false at the custody check:
3603        // only the savepoint detects that the runner's transaction is gone
3604        // and the ledger stamp would land in a foreign one.
3605        fn commits_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3606            tx.execute_batch("CREATE TABLE escaped_commit_begin (x INTEGER); COMMIT; BEGIN")
3607        }
3608        fn rolls_back_then_begins(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3609            tx.execute_batch("ROLLBACK; BEGIN")
3610        }
3611        const COMMITS: SchemaDomain = SchemaDomain {
3612            name: "custody-commit",
3613            migrations: &[
3614                Migration {
3615                    version: 1,
3616                    name: "base",
3617                    apply: no_op,
3618                },
3619                Migration {
3620                    version: 2,
3621                    name: "commits-underneath",
3622                    apply: commits_underneath,
3623                },
3624            ],
3625            initialize_current: no_op,
3626            allowed_existing_versions: &[1, 2],
3627            bridge_recoverable_versions: &[1],
3628            released_predecessors: &[SchemaPredecessor {
3629                version: 1,
3630                verify: verify_empty_predecessor,
3631            }],
3632            owned_objects: &[],
3633            retired_objects: &[],
3634        };
3635        const ROLLS_BACK: SchemaDomain = SchemaDomain {
3636            name: "custody-rollback",
3637            migrations: &[
3638                Migration {
3639                    version: 1,
3640                    name: "base",
3641                    apply: no_op,
3642                },
3643                Migration {
3644                    version: 2,
3645                    name: "rolls-back-underneath",
3646                    apply: rolls_back_underneath,
3647                },
3648            ],
3649            initialize_current: no_op,
3650            allowed_existing_versions: &[1, 2],
3651            bridge_recoverable_versions: &[1],
3652            released_predecessors: &[SchemaPredecessor {
3653                version: 1,
3654                verify: verify_empty_predecessor,
3655            }],
3656            owned_objects: &[],
3657            retired_objects: &[],
3658        };
3659        const COMMITS_THEN_BEGINS: SchemaDomain = SchemaDomain {
3660            name: "custody-commit-begin",
3661            migrations: &[
3662                Migration {
3663                    version: 1,
3664                    name: "base",
3665                    apply: no_op,
3666                },
3667                Migration {
3668                    version: 2,
3669                    name: "commits-then-begins",
3670                    apply: commits_then_begins,
3671                },
3672            ],
3673            initialize_current: no_op,
3674            allowed_existing_versions: &[1, 2],
3675            bridge_recoverable_versions: &[1],
3676            released_predecessors: &[SchemaPredecessor {
3677                version: 1,
3678                verify: verify_empty_predecessor,
3679            }],
3680            owned_objects: &[],
3681            retired_objects: &[],
3682        };
3683        const ROLLS_BACK_THEN_BEGINS: SchemaDomain = SchemaDomain {
3684            name: "custody-rollback-begin",
3685            migrations: &[
3686                Migration {
3687                    version: 1,
3688                    name: "base",
3689                    apply: no_op,
3690                },
3691                Migration {
3692                    version: 2,
3693                    name: "rolls-back-then-begins",
3694                    apply: rolls_back_then_begins,
3695                },
3696            ],
3697            initialize_current: no_op,
3698            allowed_existing_versions: &[1, 2],
3699            bridge_recoverable_versions: &[1],
3700            released_predecessors: &[SchemaPredecessor {
3701                version: 1,
3702                verify: verify_empty_predecessor,
3703            }],
3704            owned_objects: &[],
3705            retired_objects: &[],
3706        };
3707        for (domain, expected_name) in [
3708            (&COMMITS, "commits-underneath"),
3709            (&ROLLS_BACK, "rolls-back-underneath"),
3710            (&COMMITS_THEN_BEGINS, "commits-then-begins"),
3711            (&ROLLS_BACK_THEN_BEGINS, "rolls-back-then-begins"),
3712        ] {
3713            let dir = tempfile::tempdir().expect("tempdir");
3714            let mut conn = temp_conn(&dir);
3715            conn.execute_batch(CREATE_LEDGER_SQL).expect("ledger");
3716            conn.execute(
3717                "INSERT INTO main.meerkat_schema (domain, version) VALUES (?1, 1)",
3718                [domain.name],
3719            )
3720            .expect("released predecessor");
3721            let err = apply_domain_migrations(&mut conn, domain).expect_err("custody violation");
3722            match err {
3723                SqliteStoreError::MigrationBrokeTransaction {
3724                    domain: err_domain,
3725                    version,
3726                    name,
3727                } => {
3728                    assert_eq!(err_domain, domain.name);
3729                    assert_eq!(version, 2);
3730                    assert_eq!(name, expected_name);
3731                }
3732                other => panic!("wrong error: {other}"),
3733            }
3734            // The new stamp never landed: custody broke before the ledger
3735            // update, so the authenticated predecessor remains authoritative.
3736            assert_eq!(domain_version(&conn, domain.name).expect("read"), Some(1));
3737        }
3738    }
3739
3740    #[test]
3741    fn initializer_that_ends_the_transaction_is_refused_unstamped() {
3742        fn commits_underneath(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3743            tx.execute_batch("CREATE TABLE escaped_initializer (x INTEGER); COMMIT")
3744        }
3745        const COMMITS: SchemaDomain = SchemaDomain {
3746            name: "initializer-custody-commit",
3747            migrations: &[Migration {
3748                version: 1,
3749                name: "base",
3750                apply: commits_underneath,
3751            }],
3752            initialize_current: commits_underneath,
3753            allowed_existing_versions: &[1],
3754            bridge_recoverable_versions: &[1],
3755            released_predecessors: &[],
3756            owned_objects: &[SchemaObject {
3757                kind: SchemaObjectKind::Table,
3758                name: "escaped_initializer",
3759            }],
3760            retired_objects: &[],
3761        };
3762        let dir = tempfile::tempdir().expect("tempdir");
3763        let mut conn = temp_conn(&dir);
3764        let err = apply_domain_migrations(&mut conn, &COMMITS).expect_err("custody violation");
3765        match err {
3766            SqliteStoreError::MigrationBrokeTransaction {
3767                domain,
3768                version,
3769                name,
3770            } => {
3771                assert_eq!(domain, COMMITS.name);
3772                assert_eq!(version, 1);
3773                assert_eq!(name, "initialize-current");
3774            }
3775            other => panic!("wrong error: {other}"),
3776        }
3777        assert_eq!(domain_version(&conn, COMMITS.name).expect("read"), None);
3778    }
3779
3780    #[test]
3781    fn initializer_using_its_own_savepoints_keeps_custody() {
3782        // A body may nest its own savepoints; custody only trips when the
3783        // runner's enclosing transaction (and with it the custody savepoint)
3784        // is gone.
3785        fn nests_savepoints(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3786            tx.execute_batch(
3787                "SAVEPOINT body_sp;
3788                 CREATE TABLE sp_t (x INTEGER);
3789                 RELEASE SAVEPOINT body_sp",
3790            )
3791        }
3792        const NESTED: SchemaDomain = SchemaDomain {
3793            name: "custody-nested-savepoint",
3794            migrations: &[Migration {
3795                version: 1,
3796                name: "nests-savepoints",
3797                apply: nests_savepoints,
3798            }],
3799            initialize_current: nests_savepoints,
3800            allowed_existing_versions: &[1],
3801            bridge_recoverable_versions: &[1],
3802            released_predecessors: &[],
3803            owned_objects: &[SchemaObject {
3804                kind: SchemaObjectKind::Table,
3805                name: "sp_t",
3806            }],
3807            retired_objects: &[],
3808        };
3809        let dir = tempfile::tempdir().expect("tempdir");
3810        let mut conn = temp_conn(&dir);
3811        let report = apply_domain_migrations(&mut conn, &NESTED).expect("apply");
3812        assert_eq!(report.to_version, 1);
3813        assert_eq!(domain_version(&conn, NESTED.name).expect("read"), Some(1));
3814    }
3815
3816    #[test]
3817    fn schema_preflight_passes_fresh_and_current_refuses_future() {
3818        let dir = tempfile::tempdir().expect("tempdir");
3819        let mut conn = temp_conn(&dir);
3820        preflight_schema_eligibility(&conn, &DOMAIN_V1).expect("no ledger yet");
3821        apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("stamp v2");
3822        preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("current");
3823        let err =
3824            preflight_schema_eligibility(&conn, &DOMAIN_V1).expect_err("future for old binary");
3825        assert!(matches!(
3826            err,
3827            SqliteStoreError::SchemaFromTheFuture {
3828                found: 2,
3829                supported: 1,
3830                ..
3831            }
3832        ));
3833    }
3834
3835    #[test]
3836    fn current_schema_verification_does_not_take_the_wal_writer() {
3837        let dir = tempfile::tempdir().expect("tempdir");
3838        let path = dir.path().join("db.sqlite3");
3839        let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
3840        apply_domain_migrations(&mut writer, &DOMAIN_V2).expect("initialize");
3841        let mut reader = open(&path, ConnectionProfile::PRIMARY).expect("reader");
3842        // Zero is deliberate only in this test: any attempted writer
3843        // reservation reports its precise SQLite code instead of waiting.
3844        reader
3845            .busy_timeout(std::time::Duration::ZERO)
3846            .expect("busy policy");
3847        let tx = writer
3848            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
3849            .expect("reserve unrelated writer");
3850        let report = apply_domain_migrations(&mut reader, &DOMAIN_V2)
3851            .expect("current schema verification must be a read-only snapshot");
3852        assert!(!report.migrated());
3853        assert!(reader.is_autocommit());
3854        drop(tx);
3855    }
3856
3857    #[test]
3858    fn concurrent_opens_race_safely() {
3859        let dir = tempfile::tempdir().expect("tempdir");
3860        let path = dir.path().join("db.sqlite3");
3861        let mut handles = Vec::new();
3862        for _ in 0..8 {
3863            let path = path.clone();
3864            handles.push(std::thread::spawn(move || {
3865                let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3866                apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("apply")
3867            }));
3868        }
3869        let mut migrated = 0;
3870        for handle in handles {
3871            let report = handle.join().expect("thread");
3872            assert_eq!(report.to_version, 2);
3873            if report.migrated() {
3874                migrated += 1;
3875            }
3876        }
3877        assert_eq!(migrated, 1, "only the winning writer may initialize");
3878        let conn = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
3879        assert_eq!(domain_version(&conn, "test-domain").expect("read"), Some(2));
3880    }
3881
3882    #[test]
3883    fn concurrent_predecessor_upgrade_rechecks_after_read_snapshot() {
3884        let dir = tempfile::tempdir().expect("tempdir");
3885        let path = dir.path().join("db.sqlite3");
3886        let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("seed");
3887        apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("version 1");
3888        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
3889        let handles = (0..8)
3890            .map(|_| {
3891                let path = path.clone();
3892                let barrier = barrier.clone();
3893                std::thread::spawn(move || {
3894                    let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
3895                    barrier.wait();
3896                    apply_domain_migrations(&mut conn, &DOMAIN_V2).expect("upgrade")
3897                })
3898            })
3899            .collect::<Vec<_>>();
3900        let reports = handles
3901            .into_iter()
3902            .map(|thread| thread.join().expect("thread"))
3903            .collect::<Vec<_>>();
3904        assert_eq!(reports.iter().filter(|report| report.migrated()).count(), 1);
3905        assert!(reports.iter().all(|report| report.to_version == 2));
3906        preflight_schema_eligibility(&conn, &DOMAIN_V2).expect("verified upgraded catalog");
3907    }
3908
3909    /// A schema preflight judges the ledger and the catalog from one
3910    /// snapshot, so a domain another connection creates meanwhile is seen
3911    /// whole (its row and its objects) or not at all. The probe commits that
3912    /// create between the preflight's ledger read and the reads after it.
3913    mod preflight_snapshot {
3914        use std::cell::{Cell, RefCell};
3915        use std::path::Path;
3916        use std::rc::Rc;
3917        use std::sync::mpsc;
3918        use std::thread::JoinHandle;
3919        use std::time::Duration;
3920
3921        use super::*;
3922        use crate::profile::{OpenOptions, open_with};
3923
3924        /// A co-tenant domain. Stamping it creates the ledger table, as a
3925        /// runtime store's own domain does before its lazily created
3926        /// delivery domain.
3927        const FOREIGN_DOMAIN: SchemaDomain = SchemaDomain {
3928            name: "foreign-domain",
3929            migrations: &[Migration {
3930                version: 1,
3931                name: "base",
3932                apply: create_foreign,
3933            }],
3934            initialize_current: create_foreign,
3935            allowed_existing_versions: &[1],
3936            bridge_recoverable_versions: &[],
3937            released_predecessors: &[],
3938            owned_objects: &[SchemaObject {
3939                kind: SchemaObjectKind::Table,
3940                name: "foreign_t",
3941            }],
3942            retired_objects: &[],
3943        };
3944
3945        fn create_foreign(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
3946            tx.execute_batch("CREATE TABLE IF NOT EXISTS foreign_t (x INTEGER)")
3947        }
3948
3949        type PendingWriter = (JoinHandle<Result<LedgerReport, SqliteStoreError>>, bool);
3950
3951        fn open_preflighted(
3952            path: &Path,
3953            profile: ConnectionProfile,
3954        ) -> Result<Connection, SqliteStoreError> {
3955            open_with(
3956                path,
3957                profile,
3958                OpenOptions {
3959                    schema_preflight: &[&DOMAIN_V1],
3960                    ..OpenOptions::default()
3961                },
3962            )
3963        }
3964
3965        /// Arm the probe to create `DOMAIN_V1` through a second connection
3966        /// and commit it before the probe returns. Answers whether it ran.
3967        fn arm_concurrent_create(path: &Path) -> Rc<Cell<bool>> {
3968            let ran = Rc::new(Cell::new(false));
3969            let seen = Rc::clone(&ran);
3970            let path = path.to_path_buf();
3971            arm_preflight_probe_for_test(move || {
3972                let mut writer =
3973                    open(&path, ConnectionProfile::PRIMARY).expect("concurrent writer");
3974                let report = apply_domain_migrations(&mut writer, &DOMAIN_V1)
3975                    .expect("the concurrent create commits");
3976                assert!(
3977                    report.migrated(),
3978                    "the concurrent writer creates the domain"
3979                );
3980                seen.set(true);
3981            });
3982            ran
3983        }
3984
3985        #[test]
3986        fn a_domain_created_between_the_ledger_and_catalog_reads_is_not_half_seen() {
3987            let dir = tempfile::tempdir().expect("tempdir");
3988            let path = dir.path().join("db.sqlite3");
3989            let mut seed = open(&path, ConnectionProfile::PRIMARY).expect("WAL file");
3990            apply_domain_migrations(&mut seed, &FOREIGN_DOMAIN).expect("co-tenant stamped");
3991            drop(seed);
3992            let ran = arm_concurrent_create(&path);
3993
3994            let conn = open_preflighted(&path, ConnectionProfile::Primary { create: false })
3995                .expect("the preflight sees the concurrent domain whole or not at all");
3996
3997            assert!(ran.get(), "the probe committed inside the preflight");
3998            assert_eq!(
3999                domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4000                Some(1),
4001                "the concurrent create is visible once the preflight's snapshot ends"
4002            );
4003        }
4004
4005        #[test]
4006        fn a_domain_created_before_any_ledger_table_exists_is_not_half_seen() {
4007            let dir = tempfile::tempdir().expect("tempdir");
4008            let path = dir.path().join("db.sqlite3");
4009            drop(open(&path, ConnectionProfile::PRIMARY).expect("empty WAL file"));
4010            let ran = arm_concurrent_create(&path);
4011
4012            let conn = open_preflighted(&path, ConnectionProfile::Primary { create: false })
4013                .expect("the preflight sees the concurrent domain whole or not at all");
4014
4015            assert!(ran.get(), "the probe committed inside the preflight");
4016            assert_eq!(
4017                domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4018                Some(1),
4019                "the concurrent create is visible once the preflight's snapshot ends"
4020            );
4021        }
4022
4023        #[test]
4024        fn a_rollback_journal_preflight_holds_a_concurrent_commit_until_it_ends() {
4025            let dir = tempfile::tempdir().expect("tempdir");
4026            let path = dir.path().join("db.sqlite3");
4027            let mut seed = Connection::open(&path).expect("rollback-journal file");
4028            apply_domain_migrations(&mut seed, &FOREIGN_DOMAIN).expect("co-tenant stamped");
4029            let mode: String = seed
4030                .query_row("PRAGMA journal_mode", [], |row| row.get(0))
4031                .expect("journal mode");
4032            assert_eq!(mode, "delete");
4033            drop(seed);
4034            let pending: Rc<RefCell<Option<PendingWriter>>> = Rc::default();
4035            let slot = Rc::clone(&pending);
4036            let writer_path = path.clone();
4037            arm_preflight_probe_for_test(move || {
4038                let (done, finished) = mpsc::channel();
4039                // Not joined here: while the preflight holds its SHARED lock
4040                // the writer's commit waits, and joining would deadlock.
4041                let writer = std::thread::spawn(move || {
4042                    let mut writer = Connection::open(&writer_path)?;
4043                    writer.busy_timeout(Duration::from_secs(30))?;
4044                    let report = apply_domain_migrations(&mut writer, &DOMAIN_V1);
4045                    let _ = done.send(());
4046                    report
4047                });
4048                let committed_inside = finished.recv_timeout(Duration::from_millis(1500)).is_ok();
4049                *slot.borrow_mut() = Some((writer, committed_inside));
4050            });
4051
4052            let conn = open_preflighted(&path, ConnectionProfile::ReadOnly)
4053                .expect("the preflight sees the concurrent domain whole or not at all");
4054
4055            let (writer, committed_inside) = pending
4056                .borrow_mut()
4057                .take()
4058                .expect("the probe ran inside the preflight");
4059            assert!(
4060                !committed_inside,
4061                "the writer committed while the preflight was between its reads"
4062            );
4063            let report = writer
4064                .join()
4065                .expect("writer thread")
4066                .expect("the writer commits once the preflight has ended");
4067            assert!(report.migrated());
4068            drop(conn);
4069            let reader = Connection::open(&path).expect("reopen");
4070            assert_eq!(
4071                domain_version(&reader, DOMAIN_V1.name).expect("ledger"),
4072                Some(1)
4073            );
4074        }
4075
4076        /// The online writer's check reads the ledger version and the catalog
4077        /// it compares in one snapshot: an upgrade that a newer binary
4078        /// commits between those reads is never judged as a damaged current
4079        /// schema.
4080        #[test]
4081        fn an_online_check_never_judges_a_version_against_a_later_catalog() {
4082            let dir = tempfile::tempdir().expect("tempdir");
4083            let path = dir.path().join("db.sqlite3");
4084            let mut seed = open(&path, ConnectionProfile::PRIMARY).expect("WAL file");
4085            apply_domain_migrations(&mut seed, &DOMAIN_V1).expect("current domain");
4086            drop(seed);
4087            // An ordinary open prepares the expected catalog that the online
4088            // check compares against.
4089            drop(
4090                open_preflighted(&path, ConnectionProfile::Primary { create: false })
4091                    .expect("ordinary open"),
4092            );
4093            let ran = Rc::new(Cell::new(false));
4094            let seen = Rc::clone(&ran);
4095            let upgrade_path = path.clone();
4096            arm_preflight_probe_for_test(move || {
4097                let mut writer =
4098                    open(&upgrade_path, ConnectionProfile::PRIMARY).expect("concurrent writer");
4099                let report = apply_domain_migrations(&mut writer, &DOMAIN_V2)
4100                    .expect("a newer binary upgrades the domain");
4101                assert!(report.migrated(), "the concurrent writer upgrades");
4102                seen.set(true);
4103            });
4104
4105            let conn = open_preflighted(&path, ConnectionProfile::OnlineExistingWriter)
4106                .expect("the online check sees one version with its own catalog");
4107
4108            assert!(ran.get(), "the probe committed inside the online check");
4109            assert_eq!(
4110                domain_version(&conn, DOMAIN_V1.name).expect("ledger"),
4111                Some(2),
4112                "the upgrade is visible once the check's snapshot ends"
4113            );
4114        }
4115
4116        #[test]
4117        fn a_preflight_inside_a_callers_transaction_reads_that_transaction() {
4118            let dir = tempfile::tempdir().expect("tempdir");
4119            let path = dir.path().join("db.sqlite3");
4120            let mut conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
4121            apply_domain_migrations(&mut conn, &DOMAIN_V1).expect("current domain");
4122            let tx = conn.transaction().expect("the caller's transaction");
4123
4124            assert_eq!(
4125                preflight_domain_version(&tx, &DOMAIN_V1).expect("checked in that transaction"),
4126                Some(1)
4127            );
4128            preflight_schema_eligibility(&tx, &DOMAIN_V1).expect("checked again in it");
4129            assert!(
4130                !tx.is_autocommit(),
4131                "the caller's transaction is still open"
4132            );
4133            tx.commit().expect("the caller ends its own transaction");
4134        }
4135    }
4136}