Skip to main content

linkmarks_core/
storage.rs

1//! SQLite store initialization with WAL and contention hardening.
2use crate::errors::CoreError;
3use rusqlite::Connection;
4use std::path::Path;
5
6/// SQLite lock wait timeout in milliseconds.
7pub const BUSY_TIMEOUT_MS: u32 = 5000;
8
9/// Opens a file-backed SQLite connection with hardened pragmas.
10pub fn open(path: &Path) -> Result<Connection, CoreError> {
11    let conn = Connection::open(path).map_err(|e| CoreError::Storage(e.to_string()))?;
12    apply_pragmas(&conn)?;
13    Ok(conn)
14}
15
16/// Opens an in-memory SQLite connection with hardened pragmas.
17pub fn open_in_memory() -> Result<Connection, CoreError> {
18    let conn = Connection::open_in_memory().map_err(|e| CoreError::Storage(e.to_string()))?;
19    apply_pragmas(&conn)?;
20    Ok(conn)
21}
22
23fn apply_pragmas(conn: &Connection) -> Result<(), CoreError> {
24    conn.execute_batch(&format!(
25        "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; PRAGMA temp_store = MEMORY;"
26    )).map_err(|e| CoreError::Storage(e.to_string()))?;
27    Ok(())
28}
29
30/// Read the current `PRAGMA user_version` from a connection. Returns
31/// `0` on a fresh DB. Thin re-export of [`crate::migrator::current_user_version`]
32/// kept here for callers that only depend on the storage layer.
33pub fn current_user_version(conn: &Connection) -> Result<i64, CoreError> {
34    conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
35        .map_err(|e| CoreError::Storage(format!("read user_version: {e}")))
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41    use rusqlite::Connection;
42    use std::thread;
43    use tempfile::tempdir;
44
45    #[test]
46    fn opens_with_wal_mode() {
47        let dir = tempdir().unwrap();
48        let conn = open(&dir.path().join("test.db")).unwrap();
49        let mode: String = conn
50            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
51            .unwrap();
52        assert_eq!(mode.to_lowercase(), "wal");
53    }
54
55    #[test]
56    fn busy_timeout_configured() {
57        let conn = open_in_memory().unwrap();
58        let timeout: i64 = conn
59            .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
60            .unwrap();
61        assert_eq!(timeout, BUSY_TIMEOUT_MS as i64);
62    }
63
64    #[test]
65    fn foreign_keys_enabled() {
66        let conn = open_in_memory().unwrap();
67        let fk: i64 = conn
68            .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
69            .unwrap();
70        assert_eq!(fk, 1);
71    }
72
73    #[test]
74    fn concurrent_readers_do_not_block_writer() {
75        // WAL enables concurrent readers + 1 writer; the busy_timeout
76        // ensures waiters don't error out instantly. This test exercises
77        // the contract: a writer thread commits while a reader thread
78        // sees pre-commit data via the snapshot MVCC.
79        let dir = tempdir().unwrap();
80        let path = dir.path().join("concurrent.db");
81        let setup = open(&path).unwrap();
82        setup
83            .execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
84            .unwrap();
85        drop(setup);
86
87        let path_w = path.clone();
88        let writer = thread::spawn(move || {
89            let conn: Connection = open(&path_w).unwrap();
90            conn.execute("INSERT INTO t VALUES (2)", []).unwrap();
91        });
92
93        // Reader: open a fresh connection mid-write. With WAL the
94        // reader sees the committed state at connection-open time and
95        // does not block.
96        let reader = open(&path).unwrap();
97        let count: i64 = reader
98            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
99            .unwrap();
100        assert!(count >= 1, "reader starved: count={count}");
101
102        writer.join().expect("writer thread panicked");
103
104        // After writer completes, reader can re-query and see the new row.
105        let final_count: i64 = reader
106            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
107            .unwrap();
108        assert_eq!(final_count, 2);
109    }
110
111    #[test]
112    fn synchronous_normal_with_wal_is_safe() {
113        // Smoke check: `synchronous=NORMAL` with WAL does NOT corrupt
114        // on a clean shutdown. We assert the pragma is set; durability
115        // guarantees are SQLite-documented, not re-derived here.
116        let conn = open_in_memory().unwrap();
117        let sync: i64 = conn
118            .query_row("PRAGMA synchronous", [], |r| r.get(0))
119            .unwrap();
120        // 0=OFF, 1=NORMAL, 2=FULL. With WAL the NORMAL level is the
121        // documented sweet spot.
122        assert_eq!(sync, 1, "expected synchronous=NORMAL (1)");
123    }
124
125    #[test]
126    fn temp_store_in_memory() {
127        let conn = open_in_memory().unwrap();
128        let mode: i64 = conn
129            .query_row("PRAGMA temp_store", [], |r| r.get(0))
130            .unwrap();
131        // 0=DEFAULT, 1=FILE, 2=MEMORY
132        assert_eq!(mode, 2, "expected temp_store=MEMORY (2)");
133    }
134}