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