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