Skip to main content

rightkit_db/
lib.rs

1// Unsafe is confined to the host-name FFI in `host`.
2#![deny(unsafe_code)]
3
4#[cfg(all(feature = "sqlcipher", feature = "sqlcipher-system"))]
5compile_error!("rightkit-db features `sqlcipher` and `sqlcipher-system` are mutually exclusive; enable exactly one");
6
7use rusqlite::config::DbConfig;
8use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
9use std::ffi::OsString;
10use std::ops::Deref;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Condvar, Mutex, MutexGuard, Weak};
13use std::time::{Duration, Instant};
14/// The zeroizing wrapper that the SQLCipher key fields and key candidates use, re-exported so
15/// callers build keys without declaring their own `zeroize` dependency.
16pub use zeroize::Zeroizing;
17
18mod backup;
19mod checkpoint_job;
20#[allow(unsafe_code)]
21mod host;
22pub use host::per_host_path;
23/// The exact `rusqlite` this crate links (`links = "sqlite3"` allows one per build). Apps name
24/// `Connection`, `params!`, `Row`, `OptionalExtension`, `TransactionBehavior` through this path
25/// instead of declaring their own `rusqlite` dependency.
26pub use rusqlite;
27mod migrate;
28mod recover;
29mod restore;
30mod unreadable;
31
32#[cfg(all(feature = "encrypted-lifecycle", not(feature = "cipher")))]
33compile_error!("rightkit-db feature `encrypted-lifecycle` needs a SQLCipher backend; also enable `sqlcipher` or `sqlcipher-system`");
34
35/// The encrypted-store lifecycle: key selection, rotation, recovery, restore and relocation behind
36/// one cross-process guard. Needs `encrypted-lifecycle` and a SQLCipher backend.
37#[cfg(all(feature = "encrypted-lifecycle", feature = "cipher"))]
38pub mod encrypted_lifecycle;
39
40pub use backup::{
41    online_backup_to, remove_database_files, replace_database, replace_database_locked,
42    restore_from, store_lock_path, BackupProgress, StoreLock,
43};
44pub use checkpoint_job::{
45    WalCheckpointJob, WalCheckpointMode, WalCheckpointPolicy, WalCheckpointRun,
46};
47pub use migrate::{
48    add_column_if_missing, column_exists, migrate, migration_version, pending_marker,
49    pending_marker_started_at, rollback_to, rollback_to_with_storage, stored_version_is_newer,
50    user_version, write_pending_marker, Degraded, LegacyVersionTable, MigrateOptions, Migration,
51    MigrationReport, OpenOrDegrade, PendingMarker, Step, VersionStorage,
52};
53#[cfg(feature = "cipher")]
54pub use recover::rekey;
55pub use recover::{
56    quarantine_database, quarantine_database_opts, quarantine_database_with, MoveMode, OpenOutcome,
57    QuarantineOptions, QuarantineStamp, RecoveryPolicy, SidecarPolicy,
58};
59
60pub use restore::{
61    recover_interrupted_restore, recover_interrupted_restore_locked, restore_database,
62    restore_database_locked, InterruptedRestore, RestoreOptions, RestoreReceipt, RestoreVerify,
63};
64
65pub use unreadable::{
66    classify_open_failure, unreadable_counter_path, RecoveryAction, StorageProblem,
67    UnreadableCause, UnreadablePolicy, UnreadableRecovery,
68};
69
70const BUSY_TIMEOUT_MS: u64 = 5_000;
71const JOURNAL_SIZE_LIMIT: i64 = 67_108_864;
72
73pub type Result<T> = std::result::Result<T, DbError>;
74
75#[derive(Debug, thiserror::Error)]
76#[non_exhaustive]
77pub enum DbError {
78    #[error("SQLite error: {0}")]
79    Sqlite(#[from] rusqlite::Error),
80    #[error("a SQLCipher key requires the rightkit-db `sqlcipher` or `sqlcipher-system` feature")]
81    SqlcipherFeatureRequired,
82    #[error("SQLCipher key contains characters that are unsafe for PRAGMA interpolation")]
83    InvalidSqlcipherKey,
84    /// An open-time `quick_check` or `integrity_check` failed. Open-time recovery treats it as an
85    /// unreadable database (see `DbOptions::open_writer_recovering`).
86    #[error("database integrity check failed: {0}")]
87    Integrity(String),
88    #[error("quarantine suffix {0:?} must be non-empty and contain no path separator or NUL")]
89    InvalidQuarantineSuffix(String),
90    #[error("read-pool capacity must be greater than zero")]
91    InvalidPoolCapacity,
92    #[error("reader cache size must be between 1 and 2147483647 KiB, got {0}")]
93    InvalidReaderCacheSize(u32),
94    /// Checkout after [`ReadPool::close`] or [`ReadPool::close_and_wait`].
95    #[error("read pool is closed")]
96    ReadPoolClosed,
97    /// [`ReadPool::close_and_wait`] timed out. `outstanding` counts readers still checked out or
98    /// opening.
99    #[error("read pool close timed out after {timeout:?} with {outstanding} reader(s) still out")]
100    ReadPoolCloseTimeout {
101        outstanding: usize,
102        timeout: Duration,
103    },
104    /// A reader did not come up read-only: `PRAGMA query_only` did not stay on, or a read-only
105    /// handle reported a writable main database.
106    #[error("reader connection is not read-only: {0}")]
107    ReadOnlyNotEffective(String),
108    #[error("database path has no file name")]
109    InvalidDatabasePath,
110    #[error("database is at schema v{current}, newer than this build understands (v{max}) and not forward compatible")]
111    NewerDatabase { current: i64, max: i64 },
112    #[error("invalid migration list: {0}")]
113    InvalidMigrations(String),
114    #[error("migration v{version} ({name}) failed and was rolled back: {source}")]
115    MigrationFailed {
116        version: i64,
117        name: &'static str,
118        #[source]
119        source: rusqlite::Error,
120    },
121    #[error("cannot roll back: {0}")]
122    Rollback(String),
123    #[error("database backup failed: {0}")]
124    Backup(String),
125    /// [`restore_database`] failed. `previous` is where the previous store is kept when it is not
126    /// at the live path (never deleted); `failed_copy` is where the restored copy was set aside.
127    /// `rolled_back` is true when the live path holds the previous store again (or was never
128    /// touched); false means the rollback was incomplete, a restore journal remains, and
129    /// [`recover_interrupted_restore`] finishes it.
130    #[error("database restore failed: {message}")]
131    RestoreFailed {
132        message: String,
133        previous: Option<PathBuf>,
134        failed_copy: Option<PathBuf>,
135        rolled_back: bool,
136    },
137    #[error("no candidate key opened the database")]
138    NoKeyOpened,
139    #[error(transparent)]
140    Fs(#[from] rightkit_fs::FsError),
141    /// The database file could not be read (permission or I/O failure) or its storage is
142    /// unavailable. Not corruption: the file was not touched unless `quarantine_after` was
143    /// reached, in which case recovery succeeds instead of returning this. See
144    /// [`UnreadablePolicy`].
145    #[error(
146        "database {} cannot be read: {cause} ({consecutive_failures} consecutive failed open(s)): {source}",
147        .path.display()
148    )]
149    Unreadable {
150        path: PathBuf,
151        cause: UnreadableCause,
152        /// Consecutive failed opens recorded so far (0 under [`UnreadablePolicy::Fail`] and for
153        /// storage problems, which are never counted).
154        consecutive_failures: u32,
155        /// The count at which [`UnreadablePolicy::QuarantineAfter`] would set the file aside.
156        quarantine_after: Option<u32>,
157        #[source]
158        source: Box<DbError>,
159    },
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163pub enum CacheProfile {
164    Default,
165    LargeWorkingSet,
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum DurabilityProfile {
170    Full,
171    Normal,
172}
173
174/// How the journal mode is chosen. WAL needs working shared-memory locking, which network
175/// filesystems often lack and which SQLite will silently downgrade; the default therefore
176/// *verifies* the mode that took effect and falls back to a rollback journal.
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178#[non_exhaustive]
179pub enum JournalSelect {
180    /// WAL when the filesystem honors it and is not a detected network mount; else a
181    /// rollback journal (TRUNCATE with [`DbOptions::per_host_fallback`], else DELETE).
182    WalWithFallback,
183    /// WAL or fail (no fallback).
184    WalStrict,
185    /// Rollback journal, deleted after each transaction.
186    Delete,
187    /// Rollback journal, truncated after each transaction.
188    Truncate,
189}
190
191impl JournalSelect {
192    fn is_rollback(self) -> bool {
193        matches!(self, JournalSelect::Delete | JournalSelect::Truncate)
194    }
195}
196
197/// Environment kill-switch (`wal` | `delete` | `truncate`) for a misclassified mount in the
198/// field. An app's own variable ([`DbOptions::journal_env`]) is checked first.
199pub const JOURNAL_ENV: &str = "RIGHTKIT_SQLITE_JOURNAL_MODE";
200
201/// Post-key, pre-pragma per-connection initializer (register functions, collations, ...).
202/// Open-time integrity check, selected by [`DbOptions::integrity`].
203#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
204pub enum IntegrityCheck {
205    /// No check at open (default).
206    #[default]
207    Off,
208    /// `PRAGMA quick_check`: structural page checks, without the index-to-table cross-check. It
209    /// costs far less than `Full` on large databases.
210    Quick,
211    /// `PRAGMA integrity_check`, or `PRAGMA cipher_integrity_check` for a keyed database.
212    Full,
213}
214
215pub type ConnInit = Arc<dyn Fn(&Connection) -> rusqlite::Result<()> + Send + Sync>;
216
217#[derive(Clone)]
218pub struct DbOptions {
219    pub path: PathBuf,
220    /// SQLite busy wait, installed before key verification or any bootstrap statement.
221    /// Default: five seconds.
222    pub busy_timeout: Duration,
223    /// SQLCipher passphrase-style key (base64-ish charset). See also [`DbOptions::raw_key`].
224    pub key: Option<Zeroizing<String>>,
225    /// SQLCipher raw 256-bit key, applied as `x'<hex>'` (no KDF). Takes precedence over `key`.
226    pub raw_key: Option<Zeroizing<[u8; 32]>>,
227    pub profile: CacheProfile,
228    pub durability: DurabilityProfile,
229    pub foreign_keys: bool,
230    /// Legacy switch: `true` runs a full check on every open. Ignored when `integrity` is not `Off`.
231    pub integrity_check_on_open: bool,
232    /// Open-time integrity check (default `Off`). A failed check is [`DbError::Integrity`], which
233    /// `open_writer_recovering` treats as unreadable.
234    pub integrity: IntegrityCheck,
235    pub journal: JournalSelect,
236    /// Narrow the database and its sidecars to owner-only (0600) on unix. Default on. Set it to
237    /// `false` when sibling processes must read the file (for example a helper running as another
238    /// user); that is the intended switch, not a bug.
239    pub owner_only: bool,
240    pub init: Option<ConnInit>,
241    /// The app's own kill-switch variable (e.g. `CODERIGHT_SQLITE_JOURNAL_MODE`), checked
242    /// before [`JOURNAL_ENV`].
243    pub journal_env: Option<String>,
244    /// When a rollback journal is in force (network mount, kill switch, or WAL refused),
245    /// open the per-host sibling [`per_host_path`] instead of the requested file, so hosts
246    /// sharing a network directory never share one rollback-journaled database. The
247    /// requested file is not created. Default off.
248    pub per_host_fallback: bool,
249    /// Sets `PRAGMA secure_delete` on every connection at open: `Some(true)` overwrites freed
250    /// pages, `Some(false)` does not, `None` (default) leaves SQLite's compile-time default.
251    pub secure_delete: Option<bool>,
252    /// Run `PRAGMA optimize` on each writer connection at open (default `true`). Set it to `false`
253    /// to leave planner statistics alone at open, for example when the file is opened only to
254    /// inspect it. Readers never run it, whatever this is set to.
255    pub optimize_on_open: bool,
256}
257
258impl DbOptions {
259    pub fn new(path: impl Into<PathBuf>) -> Self {
260        Self {
261            path: path.into(),
262            busy_timeout: Duration::from_millis(BUSY_TIMEOUT_MS),
263            key: None,
264            raw_key: None,
265            profile: CacheProfile::Default,
266            durability: DurabilityProfile::Normal,
267            foreign_keys: false,
268            integrity_check_on_open: false,
269            integrity: IntegrityCheck::Off,
270            journal: JournalSelect::WalWithFallback,
271            owner_only: true,
272            init: None,
273            journal_env: None,
274            per_host_fallback: false,
275            secure_delete: None,
276            optimize_on_open: true,
277        }
278    }
279
280    pub fn with_journal_env(mut self, name: impl Into<String>) -> Self {
281        self.journal_env = Some(name.into());
282        self
283    }
284
285    pub fn with_per_host_fallback(mut self, on: bool) -> Self {
286        self.per_host_fallback = on;
287        self
288    }
289
290    /// Turn `PRAGMA optimize` off at writer open (see [`DbOptions::optimize_on_open`]).
291    pub fn without_optimize_on_open(mut self) -> Self {
292        self.optimize_on_open = false;
293        self
294    }
295
296    /// The file connections actually open: the requested path, or its per-host sibling when
297    /// [`Self::per_host_fallback`] applies (a rollback journal is in force, or a writer
298    /// already fell back there).
299    pub fn effective_path(&self) -> PathBuf {
300        if !self.per_host_fallback {
301            return self.path.clone();
302        }
303        let sibling = per_host_path(&self.path);
304        if effective_journal(self).is_rollback() || (sibling.exists() && !self.path.exists()) {
305            sibling
306        } else {
307            self.path.clone()
308        }
309    }
310
311    pub fn with_passphrase(mut self, key: impl Into<String>) -> Self {
312        self.key = Some(Zeroizing::new(key.into()));
313        self.raw_key = None;
314        self
315    }
316
317    pub fn with_raw_key(mut self, key: [u8; 32]) -> Self {
318        self.raw_key = Some(Zeroizing::new(key));
319        self.key = None;
320        self
321    }
322
323    pub fn is_keyed(&self) -> bool {
324        self.key.is_some() || self.raw_key.is_some()
325    }
326
327    pub fn open_writer(&self) -> Result<Connection> {
328        self.open(ConnectionKind::Writer)
329    }
330
331    pub fn open_reader(&self) -> Result<Connection> {
332        self.open(ConnectionKind::Reader { cache_kib: None })
333    }
334
335    /// Open exactly `path` read-only, without creating it or running mutating bootstrap.
336    /// URI parameters are passed to SQLite unchanged. Journal fallback, permissions,
337    /// `init`, optimization, and migrations are skipped; key and timeout still apply.
338    pub fn open_read_only(&self) -> Result<Connection> {
339        self.open_existing_at(&self.path, OpenFlags::SQLITE_OPEN_READ_ONLY)
340    }
341
342    /// Open exactly `path` read-write without CREATE or mutating bootstrap.
343    /// Uses the same minimal setup as [`Self::open_read_only`].
344    pub fn open_existing_writer(&self) -> Result<Connection> {
345        self.open_existing_at(&self.path, OpenFlags::SQLITE_OPEN_READ_WRITE)
346    }
347
348    /// Open a private, ephemeral database. `path`, filesystem permissions, journal
349    /// selection/fallback, and mmap are ignored; other bootstrap options still apply.
350    pub fn open_in_memory(&self) -> Result<Connection> {
351        let key = self.key_pragma_value()?;
352        let conn = Connection::open_in_memory()?;
353        self.initialize(&conn, key.as_ref().map(|k| k.as_str()))?;
354        configure_connection(&conn, self, ConnectionKind::Memory, None)?;
355        self.check_on_open(&conn)?;
356        Ok(conn)
357    }
358
359    fn open_existing_at(&self, path: &Path, access: OpenFlags) -> Result<Connection> {
360        let key = self.key_pragma_value()?;
361        let conn = Connection::open_with_flags(
362            path,
363            access | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
364        )?;
365        conn.busy_timeout(self.busy_timeout)?;
366        apply_key_first(&conn, key.as_ref().map(|k| k.as_str()))?;
367        verify_key(&conn)?;
368        self.check_on_open(&conn)?;
369        Ok(conn)
370    }
371
372    fn initialize(&self, conn: &Connection, key: Option<&str>) -> Result<()> {
373        conn.busy_timeout(self.busy_timeout)?;
374        apply_key_first(conn, key)?;
375        verify_key(conn)?;
376        if let Some(init) = &self.init {
377            init(conn)?;
378        }
379        Ok(())
380    }
381
382    /// A read pool with [`ReadPoolOptions::new`]: read-write file handles, with `query_only` set
383    /// and verified on every reader. Same as `open_read_pool_with(cap, ReadPoolOptions::new())`.
384    pub fn open_read_pool(&self, cap: usize) -> Result<ReadPool> {
385        ReadPool::open(self.clone(), cap, ReadPoolOptions::new())
386    }
387
388    /// A read pool configured by [`ReadPoolOptions`]. A read-only pool needs the database to exist.
389    pub fn open_read_pool_with(&self, cap: usize, pool: ReadPoolOptions) -> Result<ReadPool> {
390        ReadPool::open(self.clone(), cap, pool)
391    }
392
393    fn open_pool_reader(&self, pool: ReadPoolOptions) -> Result<Connection> {
394        if pool.read_only {
395            self.open_pool_read_only(pool.cache_kib)
396        } else {
397            self.open(ConnectionKind::Reader {
398                cache_kib: pool.cache_kib,
399            })
400        }
401    }
402
403    /// A pool reader opened with `SQLITE_OPEN_READ_ONLY`, so the file refuses writes even after
404    /// `query_only` is turned off. It never creates the file, changes the journal mode, narrows
405    /// permissions, or runs `optimize`; the writer owns those. `init` runs on each reader.
406    fn open_pool_read_only(&self, cache_kib: Option<u32>) -> Result<Connection> {
407        let path = self.effective_path();
408        let key = self.key_pragma_value()?;
409        let conn = Connection::open_with_flags(
410            &path,
411            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
412        )?;
413        conn.busy_timeout(self.busy_timeout)?;
414        apply_key_first(&conn, key.as_ref().map(|k| k.as_str()))?;
415        verify_key(&conn)?;
416        conn.pragma_update(None, "temp_store", "MEMORY")?;
417        if self.foreign_keys {
418            conn.pragma_update(None, "foreign_keys", true)?;
419        }
420        apply_cache_settings(&conn, self, ConnectionKind::Reader { cache_kib })?;
421        // Per-connection setup (`init`) runs on every reader, read-only ones included, as it does
422        // on the other connection kinds. An `init` that writes fails here with the SQLite error.
423        if let Some(init) = &self.init {
424            init(&conn)?;
425        }
426        enforce_query_only(&conn)?;
427        if !conn.is_readonly("main")? {
428            return Err(DbError::ReadOnlyNotEffective(
429                "SQLITE_OPEN_READ_ONLY handle reports a writable main database".into(),
430            ));
431        }
432        self.check_on_open(&conn)?;
433        Ok(conn)
434    }
435
436    fn open(&self, kind: ConnectionKind) -> Result<Connection> {
437        let path = self.effective_path();
438        // The per-host sibling is always rollback-journaled, whoever opens it.
439        let forced = (path != self.path).then(|| match effective_journal(self) {
440            mode if mode.is_rollback() => mode,
441            _ => fallback_mode(self),
442        });
443        let (conn, journal) = self.open_at(&path, kind, forced)?;
444        // WAL refused at runtime: with per-host fallback, the rollback journal belongs on the
445        // per-host sibling. The main file this open touched stays where it is, with every
446        // `-wal`/`-shm`/`-journal` beside it. Deleting it is never safe: file identity proves
447        // origin, not exclusive lifetime, and another connection may have committed
448        // rollback-journal data into that same file (and closed) before this point, so any
449        // check-then-remove can destroy a committed write. A created-empty main is harmless
450        // (it never holds data this open wrote, and a later open falls back again).
451        if journal == Opened::FellBack && self.per_host_fallback && path == self.path {
452            drop(conn);
453            let sibling = per_host_path(&self.path);
454            return Ok(self
455                .open_at(&sibling, kind, Some(JournalSelect::Truncate))?
456                .0);
457        }
458        Ok(conn)
459    }
460
461    fn open_at(
462        &self,
463        path: &Path,
464        kind: ConnectionKind,
465        forced: Option<JournalSelect>,
466    ) -> Result<(Connection, Opened)> {
467        let key = self.key_pragma_value()?;
468        let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
469            | OpenFlags::SQLITE_OPEN_CREATE
470            | OpenFlags::SQLITE_OPEN_NO_MUTEX;
471        let conn = Connection::open_with_flags(path, flags)?;
472        // A failed open drops `conn`. A default close checkpoints and then deletes the `-wal` and
473        // `-shm`, which destroys the evidence quarantine promises to keep (and rewrites a valid but
474        // corrupt main file). Suppress that until the open has fully succeeded. This is a plain
475        // connection setting, valid before keying; it reads nothing from the file.
476        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, true)?;
477        // Narrow the main file before any write so WAL/SHM sidecars inherit owner-only.
478        if self.owner_only {
479            rightkit_fs::restrict_owner_only(path)?;
480        }
481        self.initialize(&conn, key.as_ref().map(|k| k.as_str()))?;
482        if let Some(on) = self.secure_delete {
483            conn.pragma_update(None, "secure_delete", if on { "ON" } else { "OFF" })?;
484        }
485        let journal = configure_connection(&conn, self, kind, forced)?;
486        self.check_on_open(&conn)?;
487        if self.owner_only {
488            harden_sidecars(path)?;
489        }
490        // The open succeeded: restore the normal close behaviour (checkpoint and remove the WAL).
491        conn.set_db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, false)?;
492        Ok((conn, journal))
493    }
494
495    /// Run the open-time check selected by `integrity`; `integrity_check_on_open` alone means `Full`.
496    fn check_on_open(&self, conn: &Connection) -> Result<()> {
497        let mode = match self.integrity {
498            IntegrityCheck::Off if self.integrity_check_on_open => IntegrityCheck::Full,
499            mode => mode,
500        };
501        match (mode, self.is_keyed()) {
502            (IntegrityCheck::Off, _) => Ok(()),
503            (IntegrityCheck::Quick, _) => run_integrity_pragma(conn, "PRAGMA quick_check"),
504            (IntegrityCheck::Full, true) => {
505                run_integrity_pragma(conn, "PRAGMA cipher_integrity_check")
506            }
507            (IntegrityCheck::Full, false) => run_integrity_pragma(conn, "PRAGMA integrity_check"),
508        }
509    }
510
511    /// The `PRAGMA key` right-hand side (already quoted), validated against injection.
512    fn key_pragma_value(&self) -> Result<Option<Zeroizing<String>>> {
513        if let Some(raw) = &self.raw_key {
514            #[cfg(not(feature = "cipher"))]
515            {
516                let _ = raw;
517                return Err(DbError::SqlcipherFeatureRequired);
518            }
519            #[cfg(feature = "cipher")]
520            {
521                let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
522                return Ok(Some(Zeroizing::new(format!("\"x'{hex}'\""))));
523            }
524        }
525        let key = self.key.as_ref().map(|key| key.as_str());
526        validate_key(key)?;
527        Ok(key.map(|k| Zeroizing::new(format!("'{k}'"))))
528    }
529}
530
531/// Default-options read-only open; never creates a missing database.
532pub fn open_read_only(path: impl AsRef<Path>) -> Result<Connection> {
533    DbOptions::new(path.as_ref()).open_read_only()
534}
535
536/// Default-options read-write open; never creates a missing database.
537pub fn open_existing_writer(path: impl AsRef<Path>) -> Result<Connection> {
538    DbOptions::new(path.as_ref()).open_existing_writer()
539}
540
541/// Default-options private in-memory database.
542pub fn open_in_memory() -> Result<Connection> {
543    DbOptions::new(":memory:").open_in_memory()
544}
545
546fn harden_sidecars(db: &Path) -> Result<()> {
547    for suffix in ["-wal", "-shm"] {
548        let mut name = OsString::from(db.file_name().ok_or(DbError::InvalidDatabasePath)?);
549        name.push(suffix);
550        rightkit_fs::restrict_owner_only(&db.with_file_name(name))?;
551    }
552    Ok(())
553}
554
555#[derive(Clone, Copy)]
556enum ConnectionKind {
557    Writer,
558    /// `cache_kib` overrides the reader cache size; see [`ReadPoolOptions::with_reader_cache_kib`].
559    Reader {
560        cache_kib: Option<u32>,
561    },
562    Memory,
563}
564
565impl ConnectionKind {
566    fn reader_cache_kib(self) -> Option<u32> {
567        match self {
568            ConnectionKind::Reader { cache_kib } => cache_kib,
569            ConnectionKind::Writer | ConnectionKind::Memory => None,
570        }
571    }
572}
573
574/// What journal setup did.
575#[derive(Clone, Copy, Debug, Eq, PartialEq)]
576enum Opened {
577    AsRequested,
578    /// WAL was wanted but not honored; a rollback journal is in force.
579    FellBack,
580}
581
582fn configure_connection(
583    conn: &Connection,
584    options: &DbOptions,
585    kind: ConnectionKind,
586    forced: Option<JournalSelect>,
587) -> Result<Opened> {
588    let opened = if matches!(kind, ConnectionKind::Memory) {
589        Opened::AsRequested
590    } else {
591        configure_journal(conn, options, kind, forced)?
592    };
593    let synchronous = match options.durability {
594        DurabilityProfile::Full => "FULL",
595        DurabilityProfile::Normal => "NORMAL",
596    };
597    conn.pragma_update(None, "synchronous", synchronous)?;
598    conn.pragma_update(None, "journal_size_limit", JOURNAL_SIZE_LIMIT)?;
599    conn.pragma_update(None, "temp_store", "MEMORY")?;
600    if options.foreign_keys {
601        conn.pragma_update(None, "foreign_keys", true)?;
602    }
603    apply_cache_settings(conn, options, kind)?;
604    if matches!(kind, ConnectionKind::Reader { .. }) {
605        // Readers never run `PRAGMA optimize`: it can write statistics, and the writer runs it
606        // when it opens. `query_only` is set after every other reader setup statement.
607        enforce_query_only(conn)?;
608    } else if options.optimize_on_open {
609        optimize_on_open(conn)?;
610    }
611    Ok(opened)
612}
613
614/// Profile cache and mmap sizes, then the reader override, which wins over the profile on readers
615/// only. Writers and in-memory connections keep the profile size.
616fn apply_cache_settings(
617    conn: &Connection,
618    options: &DbOptions,
619    kind: ConnectionKind,
620) -> Result<()> {
621    if options.profile == CacheProfile::LargeWorkingSet {
622        conn.pragma_update(None, "cache_size", -65_536_i64)?;
623        if !matches!(kind, ConnectionKind::Memory) {
624            conn.pragma_update(None, "mmap_size", 268_435_456_i64)?;
625        }
626    }
627    if let Some(kib) = kind.reader_cache_kib() {
628        conn.pragma_update(None, "cache_size", -i64::from(kib))?;
629    }
630    Ok(())
631}
632
633/// Sets `PRAGMA query_only = ON` on a reader and reads it back, so a reader that did not come up
634/// query-only is never handed out.
635fn enforce_query_only(conn: &Connection) -> Result<()> {
636    conn.pragma_update(None, "query_only", true)?;
637    let on: i64 = conn.query_row("PRAGMA query_only", [], |row| row.get(0))?;
638    if on != 1 {
639        return Err(DbError::ReadOnlyNotEffective(
640            "PRAGMA query_only did not stay ON".into(),
641        ));
642    }
643    Ok(())
644}
645
646fn set_rollback(conn: &Connection, mode: JournalSelect) -> Result<()> {
647    let pragma = if mode == JournalSelect::Truncate {
648        "PRAGMA journal_mode=TRUNCATE"
649    } else {
650        "PRAGMA journal_mode=DELETE"
651    };
652    conn.query_row(pragma, [], |row| row.get::<_, String>(0))?;
653    Ok(())
654}
655
656fn configure_journal(
657    conn: &Connection,
658    options: &DbOptions,
659    kind: ConnectionKind,
660    forced: Option<JournalSelect>,
661) -> Result<Opened> {
662    let wanted = forced.unwrap_or_else(|| effective_journal(options));
663    if wanted.is_rollback() {
664        // Rollback modes are per connection: a reader left in the default DELETE mode would
665        // try to delete a TRUNCATE writer's open journal (IOERR_DELETE on Windows).
666        set_rollback(conn, wanted)?;
667        return Ok(Opened::AsRequested);
668    }
669    let honored = conn
670        .query_row("PRAGMA journal_mode=WAL", [], |row| row.get::<_, String>(0))
671        .map(|mode| mode.eq_ignore_ascii_case("wal"))
672        .unwrap_or(false)
673        // `journal_mode=WAL` can report success before the -shm/-wal sidecars are usable;
674        // one empty write transaction proves the sidecars work before anything depends on them.
675        && (matches!(kind, ConnectionKind::Reader { .. })
676            || conn.execute_batch("BEGIN IMMEDIATE; ROLLBACK;").is_ok());
677    if honored {
678        return Ok(Opened::AsRequested);
679    }
680    if wanted == JournalSelect::WalStrict {
681        return Err(DbError::Sqlite(rusqlite::Error::SqliteFailure(
682            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
683            Some("WAL journal mode was not honored by this filesystem".into()),
684        )));
685    }
686    let _ = conn.execute_batch("ROLLBACK;");
687    set_rollback(conn, fallback_mode(options))?;
688    Ok(Opened::FellBack)
689}
690
691/// The rollback journal used when WAL is unavailable: TRUNCATE with per-host fallback
692/// (CodeRight's network mode), DELETE otherwise.
693fn fallback_mode(options: &DbOptions) -> JournalSelect {
694    if options.per_host_fallback {
695        JournalSelect::Truncate
696    } else {
697        JournalSelect::Delete
698    }
699}
700
701/// Kill switches (the app's own variable first, then [`JOURNAL_ENV`]); set-but-empty or
702/// unrecognized values are ignored.
703fn env_journal(options: &DbOptions) -> Option<JournalSelect> {
704    options
705        .journal_env
706        .iter()
707        .map(String::as_str)
708        .chain([JOURNAL_ENV])
709        .find_map(|name| {
710            match std::env::var(name)
711                .ok()?
712                .trim()
713                .to_ascii_lowercase()
714                .as_str()
715            {
716                "wal" => Some(JournalSelect::WalStrict),
717                "delete" => Some(JournalSelect::Delete),
718                "truncate" => Some(JournalSelect::Truncate),
719                _ => None,
720            }
721        })
722}
723
724fn effective_journal(options: &DbOptions) -> JournalSelect {
725    if let Some(mode) = env_journal(options) {
726        return mode;
727    }
728    if options.journal == JournalSelect::WalWithFallback
729        && rightkit_fs::is_network_filesystem(&options.path)
730    {
731        return fallback_mode(options);
732    }
733    options.journal
734}
735
736/// The journal mode SQLite is actually running (`wal`, `delete`, ...).
737pub fn journal_mode(conn: &Connection) -> Result<String> {
738    Ok(conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))?)
739}
740
741/// Start a transaction that reserves SQLite's single writer slot up front.
742/// This keeps read-then-write work out of the unretryable BUSY_SNAPSHOT path.
743pub fn write_txn(conn: &mut Connection) -> Result<Transaction<'_>> {
744    conn.transaction_with_behavior(TransactionBehavior::Immediate)
745        .map_err(Into::into)
746}
747
748/// [`write_txn`] for a caller that holds only `&Connection`, such as a closure-style helper.
749/// It runs `BEGIN IMMEDIATE` through `Transaction::new_unchecked`, so the reserved writer slot is
750/// the same. The borrow checker does not stop a nested transaction here: SQLite refuses the
751/// `BEGIN` instead. The transaction rolls back when dropped unless it is committed.
752pub fn write_txn_unchecked(conn: &Connection) -> Result<Transaction<'_>> {
753    Transaction::new_unchecked(conn, TransactionBehavior::Immediate).map_err(Into::into)
754}
755
756#[cfg(feature = "cipher")]
757fn apply_key_first(conn: &Connection, key: Option<&str>) -> Result<()> {
758    if let Some(key) = key {
759        let statement = Zeroizing::new(format!("PRAGMA key = {key};"));
760        conn.execute_batch(statement.as_str())?;
761    }
762    Ok(())
763}
764
765#[cfg(not(feature = "cipher"))]
766fn apply_key_first(_conn: &Connection, key: Option<&str>) -> Result<()> {
767    if key.is_some() {
768        return Err(DbError::SqlcipherFeatureRequired);
769    }
770    Ok(())
771}
772
773fn validate_key(key: Option<&str>) -> Result<()> {
774    #[cfg(not(feature = "cipher"))]
775    if key.is_some() {
776        return Err(DbError::SqlcipherFeatureRequired);
777    }
778    if let Some(key) = key {
779        let safe = !key.is_empty()
780            && key.len() <= 4_096
781            && key.bytes().all(|byte| {
782                byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_')
783            });
784        if !safe {
785            return Err(DbError::InvalidSqlcipherKey);
786        }
787    }
788    Ok(())
789}
790
791fn verify_key(conn: &Connection) -> Result<()> {
792    conn.query_row("SELECT count(*) FROM sqlite_master", [], |row| {
793        row.get::<_, i64>(0)
794    })?;
795    Ok(())
796}
797
798fn run_integrity_pragma(conn: &Connection, pragma: &str) -> Result<()> {
799    let mut statement = conn.prepare(pragma)?;
800    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
801    let messages = rows.collect::<std::result::Result<Vec<_>, _>>()?;
802    if messages.is_empty()
803        || messages
804            .iter()
805            .all(|message| message.eq_ignore_ascii_case("ok"))
806    {
807        return Ok(());
808    }
809    Err(DbError::Integrity(messages.join("; ")))
810}
811
812/// Options for a [`ReadPool`]. The default ([`ReadPoolOptions::new`]) matches
813/// [`DbOptions::open_read_pool`]: read-write file handles, with `query_only` set and verified on
814/// every reader. Build with the `with_*` methods; the fields are private.
815#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
816pub struct ReadPoolOptions {
817    read_only: bool,
818    cache_kib: Option<u32>,
819}
820
821impl ReadPoolOptions {
822    pub fn new() -> Self {
823        Self::default()
824    }
825
826    /// Opens each reader with `SQLITE_OPEN_READ_ONLY`, so writes fail even if `query_only` is later
827    /// turned off. The database must already exist: the pool never creates it, changes its journal
828    /// mode, narrows its permissions, or runs `optimize`. Each reader still runs the `init` hook,
829    /// as on the other connections. A rollback journal left by a crash is recovered by a writer,
830    /// not by a read-only reader. Default off.
831    pub fn with_read_only(mut self, on: bool) -> Self {
832        self.read_only = on;
833        self
834    }
835
836    /// Page cache size for each reader connection, in KiB. It is applied as `PRAGMA cache_size`
837    /// set to `-kib`, so the size does not depend on the page size. Valid values are 1 to
838    /// 2147483647; the pool checks this when it opens. It overrides
839    /// [`CacheProfile::LargeWorkingSet`] on readers only. Writers and [`DbOptions::open_reader`]
840    /// keep the profile size. Default: the profile's size.
841    pub fn with_reader_cache_kib(mut self, kib: u32) -> Self {
842        self.cache_kib = Some(kib);
843        self
844    }
845}
846
847/// A bounded pool of reader connections. Clones share one pool.
848#[derive(Clone)]
849pub struct ReadPool {
850    inner: Arc<ReadPoolInner>,
851}
852
853/// Opens one reader connection. A pool built from [`DbOptions`] uses that database's reader
854/// setup; [`ReadPool::from_opener`] supplies its own.
855type ReaderOpener = Box<dyn Fn() -> Result<Connection> + Send + Sync>;
856
857struct ReadPoolInner {
858    open: ReaderOpener,
859    cap: usize,
860    state: Mutex<ReadPoolState>,
861    /// Wakes a `get` waiting for capacity, and every waiter when the pool closes.
862    available: Condvar,
863    /// Wakes `close_and_wait` when a connection is released after the pool closed.
864    drained: Condvar,
865}
866
867#[derive(Default)]
868struct ReadPoolState {
869    idle: Vec<Connection>,
870    /// Idle, checked-out, and still-opening connections.
871    total: usize,
872    closed: bool,
873}
874
875impl ReadPoolInner {
876    fn open_reader(&self) -> Result<Connection> {
877        (self.open)()
878    }
879}
880
881pub struct PooledReadConnection {
882    conn: Option<Connection>,
883    pool: Weak<ReadPoolInner>,
884    // Managed lifecycle checkout retains its exact generation/guard until connection return.
885    _lifecycle_lease: Option<Arc<dyn Send + Sync>>,
886}
887
888impl PooledReadConnection {
889    #[cfg(feature = "encrypted-lifecycle")]
890    pub(crate) fn with_lifecycle_lease(mut self, lease: Arc<dyn Send + Sync>) -> Self {
891        self._lifecycle_lease = Some(lease);
892        self
893    }
894}
895
896impl Deref for PooledReadConnection {
897    type Target = Connection;
898
899    fn deref(&self) -> &Self::Target {
900        self.conn.as_ref().expect("pooled connection is present")
901    }
902}
903
904impl Drop for PooledReadConnection {
905    fn drop(&mut self) {
906        let Some(conn) = self.conn.take() else {
907            return;
908        };
909        let Some(pool) = self.pool.upgrade() else {
910            return;
911        };
912        let mut state = lock_recover(&pool.state);
913        if state.closed {
914            // Close the file before the count drops, so `close_and_wait` returns only once it is
915            // released. Checked under the same lock as `close`, so no connection is parked idle
916            // after the pool has closed.
917            drop(conn);
918            state.total -= 1;
919            pool.drained.notify_all();
920        } else {
921            state.idle.push(conn);
922            pool.available.notify_one();
923        }
924    }
925}
926
927impl ReadPool {
928    fn open(options: DbOptions, cap: usize, pool_options: ReadPoolOptions) -> Result<Self> {
929        if cap == 0 {
930            return Err(DbError::InvalidPoolCapacity);
931        }
932        if let Some(kib) = pool_options.cache_kib {
933            if kib == 0 || kib > i32::MAX.unsigned_abs() {
934                return Err(DbError::InvalidReaderCacheSize(kib));
935            }
936        }
937        Self::with_opener(
938            cap,
939            Box::new(move || options.open_pool_reader(pool_options)),
940        )
941    }
942
943    /// A pool whose readers come from `open`, called once now to check it works and again for
944    /// each new reader. The pool enforces `cap` and the closed and wait rules; `query_only` and
945    /// other reader setup are the opener's job. Intended for tests that need a pool over a
946    /// connection they build themselves. Use [`DbOptions::open_read_pool`] for a real database.
947    pub fn from_opener(
948        cap: usize,
949        open: impl Fn() -> Result<Connection> + Send + Sync + 'static,
950    ) -> Result<Self> {
951        Self::with_opener(cap, Box::new(open))
952    }
953
954    fn with_opener(cap: usize, open: ReaderOpener) -> Result<Self> {
955        if cap == 0 {
956            return Err(DbError::InvalidPoolCapacity);
957        }
958        let pool = Self {
959            inner: Arc::new(ReadPoolInner {
960                open,
961                cap,
962                state: Mutex::new(ReadPoolState::default()),
963                available: Condvar::new(),
964                drained: Condvar::new(),
965            }),
966        };
967        let conn = pool.inner.open_reader()?;
968        let mut state = lock_recover(&pool.inner.state);
969        state.total = 1;
970        state.idle.push(conn);
971        drop(state);
972        Ok(pool)
973    }
974
975    /// Checks out an idle reader, opens a new one while under the cap, or waits for a return.
976    /// Fails with [`DbError::ReadPoolClosed`] once the pool is closed, including while waiting.
977    /// The wait has no bound; use [`Self::get_timeout`] to give up after a deadline.
978    pub fn get(&self) -> Result<PooledReadConnection> {
979        match self.checkout(None)? {
980            Some(reader) => Ok(reader),
981            // Unreachable: an unbounded wait ends only with a reader or an error.
982            None => Err(DbError::ReadPoolClosed),
983        }
984    }
985
986    /// [`Self::get`] with a bounded wait. Returns `Ok(Some(reader))` as soon as a reader is idle
987    /// or can be opened under the cap, and `Ok(None)` if none frees up within `timeout`. A zero
988    /// `timeout` never waits: it returns an idle or newly opened reader, or `Ok(None)`. If the
989    /// pool is closed, or closes while waiting, the result is [`DbError::ReadPoolClosed`], never
990    /// `Ok(None)`. Opening a new reader is not bounded by `timeout`. A timeout too large for the
991    /// clock waits in bounded slices until a reader frees up or the pool closes.
992    pub fn get_timeout(&self, timeout: Duration) -> Result<Option<PooledReadConnection>> {
993        self.checkout(Some(timeout))
994    }
995
996    /// Shared checkout. `wait` is `None` for no deadline.
997    fn checkout(&self, wait: Option<Duration>) -> Result<Option<PooledReadConnection>> {
998        let started = Instant::now();
999        let deadline = wait.and_then(|wait| started.checked_add(wait));
1000        let mut state = lock_recover(&self.inner.state);
1001        loop {
1002            if state.closed {
1003                return Err(DbError::ReadPoolClosed);
1004            }
1005            if let Some(conn) = state.idle.pop() {
1006                return Ok(Some(PooledReadConnection {
1007                    conn: Some(conn),
1008                    pool: Arc::downgrade(&self.inner),
1009                    _lifecycle_lease: None,
1010                }));
1011            }
1012            if state.total < self.inner.cap {
1013                state.total += 1;
1014                drop(state);
1015                return self.open_checked_out().map(Some);
1016            }
1017            state = match (wait, deadline) {
1018                (None, _) => match self.inner.available.wait(state) {
1019                    Ok(state) => state,
1020                    Err(poisoned) => poisoned.into_inner(),
1021                },
1022                (Some(_), Some(at)) => {
1023                    let remaining = at.saturating_duration_since(Instant::now());
1024                    if remaining.is_zero() {
1025                        return Ok(None);
1026                    }
1027                    match self.inner.available.wait_timeout(state, remaining) {
1028                        Ok((state, _)) => state,
1029                        Err(poisoned) => poisoned.into_inner().0,
1030                    }
1031                }
1032                (Some(_), None) => {
1033                    match self
1034                        .inner
1035                        .available
1036                        .wait_timeout(state, Duration::from_secs(60))
1037                    {
1038                        Ok((state, _)) => state,
1039                        Err(poisoned) => poisoned.into_inner().0,
1040                    }
1041                }
1042            };
1043        }
1044    }
1045
1046    /// Opens a reader for a `get` that reserved a slot. If the pool closed meanwhile, the new
1047    /// reader is dropped and the caller gets [`DbError::ReadPoolClosed`].
1048    fn open_checked_out(&self) -> Result<PooledReadConnection> {
1049        match self.inner.open_reader() {
1050            Ok(conn) => {
1051                let mut state = lock_recover(&self.inner.state);
1052                if state.closed {
1053                    drop(conn);
1054                    state.total -= 1;
1055                    self.inner.drained.notify_all();
1056                    return Err(DbError::ReadPoolClosed);
1057                }
1058                Ok(PooledReadConnection {
1059                    conn: Some(conn),
1060                    pool: Arc::downgrade(&self.inner),
1061                    _lifecycle_lease: None,
1062                })
1063            }
1064            Err(error) => {
1065                let mut state = lock_recover(&self.inner.state);
1066                state.total -= 1;
1067                self.inner.available.notify_one();
1068                self.inner.drained.notify_all();
1069                Err(error)
1070            }
1071        }
1072    }
1073
1074    /// Stops handing out readers and drops the idle ones now. A reader still checked out closes
1075    /// when it is returned. Calling it again does nothing.
1076    pub fn close(&self) {
1077        let idle = {
1078            let mut state = lock_recover(&self.inner.state);
1079            if state.closed {
1080                return;
1081            }
1082            state.closed = true;
1083            self.inner.available.notify_all();
1084            std::mem::take(&mut state.idle)
1085        };
1086        let closed = idle.len();
1087        // Files close before the count drops, so `close_and_wait` sees them released.
1088        drop(idle);
1089        let mut state = lock_recover(&self.inner.state);
1090        state.total -= closed;
1091        self.inner.drained.notify_all();
1092    }
1093
1094    /// [`Self::close`], then waits up to `timeout` for every checked-out reader to be returned.
1095    /// `Ok(())` means every connection is closed and its database file released. Otherwise it
1096    /// returns [`DbError::ReadPoolCloseTimeout`] with the number still out. Do not call it while
1097    /// holding a reader from this pool: that reader cannot come back, so the call times out. A
1098    /// timeout too large for the clock waits in bounded slices until the readers return.
1099    pub fn close_and_wait(&self, timeout: Duration) -> Result<()> {
1100        self.close();
1101        let deadline = Instant::now().checked_add(timeout);
1102        let mut state = lock_recover(&self.inner.state);
1103        loop {
1104            if state.total == 0 {
1105                return Ok(());
1106            }
1107            let remaining = match deadline {
1108                Some(at) => at.saturating_duration_since(Instant::now()),
1109                None => Duration::from_secs(60),
1110            };
1111            if remaining.is_zero() {
1112                return Err(DbError::ReadPoolCloseTimeout {
1113                    outstanding: state.total,
1114                    timeout,
1115                });
1116            }
1117            let (next, _) = match self.inner.drained.wait_timeout(state, remaining) {
1118                Ok(pair) => pair,
1119                Err(poisoned) => poisoned.into_inner(),
1120            };
1121            state = next;
1122        }
1123    }
1124
1125    pub fn capacity(&self) -> usize {
1126        self.inner.cap
1127    }
1128
1129    pub fn idle_len(&self) -> usize {
1130        lock_recover(&self.inner.state).idle.len()
1131    }
1132
1133    pub fn total_len(&self) -> usize {
1134        lock_recover(&self.inner.state).total
1135    }
1136}
1137
1138fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1139    mutex
1140        .lock()
1141        .unwrap_or_else(|poisoned| poisoned.into_inner())
1142}
1143
1144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1145pub struct CheckpointStatus {
1146    pub busy: i64,
1147    pub log_frames: i64,
1148    pub checkpointed_frames: i64,
1149}
1150
1151pub fn checkpoint_truncate(conn: &Connection) -> Result<CheckpointStatus> {
1152    checkpoint(conn, "PRAGMA wal_checkpoint(TRUNCATE)")
1153}
1154
1155/// `PRAGMA wal_checkpoint(RESTART)`: copies the log into the main file like PASSIVE, waits (under
1156/// the busy timeout) for readers still using the log, then the next writer starts it again from
1157/// the beginning. The log file keeps its size. Use it when the database is quiet and the log is
1158/// large; [`WalCheckpointJob`] decides when.
1159pub fn checkpoint_restart(conn: &Connection) -> Result<CheckpointStatus> {
1160    checkpoint(conn, "PRAGMA wal_checkpoint(RESTART)")
1161}
1162
1163pub fn checkpoint_if_wal_larger_than(
1164    conn: &Connection,
1165    db: &Path,
1166    bytes: u64,
1167) -> Result<Option<CheckpointStatus>> {
1168    let wal = wal_path(db)?;
1169    let size = match std::fs::metadata(wal) {
1170        Ok(metadata) => metadata.len(),
1171        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1172        Err(_) => return Ok(None),
1173    };
1174    if size <= bytes {
1175        return Ok(None);
1176    }
1177    checkpoint(conn, "PRAGMA wal_checkpoint(PASSIVE)").map(Some)
1178}
1179
1180fn checkpoint(conn: &Connection, pragma: &str) -> Result<CheckpointStatus> {
1181    conn.query_row(pragma, [], |row| {
1182        Ok(CheckpointStatus {
1183            busy: row.get(0)?,
1184            log_frames: row.get(1)?,
1185            checkpointed_frames: row.get(2)?,
1186        })
1187    })
1188    .map_err(Into::into)
1189}
1190
1191fn wal_path(db: &Path) -> Result<PathBuf> {
1192    let file_name = db.file_name().ok_or(DbError::InvalidDatabasePath)?;
1193    let mut wal_name = OsString::from(file_name);
1194    wal_name.push("-wal");
1195    Ok(db.with_file_name(wal_name))
1196}
1197
1198pub fn optimize_on_open(conn: &Connection) -> Result<()> {
1199    conn.execute_batch("PRAGMA optimize=0x10002;")?;
1200    Ok(())
1201}
1202
1203pub fn optimize_boundary(conn: &Connection) -> Result<()> {
1204    conn.execute_batch("PRAGMA optimize;")?;
1205    Ok(())
1206}