Skip to main content

meerkat_sqlite/
profile.rs

1//! DDL-free connection opening under named policy profiles.
2//!
3//! Every profile maps to a policy that already existed somewhere in the
4//! workspace; the profiles name those policies instead of erasing them:
5//!
6//! - [`ConnectionProfile::Primary`]: the production writer policy
7//!   (WAL + `synchronous=FULL` + the one shared busy timeout). With
8//!   `create: false` it is the no-create writer variant previously
9//!   hand-rolled for identity databases. WAL establishment is verified
10//!   against the mode SQLite reports back, and the journal-mode conversion
11//!   only runs after the [`OpenOptions::schema_preflight`] eligibility check,
12//!   so an ineligible file is refused before mutation.
13//! - [`ConnectionProfile::ReadOnly`]: passive observation
14//!   (`SQLITE_OPEN_READ_ONLY | URI | NO_MUTEX`, shared busy timeout, no
15//!   pragma mutation — a reader must not convert a foreign file's journal
16//!   mode). The precise no-write guarantee is typed as
17//!   [`WriteContact::ReadOnlyWalSidecars`]: logical content is never
18//!   altered, but WAL sidecars may be required to read a WAL database.
19//! - [`ConnectionProfile::Maintenance`]: fail-fast offline access (zero busy
20//!   timeout, never creates, no pragma mutation). This is the profile for
21//!   migration/diagnostic work performed by the party holding the exclusive
22//!   maintenance fence; it deliberately does not take a shared fence guard.
23//!
24//! Opening a connection never runs schema DDL. Stores apply their
25//! [`crate::ledger`] domain after opening.
26//!
27//! # Journal mode is a property of the file
28//!
29//! Journal mode lives in the database header, not in a connection: a durable
30//! read-write connection to a rollback-journal ("delete") database takes a
31//! database-wide EXCLUSIVE lock for every write, with no reader/writer
32//! separation, however the connection was configured. Which profiles
33//! establish WAL and which leave the file as found is therefore stated once,
34//! as [`ConnectionProfile::journal_policy`], instead of being implied by the
35//! shape of a `match` arm.
36//!
37//! # Reading a store's journal mode by hand
38//!
39//! `PRAGMA journal_mode` is only truthful over a connection that opened the
40//! file normally. Two shapes routinely mislead an operator diagnosing a store
41//! from the outside:
42//!
43//! - A database opened with the `immutable=1` URI parameter reports `delete`
44//!   whatever the file actually is: `immutable=1` asserts the file cannot
45//!   change, so SQLite ignores WAL entirely. It can never establish a journal
46//!   mode, and it must never be pointed at a live database, whose concurrent
47//!   writes it also suppresses the locking for.
48//! - Absent `-wal`/`-shm` sidecars are the normal at-rest shape here. These
49//!   stores hold no long-lived connection, and SQLite checkpoints and unlinks
50//!   both sidecars when the last connection to a WAL database closes.
51//!   Sidecars on disk mean a connection is currently open; their absence
52//!   means nothing about the mode. Note also that some system SQLite builds
53//!   refuse a read-only open of a WAL database whose sidecars are absent
54//!   (`SQLITE_CANTOPEN`), which is what tempts a hand probe into
55//!   `immutable=1` in the first place.
56
57use std::path::Path;
58use std::time::Duration;
59
60use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
61
62use crate::error::SqliteStoreError;
63use crate::ledger::SchemaDomain;
64
65/// The one shared busy timeout for production profiles.
66///
67/// Previously `SQLITE_BUSY_TIMEOUT_MS` was defined six times across the
68/// workspace with values of 5s and 60s. 60s is the value the contended
69/// multi-writer store (sessions) was deliberately raised to; the harmonized
70/// default adopts it. Callers with a deliberate different policy pass an
71/// override via [`OpenOptions::busy_timeout`] so the decision stays named at
72/// the call site.
73pub const SHARED_BUSY_TIMEOUT: Duration = Duration::from_millis(60_000);
74
75/// Upper bound on the busy timeout actually handed to SQLite.
76///
77/// `sqlite3_busy_timeout` takes an `int` of milliseconds and rusqlite's
78/// conversion panics past `i32::MAX`; a panic in library code is not an
79/// acceptable outcome for an oversized configuration value, so anything
80/// above this bound (~24.8 days) is clamped to it.
81const MAX_BUSY_TIMEOUT: Duration = Duration::from_millis(i32::MAX as u64);
82
83/// Named connection policy profiles.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ConnectionProfile {
86    /// Production read-write policy: WAL, `synchronous=FULL`, shared busy
87    /// timeout. `create: true` creates parent directories and the file;
88    /// `create: false` refuses to create (no-create writer, for files whose
89    /// creation is owned elsewhere).
90    Primary { create: bool },
91    /// Passive read-only observation. Never creates the database file, never
92    /// mutates pragmas, never alters logical database content. Reading a
93    /// database that is in WAL mode may still create or update its
94    /// `-wal`/`-shm` sidecar files (see
95    /// [`WriteContact::ReadOnlyWalSidecars`]).
96    ReadOnly,
97    /// Fail-fast maintenance access for offline work: zero busy timeout by
98    /// default (a held lock surfaces immediately instead of stalling), never
99    /// creates, never mutates pragmas. `write: false` opens read-only.
100    Maintenance { write: bool },
101}
102
103/// What an open under a profile does to the file's journal mode.
104///
105/// Naming this makes the durable-writer rule checkable instead of accidental:
106/// a profile added later has to choose a policy, and cannot inherit
107/// "not `Primary`, therefore no WAL" from the shape of a `match` arm.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum JournalPolicy {
110    /// Convert-or-confirm `journal_mode=WAL` at open, verified against the
111    /// mode SQLite reports back, and fail the open when WAL cannot be
112    /// established. Every profile that serves durable read-write traffic is
113    /// here; a store that cannot get WAL must not run degraded.
114    EstablishWal,
115    /// Leave the file's journal mode exactly as found.
116    PreserveExisting,
117}
118
119/// The strongest filesystem no-write guarantee an open under a profile can
120/// make. Diagnostic surfaces (doctor) report from this instead of promising
121/// a zero-touch open that SQLite cannot deliver.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum WriteContact {
124    /// The profile writes: mutating pragmas at open (journal-mode
125    /// conversion) and/or subsequent DDL/DML.
126    ReadWrite,
127    /// Logical database content is never modified, but SQLite may still
128    /// create or update the `-wal`/`-shm` sidecar files when the database is
129    /// in WAL mode: coherent WAL reads require the shared-memory index, and
130    /// rusqlite offers no way around that short of `immutable=1`, which is
131    /// only sound when no live writer can exist and is therefore not used
132    /// here. On truly read-only media a WAL database whose sidecars are
133    /// absent fails to read (typed SQLite error) instead of being mutated;
134    /// rollback-journal databases are read with no sidecar contact at all.
135    ReadOnlyWalSidecars,
136}
137
138impl ConnectionProfile {
139    /// Convenience: the common create-capable primary profile.
140    pub const PRIMARY: Self = Self::Primary { create: true };
141
142    fn name(self) -> &'static str {
143        match self {
144            Self::Primary { create: true } => "primary",
145            Self::Primary { create: false } => "primary(no-create)",
146            Self::ReadOnly => "read-only",
147            Self::Maintenance { write: true } => "maintenance(write)",
148            Self::Maintenance { write: false } => "maintenance(read)",
149        }
150    }
151
152    fn default_busy_timeout(self) -> Duration {
153        match self {
154            Self::Primary { .. } | Self::ReadOnly => SHARED_BUSY_TIMEOUT,
155            Self::Maintenance { .. } => Duration::ZERO,
156        }
157    }
158
159    /// What an open under this profile does to the file's journal mode.
160    ///
161    /// `Maintenance { write: true }` is the one read-write profile that
162    /// preserves the mode it finds, and deliberately: it is the offline
163    /// surgeon's profile, taken by the party holding the exclusive
164    /// maintenance fence to bridge ledgers or to archive a file by rename.
165    /// Converting the journal mode of a database another step is about to
166    /// relocate is a mutation outside that mandate, and it would leave
167    /// sidecars beside bytes that are about to move. Durable serving traffic
168    /// therefore never uses that profile; it uses
169    /// [`ConnectionProfile::Primary`], which establishes and verifies WAL or
170    /// refuses the open.
171    pub fn journal_policy(self) -> JournalPolicy {
172        match self {
173            Self::Primary { .. } => JournalPolicy::EstablishWal,
174            Self::ReadOnly | Self::Maintenance { .. } => JournalPolicy::PreserveExisting,
175        }
176    }
177
178    /// The strongest no-write guarantee an open under this profile makes.
179    pub fn write_contact(self) -> WriteContact {
180        match self {
181            Self::Primary { .. } | Self::Maintenance { write: true } => WriteContact::ReadWrite,
182            Self::ReadOnly | Self::Maintenance { write: false } => {
183                WriteContact::ReadOnlyWalSidecars
184            }
185        }
186    }
187}
188
189/// Per-open overrides. The zero-value default defers everything to the
190/// profile.
191#[derive(Debug, Clone, Copy, Default)]
192pub struct OpenOptions {
193    /// Override the profile's busy timeout. A store with a deliberate
194    /// contention policy (for example a lock-arbitration sidecar that waits
195    /// 30s then fails closed) names it here instead of hand-rolling an
196    /// opener. Values beyond SQLite's `int` millisecond range are clamped to
197    /// `i32::MAX` milliseconds.
198    pub busy_timeout: Option<Duration>,
199    /// Schema domains whose version and exact owned catalog are checked
200    /// BEFORE any mutating pragma runs. Future, pre-floor, gap,
201    /// unledgered-owned, and fingerprint-mismatched shapes are refused with
202    /// logical content unmodified — sidecars may still be touched because
203    /// reading the
204    /// ledger of a WAL-mode file over a read-write connection contacts its
205    /// `-wal`/`-shm` files (see [`WriteContact::ReadOnlyWalSidecars`]).
206    /// Without the preflight, a Primary open would convert an ineligible
207    /// database's journal mode before the ledger refusal in
208    /// [`crate::ledger::apply_domain_migrations`] ever fires. The check runs
209    /// unconditionally after every open — never gated on a pre-open
210    /// filesystem probe, which a concurrent creator could race. A missing
211    /// ledger row passes only for a fresh domain with zero owned objects.
212    /// Empty (the default) skips the preflight.
213    pub schema_preflight: &'static [&'static SchemaDomain],
214}
215
216/// Open `path` under `profile` with the profile's defaults.
217pub fn open(path: &Path, profile: ConnectionProfile) -> Result<Connection, SqliteStoreError> {
218    open_with(path, profile, OpenOptions::default())
219}
220
221/// Open `path` under `profile` with per-open overrides.
222pub fn open_with(
223    path: &Path,
224    profile: ConnectionProfile,
225    options: OpenOptions,
226) -> Result<Connection, SqliteStoreError> {
227    let conn = match profile {
228        ConnectionProfile::Primary { create: true } => {
229            if let Some(parent) = path.parent() {
230                std::fs::create_dir_all(parent)?;
231            }
232            Connection::open(path)?
233        }
234        ConnectionProfile::Primary { create: false } => {
235            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
236        }
237        ConnectionProfile::ReadOnly => {
238            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
239        }
240        ConnectionProfile::Maintenance { write } => {
241            let base = if write {
242                OpenFlags::SQLITE_OPEN_READ_WRITE
243            } else {
244                OpenFlags::SQLITE_OPEN_READ_ONLY
245            };
246            open_existing(path, profile, base)?
247        }
248    };
249
250    let busy = options
251        .busy_timeout
252        .unwrap_or_else(|| profile.default_busy_timeout())
253        .min(MAX_BUSY_TIMEOUT);
254    conn.busy_timeout(busy)?;
255
256    // Schema eligibility preflight runs before any mutating pragma: a refusal
257    // leaves the database's logical content unmodified (WAL-mode
258    // files may see sidecar contact from the ledger read). It is not gated
259    // on a pre-open filesystem probe — a database created by another process
260    // between such a probe and the open would dodge the check. A fresh
261    // create carries no ledger table and passes inside
262    // `preflight_schema_eligibility` itself.
263    for domain in options.schema_preflight {
264        crate::ledger::preflight_schema_eligibility(&conn, domain)?;
265    }
266
267    match profile.journal_policy() {
268        JournalPolicy::EstablishWal => {
269            set_wal_journal_mode(&conn, path, busy)?;
270            // The durable-writer pair: WAL for reader/writer separation,
271            // `synchronous=FULL` for the commit durability that goes with it.
272            conn.pragma_update(None, "synchronous", "FULL")?;
273        }
274        JournalPolicy::PreserveExisting => {}
275    }
276
277    Ok(conn)
278}
279
280/// Convert (or confirm) the WAL journal mode with a bounded retry, verified
281/// against the effective mode SQLite reports back.
282///
283/// Converting a rollback-journal database to WAL needs an exclusive lock, and
284/// SQLite can return `SQLITE_BUSY` from the journal-mode pragma WITHOUT
285/// consulting the busy handler while another connection holds the file. Once
286/// a file is WAL the pragma is a lock-free no-op, so the retry only ever
287/// spins while an existing rollback-journal file is being converted (the
288/// first-create race, or the one-time conversion of a legacy database).
289///
290/// Both ways this can end are failures of the open, not degraded successes: a
291/// non-WAL effective mode is [`SqliteStoreError::WalNotEstablished`], and an
292/// exhausted retry budget is [`SqliteStoreError::WalConversionContended`].
293fn set_wal_journal_mode(
294    conn: &Connection,
295    path: &Path,
296    busy_timeout: Duration,
297) -> Result<(), SqliteStoreError> {
298    // Bound the retry by the connection's busy policy (with a small floor so
299    // zero-timeout profiles still tolerate the momentary create race).
300    let budget = busy_timeout.max(Duration::from_millis(250));
301    let deadline = std::time::Instant::now() + budget;
302    loop {
303        // `pragma_update` would discard the mode the pragma returns, and
304        // SQLite can decline the conversion without raising an error — so
305        // the returned mode is read back and anything that is not WAL is a
306        // typed failure. In-memory databases have no on-disk journal and
307        // always report `memory`; they are accepted as-is.
308        let result = conn
309            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
310        match result {
311            Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
312                return Ok(());
313            }
314            Ok(mode) => {
315                return Err(SqliteStoreError::WalNotEstablished {
316                    path: path.to_path_buf(),
317                    actual: mode,
318                });
319            }
320            Err(error) if crate::error::is_busy_or_locked(&error) => {
321                if std::time::Instant::now() < deadline {
322                    std::thread::sleep(Duration::from_millis(5));
323                    continue;
324                }
325                // The retry budget is spent and the file is still locked by
326                // someone else. Fail the open typed rather than hand back a
327                // bare "database is locked", which reads as ordinary
328                // contention and hides that the store would otherwise serve
329                // durable traffic from a rollback-journal database.
330                return Err(SqliteStoreError::WalConversionContended {
331                    path: path.to_path_buf(),
332                    waited_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX),
333                    source: error,
334                });
335            }
336            Err(error) => return Err(error.into()),
337        }
338    }
339}
340
341fn open_existing(
342    path: &Path,
343    profile: ConnectionProfile,
344    base: OpenFlags,
345) -> Result<Connection, SqliteStoreError> {
346    if !path.is_file() {
347        return Err(SqliteStoreError::OpenRefused {
348            path: path.to_path_buf(),
349            profile: profile.name(),
350            detail: "database file does not exist".to_string(),
351        });
352    }
353    Ok(Connection::open_with_flags(
354        path,
355        base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
356    )?)
357}
358
359/// Begin an IMMEDIATE transaction. Bounded waiting for the write lock is the
360/// connection busy handler's job (set at open per profile), not a caller
361/// retry loop.
362pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
363    Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
364}
365
366#[cfg(test)]
367#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
368mod tests {
369    use std::path::PathBuf;
370
371    use super::*;
372
373    fn sidecar_paths(path: &Path) -> (PathBuf, PathBuf) {
374        let mut wal = path.as_os_str().to_os_string();
375        wal.push("-wal");
376        let mut shm = path.as_os_str().to_os_string();
377        shm.push("-shm");
378        (PathBuf::from(wal), PathBuf::from(shm))
379    }
380
381    fn journal_mode(conn: &Connection) -> String {
382        conn.pragma_query_value(None, "journal_mode", |row| row.get(0))
383            .expect("journal_mode")
384    }
385
386    /// Seed a rollback-journal ("delete") database with one row, exactly the
387    /// shape a host that predates the WAL policy carries on disk.
388    fn seed_delete_mode_database(path: &Path) {
389        let conn = Connection::open(path).expect("create");
390        conn.execute_batch("CREATE TABLE legacy (x INTEGER); INSERT INTO legacy VALUES (7);")
391            .expect("seed");
392        assert_eq!(journal_mode(&conn), "delete", "fixture must start non-WAL");
393    }
394
395    #[test]
396    fn primary_creates_dirs_sets_wal_and_full() {
397        let dir = tempfile::tempdir().expect("tempdir");
398        let path = dir.path().join("nested/sub/test.sqlite3");
399        let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
400        let journal: String = conn
401            .pragma_query_value(None, "journal_mode", |r| r.get(0))
402            .expect("journal_mode");
403        assert_eq!(journal, "wal");
404        let sync: i64 = conn
405            .pragma_query_value(None, "synchronous", |r| r.get(0))
406            .expect("synchronous");
407        assert_eq!(sync, 2, "synchronous=FULL");
408    }
409
410    #[test]
411    fn non_creating_profiles_refuse_missing_file() {
412        let dir = tempfile::tempdir().expect("tempdir");
413        let path = dir.path().join("missing.sqlite3");
414        for profile in [
415            ConnectionProfile::Primary { create: false },
416            ConnectionProfile::ReadOnly,
417            ConnectionProfile::Maintenance { write: true },
418            ConnectionProfile::Maintenance { write: false },
419        ] {
420            let err = open(&path, profile).expect_err("must refuse missing file");
421            assert!(
422                matches!(err, SqliteStoreError::OpenRefused { .. }),
423                "{profile:?}"
424            );
425        }
426        assert!(
427            !path.exists(),
428            "no profile may create the file as a side effect"
429        );
430    }
431
432    #[test]
433    fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
434        let dir = tempfile::tempdir().expect("tempdir");
435        let path = dir.path().join("db.sqlite3");
436        {
437            let conn = Connection::open(&path).expect("create");
438            conn.execute_batch("CREATE TABLE t (x INTEGER)")
439                .expect("ddl");
440        }
441        let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
442        let journal: String = conn
443            .pragma_query_value(None, "journal_mode", |r| r.get(0))
444            .expect("journal_mode");
445        assert_eq!(
446            journal, "delete",
447            "reader must not convert the journal mode"
448        );
449        conn.execute("INSERT INTO t VALUES (1)", [])
450            .expect_err("read-only connection must reject writes");
451    }
452
453    #[test]
454    fn maintenance_profile_fails_fast_on_held_lock() {
455        let dir = tempfile::tempdir().expect("tempdir");
456        let path = dir.path().join("db.sqlite3");
457        let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
458        writer
459            .execute_batch("CREATE TABLE t (x INTEGER)")
460            .expect("ddl");
461        let tx = begin_immediate(&mut writer).expect("hold write lock");
462
463        let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
464        let err = maint
465            .execute_batch("BEGIN IMMEDIATE")
466            .expect_err("zero busy timeout must surface the held lock immediately");
467        assert!(crate::error::is_busy_or_locked(&match err {
468            rusqlite::Error::SqliteFailure(..) => err,
469            other => panic!("unexpected error shape: {other}"),
470        }));
471        drop(tx);
472    }
473
474    #[test]
475    fn busy_timeout_override_applies() {
476        let dir = tempfile::tempdir().expect("tempdir");
477        let path = dir.path().join("db.sqlite3");
478        let conn = open_with(
479            &path,
480            ConnectionProfile::PRIMARY,
481            OpenOptions {
482                busy_timeout: Some(Duration::from_millis(1234)),
483                ..OpenOptions::default()
484            },
485        )
486        .expect("open");
487        let timeout: i64 = conn
488            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
489            .expect("busy_timeout");
490        assert_eq!(timeout, 1234);
491    }
492
493    #[test]
494    fn oversized_busy_timeout_is_clamped_not_panicking() {
495        let dir = tempfile::tempdir().expect("tempdir");
496        let path = dir.path().join("db.sqlite3");
497        let conn = open_with(
498            &path,
499            ConnectionProfile::PRIMARY,
500            OpenOptions {
501                busy_timeout: Some(Duration::MAX),
502                ..OpenOptions::default()
503            },
504        )
505        .expect("oversized timeout must clamp, not panic");
506        let timeout: i64 = conn
507            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
508            .expect("busy_timeout");
509        assert_eq!(timeout, i64::from(i32::MAX));
510    }
511
512    fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
513        tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
514    }
515
516    const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
517        name: "preflight-domain",
518        migrations: &[crate::ledger::Migration {
519            version: 1,
520            name: "base",
521            apply: preflight_base,
522        }],
523        initialize_current: preflight_base,
524        allowed_existing_versions: &[1],
525        bridge_recoverable_versions: &[1],
526        released_predecessors: &[],
527        owned_objects: &[crate::ledger::SchemaObject {
528            kind: crate::ledger::SchemaObjectKind::Table,
529            name: "preflight_t",
530        }],
531        retired_objects: &[],
532    };
533
534    #[test]
535    fn schema_preflight_refuses_future_file_before_wal_conversion() {
536        let dir = tempfile::tempdir().expect("tempdir");
537        let path = dir.path().join("db.sqlite3");
538        {
539            // A rollback-journal file stamped by a "newer binary". The
540            // create-capable profile below must refuse it all the same:
541            // the preflight consults only the opened connection's ledger,
542            // so a file created by another process at any point before the
543            // open (there is no pre-open existence probe to race) is
544            // classified by its content, not by who created it.
545            let conn = Connection::open(&path).expect("create raw");
546            conn.execute_batch(
547                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
548                 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
549            )
550            .expect("seed future ledger");
551        }
552        let err = open_with(
553            &path,
554            ConnectionProfile::PRIMARY,
555            OpenOptions {
556                schema_preflight: &[&PREFLIGHT_DOMAIN],
557                ..OpenOptions::default()
558            },
559        )
560        .expect_err("future file must be refused");
561        assert!(matches!(
562            err,
563            SqliteStoreError::SchemaFromTheFuture {
564                found: 999,
565                supported: 1,
566                ..
567            }
568        ));
569        // The refusal fired before the mutating pragma: the file still has
570        // its original rollback journal mode.
571        let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
572            .expect("reopen raw");
573        let journal: String = conn
574            .pragma_query_value(None, "journal_mode", |r| r.get(0))
575            .expect("journal_mode");
576        assert_eq!(journal, "delete");
577    }
578
579    #[test]
580    fn schema_preflight_allows_fresh_create_and_current_files() {
581        let dir = tempfile::tempdir().expect("tempdir");
582        let path = dir.path().join("fresh.sqlite3");
583        let options = OpenOptions {
584            schema_preflight: &[&PREFLIGHT_DOMAIN],
585            ..OpenOptions::default()
586        };
587        // The preflight runs on fresh creates too (no filesystem-based
588        // exemption); it passes because the new file has no ledger table.
589        let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
590            .expect("fresh create carries no ledger table and passes preflight");
591        crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
592        drop(conn);
593        open_with(&path, ConnectionProfile::PRIMARY, options)
594            .expect("current file passes preflight");
595    }
596
597    #[test]
598    fn schema_preflight_refuses_current_fingerprint_mismatch_before_wal_conversion() {
599        let dir = tempfile::tempdir().expect("tempdir");
600        let path = dir.path().join("partial.sqlite3");
601        {
602            let conn = Connection::open(&path).expect("create raw");
603            conn.execute_batch(
604                "CREATE TABLE preflight_t (x INTEGER, candidate_only TEXT);
605                 CREATE TABLE meerkat_schema (
606                     domain TEXT PRIMARY KEY,
607                     version INTEGER NOT NULL
608                 );
609                 INSERT INTO meerkat_schema VALUES ('preflight-domain', 1);",
610            )
611            .expect("seed partial current");
612        }
613        let err = open_with(
614            &path,
615            ConnectionProfile::PRIMARY,
616            OpenOptions {
617                schema_preflight: &[&PREFLIGHT_DOMAIN],
618                ..OpenOptions::default()
619            },
620        )
621        .expect_err("partial current must be refused");
622        assert!(matches!(
623            err,
624            SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
625        ));
626        let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
627            .expect("reopen raw");
628        let journal: String = conn
629            .pragma_query_value(None, "journal_mode", |row| row.get(0))
630            .expect("journal mode");
631        assert_eq!(journal, "delete");
632    }
633
634    #[test]
635    fn primary_in_memory_reports_memory_journal_and_opens() {
636        // In-memory databases cannot hold WAL; the verified journal-mode
637        // setup accepts their `memory` reading instead of refusing them.
638        let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
639        let journal: String = conn
640            .pragma_query_value(None, "journal_mode", |r| r.get(0))
641            .expect("journal_mode");
642        assert_eq!(journal, "memory");
643    }
644
645    #[test]
646    fn write_contact_names_the_wal_sidecar_caveat() {
647        assert_eq!(
648            ConnectionProfile::PRIMARY.write_contact(),
649            WriteContact::ReadWrite
650        );
651        assert_eq!(
652            ConnectionProfile::Primary { create: false }.write_contact(),
653            WriteContact::ReadWrite
654        );
655        assert_eq!(
656            ConnectionProfile::Maintenance { write: true }.write_contact(),
657            WriteContact::ReadWrite
658        );
659        assert_eq!(
660            ConnectionProfile::ReadOnly.write_contact(),
661            WriteContact::ReadOnlyWalSidecars
662        );
663        assert_eq!(
664            ConnectionProfile::Maintenance { write: false }.write_contact(),
665            WriteContact::ReadOnlyWalSidecars
666        );
667    }
668
669    #[test]
670    fn journal_policy_is_pinned_for_every_profile() {
671        // A new profile must choose a policy here rather than inherit
672        // "not Primary, therefore no WAL" by omission. `Maintenance { write:
673        // true }` is read-write and still preserves the mode it finds: see
674        // `ConnectionProfile::journal_policy` for why.
675        for (profile, expected) in [
676            (ConnectionProfile::PRIMARY, JournalPolicy::EstablishWal),
677            (
678                ConnectionProfile::Primary { create: false },
679                JournalPolicy::EstablishWal,
680            ),
681            (ConnectionProfile::ReadOnly, JournalPolicy::PreserveExisting),
682            (
683                ConnectionProfile::Maintenance { write: true },
684                JournalPolicy::PreserveExisting,
685            ),
686            (
687                ConnectionProfile::Maintenance { write: false },
688                JournalPolicy::PreserveExisting,
689            ),
690        ] {
691            assert_eq!(profile.journal_policy(), expected, "{profile:?}");
692        }
693    }
694
695    #[test]
696    fn primary_open_converts_an_existing_delete_mode_database_and_keeps_its_content() {
697        // The migration path for a host whose file predates the WAL policy:
698        // the ordinary production open converts it, in place, on open.
699        let dir = tempfile::tempdir().expect("tempdir");
700        let path = dir.path().join("legacy.sqlite3");
701        seed_delete_mode_database(&path);
702
703        let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
704        assert_eq!(journal_mode(&conn), "wal");
705        let row: i64 = conn
706            .query_row("SELECT x FROM legacy", [], |row| row.get(0))
707            .expect("content survives the conversion");
708        assert_eq!(row, 7);
709        drop(conn);
710
711        // The conversion is a property of the file, so it outlives the
712        // connection that performed it.
713        let reopened = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
714        assert_eq!(journal_mode(&reopened), "wal");
715    }
716
717    #[test]
718    fn non_wal_profiles_leave_an_existing_delete_mode_file_alone() {
719        // The deliberate exclusion, pinned behaviorally: the offline
720        // surgeon's read-write profile must not convert a file another
721        // maintenance step may be about to archive or relocate.
722        let dir = tempfile::tempdir().expect("tempdir");
723        let path = dir.path().join("legacy.sqlite3");
724        seed_delete_mode_database(&path);
725
726        for profile in [
727            ConnectionProfile::Maintenance { write: true },
728            ConnectionProfile::Maintenance { write: false },
729            ConnectionProfile::ReadOnly,
730        ] {
731            let conn = open(&path, profile).expect("open");
732            assert_eq!(journal_mode(&conn), "delete", "{profile:?}");
733        }
734    }
735
736    #[test]
737    fn wal_database_at_rest_has_no_sidecars_and_still_reports_wal() {
738        // The at-rest shape an operator diagnoses by hand. These stores keep
739        // no long-lived connection, so the steady state of a healthy WAL
740        // database is: no `-wal`, no `-shm`, WAL recorded in the header.
741        // Absent sidecars are not evidence of a rollback-journal file.
742        let dir = tempfile::tempdir().expect("tempdir");
743        let path = dir.path().join("at-rest.sqlite3");
744        {
745            let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
746            conn.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
747                .expect("write");
748            assert_eq!(journal_mode(&conn), "wal");
749        }
750
751        let (wal, shm) = sidecar_paths(&path);
752        assert!(!wal.exists(), "a closed WAL database keeps no -wal");
753        assert!(!shm.exists(), "a closed WAL database keeps no -shm");
754
755        // Header bytes 18/19 are the file-format read/write versions; 2 means
756        // WAL. This is the byte-level authority the pragma reports from.
757        let header = std::fs::read(&path).expect("read header");
758        assert_eq!(header.get(18).copied(), Some(2), "read version");
759        assert_eq!(header.get(19).copied(), Some(2), "write version");
760
761        // Both ordinary opens report the truth with no sidecars present.
762        assert_eq!(
763            journal_mode(&open(&path, ConnectionProfile::ReadOnly).expect("read-only reopen")),
764            "wal"
765        );
766        assert_eq!(
767            journal_mode(&open(&path, ConnectionProfile::PRIMARY).expect("primary reopen")),
768            "wal"
769        );
770    }
771
772    #[test]
773    fn wal_conversion_that_cannot_win_the_lock_fails_typed() {
774        let dir = tempfile::tempdir().expect("tempdir");
775        let path = dir.path().join("contended.sqlite3");
776        seed_delete_mode_database(&path);
777
778        // A reader parked inside a read transaction holds the SHARED lock
779        // that converting to WAL needs to escalate past. Only a
780        // rollback-journal file can contend here: once a file is WAL the
781        // pragma is a lock-free no-op.
782        let reader = Connection::open(&path).expect("reader");
783        reader
784            .execute_batch("BEGIN; SELECT count(*) FROM legacy;")
785            .expect("hold the shared lock");
786
787        let err = open_with(
788            &path,
789            ConnectionProfile::PRIMARY,
790            OpenOptions {
791                busy_timeout: Some(Duration::ZERO),
792                ..OpenOptions::default()
793            },
794        )
795        .expect_err("a durable read-write open must fail closed, not run in delete mode");
796        assert!(
797            matches!(
798                err,
799                SqliteStoreError::WalConversionContended { path: ref refused, waited_ms, .. }
800                    if refused == &path && waited_ms >= 250
801            ),
802            "{err}"
803        );
804
805        // The refusal left the database exactly as found.
806        reader.execute_batch("COMMIT").expect("release");
807        assert_eq!(journal_mode(&reader), "delete");
808        let row: i64 = reader
809            .query_row("SELECT x FROM legacy", [], |row| row.get(0))
810            .expect("content untouched");
811        assert_eq!(row, 7);
812    }
813}