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    // Validate the file before SQLite sees it, then again once the
228    // connection holds it and before any pragma or schema work runs: a file
229    // replaced or linked in between is refused rather than mutated.
230    let expected = validate_database_file(path)?;
231    let conn = match profile {
232        ConnectionProfile::Primary { create: true } => {
233            if let Some(parent) = path.parent() {
234                std::fs::create_dir_all(parent)?;
235            }
236            Connection::open(path)?
237        }
238        ConnectionProfile::Primary { create: false } => {
239            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
240        }
241        ConnectionProfile::ReadOnly => {
242            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
243        }
244        ConnectionProfile::Maintenance { write } => {
245            let base = if write {
246                OpenFlags::SQLITE_OPEN_READ_WRITE
247            } else {
248                OpenFlags::SQLITE_OPEN_READ_ONLY
249            };
250            open_existing(path, profile, base)?
251        }
252    };
253
254    let busy = options
255        .busy_timeout
256        .unwrap_or_else(|| profile.default_busy_timeout())
257        .min(MAX_BUSY_TIMEOUT);
258    conn.busy_timeout(busy)?;
259    let opened = validate_database_file(path)?;
260    if let (Some(expected), Some(opened)) = (expected, opened)
261        && expected != opened
262    {
263        return Err(SqliteStoreError::UnsupportedDatabaseFile {
264            path: path.to_path_buf(),
265            detail: "the database file was replaced while it was being opened".to_string(),
266        });
267    }
268
269    // Schema eligibility preflight runs before any mutating pragma: a refusal
270    // leaves the database's logical content unmodified (WAL-mode
271    // files may see sidecar contact from the ledger read). It is not gated
272    // on a pre-open filesystem probe — a database created by another process
273    // between such a probe and the open would dodge the check. A fresh
274    // create carries no ledger table and passes inside
275    // `preflight_schema_eligibility` itself.
276    for domain in options.schema_preflight {
277        crate::ledger::preflight_schema_eligibility(&conn, domain)?;
278    }
279
280    match profile.journal_policy() {
281        JournalPolicy::EstablishWal => {
282            set_wal_journal_mode(&conn, path, busy)?;
283            // The durable-writer pair: WAL for reader/writer separation,
284            // `synchronous=FULL` for the commit durability that goes with it.
285            conn.pragma_update(None, "synchronous", "FULL")?;
286        }
287        JournalPolicy::PreserveExisting => {}
288    }
289
290    Ok(conn)
291}
292
293/// Convert (or confirm) the WAL journal mode with a bounded retry, verified
294/// against the effective mode SQLite reports back.
295///
296/// Converting a rollback-journal database to WAL needs an exclusive lock, and
297/// SQLite can return `SQLITE_BUSY` from the journal-mode pragma WITHOUT
298/// consulting the busy handler while another connection holds the file. Once
299/// a file is WAL the pragma is a lock-free no-op, so the retry only ever
300/// spins while an existing rollback-journal file is being converted (the
301/// first-create race, or the one-time conversion of a legacy database).
302///
303/// Both ways this can end are failures of the open, not degraded successes: a
304/// non-WAL effective mode is [`SqliteStoreError::WalNotEstablished`], and an
305/// exhausted retry budget is [`SqliteStoreError::WalConversionContended`].
306fn set_wal_journal_mode(
307    conn: &Connection,
308    path: &Path,
309    busy_timeout: Duration,
310) -> Result<(), SqliteStoreError> {
311    // Bound the retry by the connection's busy policy (with a small floor so
312    // zero-timeout profiles still tolerate the momentary create race).
313    let budget = busy_timeout.max(Duration::from_millis(250));
314    let deadline = std::time::Instant::now() + budget;
315    loop {
316        // `pragma_update` would discard the mode the pragma returns, and
317        // SQLite can decline the conversion without raising an error — so
318        // the returned mode is read back and anything that is not WAL is a
319        // typed failure. In-memory databases have no on-disk journal and
320        // always report `memory`; they are accepted as-is.
321        let result = conn
322            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
323        match result {
324            Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
325                return Ok(());
326            }
327            Ok(mode) => {
328                return Err(SqliteStoreError::WalNotEstablished {
329                    path: path.to_path_buf(),
330                    actual: mode,
331                });
332            }
333            Err(error) if crate::error::is_busy_or_locked(&error) => {
334                if std::time::Instant::now() < deadline {
335                    std::thread::sleep(Duration::from_millis(5));
336                    continue;
337                }
338                // The retry budget is spent and the file is still locked by
339                // someone else. Fail the open typed rather than hand back a
340                // bare "database is locked", which reads as ordinary
341                // contention and hides that the store would otherwise serve
342                // durable traffic from a rollback-journal database.
343                return Err(SqliteStoreError::WalConversionContended {
344                    path: path.to_path_buf(),
345                    waited_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX),
346                    source: error,
347                });
348            }
349            Err(error) => return Err(error.into()),
350        }
351    }
352}
353
354/// Device and inode of a validated database file.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub struct DatabaseFileIdentity {
357    device: u64,
358    inode: u64,
359}
360
361/// Refuse a database path SQLite cannot safely address by that one name.
362///
363/// SQLite names a database's journal, WAL and SHM files after the path it
364/// opened, so two hard links to one database give two sets of sidecars and
365/// break coordinated access and crash recovery ([SQLite: multiple links to
366/// the same file](https://www.sqlite.org/howtocorrupt.html#multiple_links_to_the_same_file)).
367/// An existing path must be a regular file (after following symlinks, which
368/// SQLite resolves itself) and, where the platform reports link counts, have
369/// exactly one hard link. A missing path is accepted: creating it is the
370/// opener's decision. Returns the file's identity on platforms that expose
371/// one, so a caller can confirm the opened file is the validated one.
372///
373/// This keeps the storage namespace honest for a cooperating owner; it does
374/// not defend against an adversary replacing paths concurrently.
375pub fn validate_database_file(
376    path: &Path,
377) -> Result<Option<DatabaseFileIdentity>, SqliteStoreError> {
378    let refuse = |detail: String| SqliteStoreError::UnsupportedDatabaseFile {
379        path: path.to_path_buf(),
380        detail,
381    };
382    let metadata = match std::fs::metadata(path) {
383        Ok(metadata) => metadata,
384        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
385        Err(error) => return Err(error.into()),
386    };
387    if !metadata.is_file() {
388        return Err(refuse("not a regular file".to_string()));
389    }
390    #[cfg(unix)]
391    {
392        use std::os::unix::fs::MetadataExt as _;
393        if metadata.nlink() > 1 {
394            return Err(refuse(format!(
395                "the file has {} hard links; SQLite needs exactly one name per database",
396                metadata.nlink()
397            )));
398        }
399        Ok(Some(DatabaseFileIdentity {
400            device: metadata.dev(),
401            inode: metadata.ino(),
402        }))
403    }
404    #[cfg(not(unix))]
405    {
406        Ok(None)
407    }
408}
409
410fn open_existing(
411    path: &Path,
412    profile: ConnectionProfile,
413    base: OpenFlags,
414) -> Result<Connection, SqliteStoreError> {
415    if !path.is_file() {
416        return Err(SqliteStoreError::OpenRefused {
417            path: path.to_path_buf(),
418            profile: profile.name(),
419            detail: "database file does not exist".to_string(),
420        });
421    }
422    Ok(Connection::open_with_flags(
423        path,
424        base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
425    )?)
426}
427
428/// Begin an IMMEDIATE transaction. Bounded waiting for the write lock is the
429/// connection busy handler's job (set at open per profile), not a caller
430/// retry loop.
431pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
432    Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
433}
434
435#[cfg(test)]
436#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
437mod tests {
438    use std::path::PathBuf;
439
440    use super::*;
441
442    /// #1551: an existing database must be a regular file with one name.
443    #[test]
444    fn database_file_validation_accepts_one_name_and_refuses_others() {
445        let dir = tempfile::tempdir().unwrap();
446        let missing = dir.path().join("missing.sqlite3");
447        assert_eq!(validate_database_file(&missing).unwrap(), None);
448
449        let directory = dir.path().join("directory.sqlite3");
450        std::fs::create_dir(&directory).unwrap();
451        assert!(matches!(
452            validate_database_file(&directory),
453            Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
454        ));
455        assert!(matches!(
456            open_with(
457                &directory,
458                ConnectionProfile::PRIMARY,
459                OpenOptions::default()
460            ),
461            Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
462        ));
463
464        let single = dir.path().join("single.sqlite3");
465        drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
466        #[cfg(unix)]
467        assert!(validate_database_file(&single).unwrap().is_some());
468        drop(open_with(&single, ConnectionProfile::PRIMARY, OpenOptions::default()).unwrap());
469    }
470
471    /// #1551: a hard-linked database is refused through either name before
472    /// SQLite opens it, so neither name gains WAL or SHM sidecars and the
473    /// database bytes are untouched.
474    #[cfg(unix)]
475    #[test]
476    fn hard_linked_database_is_refused_before_any_sqlite_open() {
477        let dir = tempfile::tempdir().unwrap();
478        let original = dir.path().join("original.sqlite3");
479        {
480            let conn = Connection::open(&original).unwrap();
481            conn.execute_batch(
482                "CREATE TABLE canary (value INTEGER); INSERT INTO canary VALUES (1);",
483            )
484            .unwrap();
485        }
486        let linked = dir.path().join("linked.sqlite3");
487        std::fs::hard_link(&original, &linked).unwrap();
488        let bytes = std::fs::read(&original).unwrap();
489        for name in [&original, &linked] {
490            for profile in [
491                ConnectionProfile::PRIMARY,
492                ConnectionProfile::ReadOnly,
493                ConnectionProfile::Maintenance { write: true },
494            ] {
495                assert!(
496                    matches!(
497                        open_with(name, profile, OpenOptions::default()),
498                        Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
499                    ),
500                    "{} through {}",
501                    profile.name(),
502                    name.display()
503                );
504            }
505            assert!(matches!(
506                crate::fence::OperationGuard::for_database(name),
507                Err(SqliteStoreError::UnsupportedDatabaseFile { .. })
508            ));
509            let (wal, shm) = sidecar_paths(name);
510            assert!(
511                !wal.exists() && !shm.exists(),
512                "no sidecar for {}",
513                name.display()
514            );
515        }
516        assert_eq!(std::fs::read(&original).unwrap(), bytes);
517    }
518
519    fn sidecar_paths(path: &Path) -> (PathBuf, PathBuf) {
520        let mut wal = path.as_os_str().to_os_string();
521        wal.push("-wal");
522        let mut shm = path.as_os_str().to_os_string();
523        shm.push("-shm");
524        (PathBuf::from(wal), PathBuf::from(shm))
525    }
526
527    fn journal_mode(conn: &Connection) -> String {
528        conn.pragma_query_value(None, "journal_mode", |row| row.get(0))
529            .expect("journal_mode")
530    }
531
532    /// Seed a rollback-journal ("delete") database with one row, exactly the
533    /// shape a host that predates the WAL policy carries on disk.
534    fn seed_delete_mode_database(path: &Path) {
535        let conn = Connection::open(path).expect("create");
536        conn.execute_batch("CREATE TABLE legacy (x INTEGER); INSERT INTO legacy VALUES (7);")
537            .expect("seed");
538        assert_eq!(journal_mode(&conn), "delete", "fixture must start non-WAL");
539    }
540
541    #[test]
542    fn primary_creates_dirs_sets_wal_and_full() {
543        let dir = tempfile::tempdir().expect("tempdir");
544        let path = dir.path().join("nested/sub/test.sqlite3");
545        let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
546        let journal: String = conn
547            .pragma_query_value(None, "journal_mode", |r| r.get(0))
548            .expect("journal_mode");
549        assert_eq!(journal, "wal");
550        let sync: i64 = conn
551            .pragma_query_value(None, "synchronous", |r| r.get(0))
552            .expect("synchronous");
553        assert_eq!(sync, 2, "synchronous=FULL");
554    }
555
556    #[test]
557    fn non_creating_profiles_refuse_missing_file() {
558        let dir = tempfile::tempdir().expect("tempdir");
559        let path = dir.path().join("missing.sqlite3");
560        for profile in [
561            ConnectionProfile::Primary { create: false },
562            ConnectionProfile::ReadOnly,
563            ConnectionProfile::Maintenance { write: true },
564            ConnectionProfile::Maintenance { write: false },
565        ] {
566            let err = open(&path, profile).expect_err("must refuse missing file");
567            assert!(
568                matches!(err, SqliteStoreError::OpenRefused { .. }),
569                "{profile:?}"
570            );
571        }
572        assert!(
573            !path.exists(),
574            "no profile may create the file as a side effect"
575        );
576    }
577
578    #[test]
579    fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
580        let dir = tempfile::tempdir().expect("tempdir");
581        let path = dir.path().join("db.sqlite3");
582        {
583            let conn = Connection::open(&path).expect("create");
584            conn.execute_batch("CREATE TABLE t (x INTEGER)")
585                .expect("ddl");
586        }
587        let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
588        let journal: String = conn
589            .pragma_query_value(None, "journal_mode", |r| r.get(0))
590            .expect("journal_mode");
591        assert_eq!(
592            journal, "delete",
593            "reader must not convert the journal mode"
594        );
595        conn.execute("INSERT INTO t VALUES (1)", [])
596            .expect_err("read-only connection must reject writes");
597    }
598
599    #[test]
600    fn maintenance_profile_fails_fast_on_held_lock() {
601        let dir = tempfile::tempdir().expect("tempdir");
602        let path = dir.path().join("db.sqlite3");
603        let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
604        writer
605            .execute_batch("CREATE TABLE t (x INTEGER)")
606            .expect("ddl");
607        let tx = begin_immediate(&mut writer).expect("hold write lock");
608
609        let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
610        let err = maint
611            .execute_batch("BEGIN IMMEDIATE")
612            .expect_err("zero busy timeout must surface the held lock immediately");
613        assert!(crate::error::is_busy_or_locked(&match err {
614            rusqlite::Error::SqliteFailure(..) => err,
615            other => panic!("unexpected error shape: {other}"),
616        }));
617        drop(tx);
618    }
619
620    #[test]
621    fn busy_timeout_override_applies() {
622        let dir = tempfile::tempdir().expect("tempdir");
623        let path = dir.path().join("db.sqlite3");
624        let conn = open_with(
625            &path,
626            ConnectionProfile::PRIMARY,
627            OpenOptions {
628                busy_timeout: Some(Duration::from_millis(1234)),
629                ..OpenOptions::default()
630            },
631        )
632        .expect("open");
633        let timeout: i64 = conn
634            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
635            .expect("busy_timeout");
636        assert_eq!(timeout, 1234);
637    }
638
639    #[test]
640    fn oversized_busy_timeout_is_clamped_not_panicking() {
641        let dir = tempfile::tempdir().expect("tempdir");
642        let path = dir.path().join("db.sqlite3");
643        let conn = open_with(
644            &path,
645            ConnectionProfile::PRIMARY,
646            OpenOptions {
647                busy_timeout: Some(Duration::MAX),
648                ..OpenOptions::default()
649            },
650        )
651        .expect("oversized timeout must clamp, not panic");
652        let timeout: i64 = conn
653            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
654            .expect("busy_timeout");
655        assert_eq!(timeout, i64::from(i32::MAX));
656    }
657
658    fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
659        tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
660    }
661
662    const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
663        name: "preflight-domain",
664        migrations: &[crate::ledger::Migration {
665            version: 1,
666            name: "base",
667            apply: preflight_base,
668        }],
669        initialize_current: preflight_base,
670        allowed_existing_versions: &[1],
671        bridge_recoverable_versions: &[1],
672        released_predecessors: &[],
673        owned_objects: &[crate::ledger::SchemaObject {
674            kind: crate::ledger::SchemaObjectKind::Table,
675            name: "preflight_t",
676        }],
677        retired_objects: &[],
678    };
679
680    #[test]
681    fn schema_preflight_refuses_future_file_before_wal_conversion() {
682        let dir = tempfile::tempdir().expect("tempdir");
683        let path = dir.path().join("db.sqlite3");
684        {
685            // A rollback-journal file stamped by a "newer binary". The
686            // create-capable profile below must refuse it all the same:
687            // the preflight consults only the opened connection's ledger,
688            // so a file created by another process at any point before the
689            // open (there is no pre-open existence probe to race) is
690            // classified by its content, not by who created it.
691            let conn = Connection::open(&path).expect("create raw");
692            conn.execute_batch(
693                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
694                 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
695            )
696            .expect("seed future ledger");
697        }
698        let err = open_with(
699            &path,
700            ConnectionProfile::PRIMARY,
701            OpenOptions {
702                schema_preflight: &[&PREFLIGHT_DOMAIN],
703                ..OpenOptions::default()
704            },
705        )
706        .expect_err("future file must be refused");
707        assert!(matches!(
708            err,
709            SqliteStoreError::SchemaFromTheFuture {
710                found: 999,
711                supported: 1,
712                ..
713            }
714        ));
715        // The refusal fired before the mutating pragma: the file still has
716        // its original rollback journal mode.
717        let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
718            .expect("reopen raw");
719        let journal: String = conn
720            .pragma_query_value(None, "journal_mode", |r| r.get(0))
721            .expect("journal_mode");
722        assert_eq!(journal, "delete");
723    }
724
725    #[test]
726    fn schema_preflight_allows_fresh_create_and_current_files() {
727        let dir = tempfile::tempdir().expect("tempdir");
728        let path = dir.path().join("fresh.sqlite3");
729        let options = OpenOptions {
730            schema_preflight: &[&PREFLIGHT_DOMAIN],
731            ..OpenOptions::default()
732        };
733        // The preflight runs on fresh creates too (no filesystem-based
734        // exemption); it passes because the new file has no ledger table.
735        let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
736            .expect("fresh create carries no ledger table and passes preflight");
737        crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
738        drop(conn);
739        open_with(&path, ConnectionProfile::PRIMARY, options)
740            .expect("current file passes preflight");
741    }
742
743    #[test]
744    fn schema_preflight_refuses_current_fingerprint_mismatch_before_wal_conversion() {
745        let dir = tempfile::tempdir().expect("tempdir");
746        let path = dir.path().join("partial.sqlite3");
747        {
748            let conn = Connection::open(&path).expect("create raw");
749            conn.execute_batch(
750                "CREATE TABLE preflight_t (x INTEGER, candidate_only TEXT);
751                 CREATE TABLE meerkat_schema (
752                     domain TEXT PRIMARY KEY,
753                     version INTEGER NOT NULL
754                 );
755                 INSERT INTO meerkat_schema VALUES ('preflight-domain', 1);",
756            )
757            .expect("seed partial current");
758        }
759        let err = open_with(
760            &path,
761            ConnectionProfile::PRIMARY,
762            OpenOptions {
763                schema_preflight: &[&PREFLIGHT_DOMAIN],
764                ..OpenOptions::default()
765            },
766        )
767        .expect_err("partial current must be refused");
768        assert!(matches!(
769            err,
770            SqliteStoreError::CurrentSchemaMismatch { version: 1, ref changed_objects, .. }
771                if changed_objects == &vec!["table:preflight_t".to_string()]
772        ));
773        let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
774            .expect("reopen raw");
775        let journal: String = conn
776            .pragma_query_value(None, "journal_mode", |row| row.get(0))
777            .expect("journal mode");
778        assert_eq!(journal, "delete");
779    }
780
781    #[test]
782    fn primary_in_memory_reports_memory_journal_and_opens() {
783        // In-memory databases cannot hold WAL; the verified journal-mode
784        // setup accepts their `memory` reading instead of refusing them.
785        let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
786        let journal: String = conn
787            .pragma_query_value(None, "journal_mode", |r| r.get(0))
788            .expect("journal_mode");
789        assert_eq!(journal, "memory");
790    }
791
792    #[test]
793    fn write_contact_names_the_wal_sidecar_caveat() {
794        assert_eq!(
795            ConnectionProfile::PRIMARY.write_contact(),
796            WriteContact::ReadWrite
797        );
798        assert_eq!(
799            ConnectionProfile::Primary { create: false }.write_contact(),
800            WriteContact::ReadWrite
801        );
802        assert_eq!(
803            ConnectionProfile::Maintenance { write: true }.write_contact(),
804            WriteContact::ReadWrite
805        );
806        assert_eq!(
807            ConnectionProfile::ReadOnly.write_contact(),
808            WriteContact::ReadOnlyWalSidecars
809        );
810        assert_eq!(
811            ConnectionProfile::Maintenance { write: false }.write_contact(),
812            WriteContact::ReadOnlyWalSidecars
813        );
814    }
815
816    #[test]
817    fn journal_policy_is_pinned_for_every_profile() {
818        // A new profile must choose a policy here rather than inherit
819        // "not Primary, therefore no WAL" by omission. `Maintenance { write:
820        // true }` is read-write and still preserves the mode it finds: see
821        // `ConnectionProfile::journal_policy` for why.
822        for (profile, expected) in [
823            (ConnectionProfile::PRIMARY, JournalPolicy::EstablishWal),
824            (
825                ConnectionProfile::Primary { create: false },
826                JournalPolicy::EstablishWal,
827            ),
828            (ConnectionProfile::ReadOnly, JournalPolicy::PreserveExisting),
829            (
830                ConnectionProfile::Maintenance { write: true },
831                JournalPolicy::PreserveExisting,
832            ),
833            (
834                ConnectionProfile::Maintenance { write: false },
835                JournalPolicy::PreserveExisting,
836            ),
837        ] {
838            assert_eq!(profile.journal_policy(), expected, "{profile:?}");
839        }
840    }
841
842    #[test]
843    fn primary_open_converts_an_existing_delete_mode_database_and_keeps_its_content() {
844        // The migration path for a host whose file predates the WAL policy:
845        // the ordinary production open converts it, in place, on open.
846        let dir = tempfile::tempdir().expect("tempdir");
847        let path = dir.path().join("legacy.sqlite3");
848        seed_delete_mode_database(&path);
849
850        let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
851        assert_eq!(journal_mode(&conn), "wal");
852        let row: i64 = conn
853            .query_row("SELECT x FROM legacy", [], |row| row.get(0))
854            .expect("content survives the conversion");
855        assert_eq!(row, 7);
856        drop(conn);
857
858        // The conversion is a property of the file, so it outlives the
859        // connection that performed it.
860        let reopened = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
861        assert_eq!(journal_mode(&reopened), "wal");
862    }
863
864    #[test]
865    fn non_wal_profiles_leave_an_existing_delete_mode_file_alone() {
866        // The deliberate exclusion, pinned behaviorally: the offline
867        // surgeon's read-write profile must not convert a file another
868        // maintenance step may be about to archive or relocate.
869        let dir = tempfile::tempdir().expect("tempdir");
870        let path = dir.path().join("legacy.sqlite3");
871        seed_delete_mode_database(&path);
872
873        for profile in [
874            ConnectionProfile::Maintenance { write: true },
875            ConnectionProfile::Maintenance { write: false },
876            ConnectionProfile::ReadOnly,
877        ] {
878            let conn = open(&path, profile).expect("open");
879            assert_eq!(journal_mode(&conn), "delete", "{profile:?}");
880        }
881    }
882
883    #[test]
884    fn wal_database_at_rest_has_no_sidecars_and_still_reports_wal() {
885        // The at-rest shape an operator diagnoses by hand. These stores keep
886        // no long-lived connection, so the steady state of a healthy WAL
887        // database is: no `-wal`, no `-shm`, WAL recorded in the header.
888        // Absent sidecars are not evidence of a rollback-journal file.
889        let dir = tempfile::tempdir().expect("tempdir");
890        let path = dir.path().join("at-rest.sqlite3");
891        {
892            let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
893            conn.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
894                .expect("write");
895            assert_eq!(journal_mode(&conn), "wal");
896        }
897
898        let (wal, shm) = sidecar_paths(&path);
899        assert!(!wal.exists(), "a closed WAL database keeps no -wal");
900        assert!(!shm.exists(), "a closed WAL database keeps no -shm");
901
902        // Header bytes 18/19 are the file-format read/write versions; 2 means
903        // WAL. This is the byte-level authority the pragma reports from.
904        let header = std::fs::read(&path).expect("read header");
905        assert_eq!(header.get(18).copied(), Some(2), "read version");
906        assert_eq!(header.get(19).copied(), Some(2), "write version");
907
908        // Both ordinary opens report the truth with no sidecars present.
909        assert_eq!(
910            journal_mode(&open(&path, ConnectionProfile::ReadOnly).expect("read-only reopen")),
911            "wal"
912        );
913        assert_eq!(
914            journal_mode(&open(&path, ConnectionProfile::PRIMARY).expect("primary reopen")),
915            "wal"
916        );
917    }
918
919    #[test]
920    fn wal_conversion_that_cannot_win_the_lock_fails_typed() {
921        let dir = tempfile::tempdir().expect("tempdir");
922        let path = dir.path().join("contended.sqlite3");
923        seed_delete_mode_database(&path);
924
925        // A reader parked inside a read transaction holds the SHARED lock
926        // that converting to WAL needs to escalate past. Only a
927        // rollback-journal file can contend here: once a file is WAL the
928        // pragma is a lock-free no-op.
929        let reader = Connection::open(&path).expect("reader");
930        reader
931            .execute_batch("BEGIN; SELECT count(*) FROM legacy;")
932            .expect("hold the shared lock");
933
934        let err = open_with(
935            &path,
936            ConnectionProfile::PRIMARY,
937            OpenOptions {
938                busy_timeout: Some(Duration::ZERO),
939                ..OpenOptions::default()
940            },
941        )
942        .expect_err("a durable read-write open must fail closed, not run in delete mode");
943        assert!(
944            matches!(
945                err,
946                SqliteStoreError::WalConversionContended { path: ref refused, waited_ms, .. }
947                    if refused == &path && waited_ms >= 250
948            ),
949            "{err}"
950        );
951
952        // The refusal left the database exactly as found.
953        reader.execute_batch("COMMIT").expect("release");
954        assert_eq!(journal_mode(&reader), "delete");
955        let row: i64 = reader
956            .query_row("SELECT x FROM legacy", [], |row| row.get(0))
957            .expect("content untouched");
958        assert_eq!(row, 7);
959    }
960}