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