Skip to main content

mempill_sqlite/
connection.rs

1//! Connection lifecycle for mempill-sqlite.
2//!
3//! Every connection — whether backed by a file or opened in-memory — MUST have the
4//! mandatory PRAGMAs applied **before** migrations run or any write is served.
5//!
6//! # PRAGMA contract
7//!
8//! ```sql
9//! PRAGMA journal_mode = WAL;      -- WAL for concurrent reads during writes
10//! PRAGMA synchronous  = FULL;     -- full-durability write path (WAL+NORMAL can lose writes on power loss)
11//! PRAGMA foreign_keys = ON;       -- enforce FK constraints from v1_initial.sql
12//! ```
13//!
14//! ## In-memory WAL caveat
15//! SQLite silently downgrades `journal_mode` to `memory` for `:memory:` connections
16//! because WAL requires a real file (it writes a `-wal` and `-shm` sidecar).
17//! This is expected and documented behaviour. The durability guarantees (`synchronous=FULL`
18//! and `foreign_keys=ON`) are still applied and tested for in-memory connections.
19//! WAL mode is tested separately against a temporary file-backed database.
20
21use rusqlite::{Connection, Result as SqlResult};
22
23use crate::migrations;
24
25/// Open a **file-backed** SQLite connection at `path`, apply mandatory PRAGMAs, then run
26/// any pending migrations.
27///
28/// This is the production path for per-agent_id databases (one file per agent).
29pub fn open(path: &str) -> Result<Connection, crate::SqliteStoreError> {
30    let conn = Connection::open(path)?;
31    apply_pragmas(&conn)?;
32    migrations::apply_migrations(&conn)?;
33    Ok(conn)
34}
35
36/// Open an **in-memory** SQLite connection, apply mandatory PRAGMAs (except WAL — see
37/// module-level caveat), then run migrations.
38///
39/// Used for tests and ephemeral engine contexts.
40pub fn open_in_memory() -> Result<Connection, crate::SqliteStoreError> {
41    let conn = Connection::open_in_memory()?;
42    apply_pragmas(&conn)?;
43    migrations::apply_migrations(&conn)?;
44    Ok(conn)
45}
46
47/// Apply the mandatory connection-level PRAGMAs.
48///
49/// Must be called before any DDL or DML on a freshly-opened connection.
50/// The order matters: `foreign_keys=ON` must precede any INSERT that references FKs.
51fn apply_pragmas(conn: &Connection) -> SqlResult<()> {
52    // journal_mode returns the active mode as a result row; we discard it.
53    // For :memory: connections SQLite returns "memory" instead of "wal" — expected.
54    conn.execute_batch(
55        "PRAGMA journal_mode = WAL;\
56         PRAGMA synchronous  = FULL;\
57         PRAGMA foreign_keys = ON;",
58    )?;
59    Ok(())
60}
61
62// ── Tests ──────────────────────────────────────────────────────────────────────
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use std::fs;
68
69    /// Query a single-column, single-row PRAGMA and return the string value.
70    fn pragma_str(conn: &Connection, pragma: &str) -> String {
71        conn.query_row(
72            &format!("PRAGMA {pragma}"),
73            [],
74            |row| row.get::<_, String>(0),
75        )
76        .unwrap_or_else(|_| String::new())
77    }
78
79    /// Query a PRAGMA that returns an integer.
80    fn pragma_int(conn: &Connection, pragma: &str) -> i64 {
81        conn.query_row(
82            &format!("PRAGMA {pragma}"),
83            [],
84            |row| row.get::<_, i64>(0),
85        )
86        .unwrap_or(-1)
87    }
88
89    // ── in-memory PRAGMA tests ────────────────────────────────────────────────
90
91    /// `synchronous=FULL` corresponds to integer value 2 in SQLite.
92    /// SQLite synchronous levels: 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA.
93    #[test]
94    fn in_memory_synchronous_is_full() {
95        let conn = open_in_memory().expect("in-memory open should succeed");
96        let sync_val = pragma_int(&conn, "synchronous");
97        assert_eq!(sync_val, 2, "synchronous must be FULL (2) on in-memory connection");
98    }
99
100    #[test]
101    fn in_memory_foreign_keys_is_on() {
102        let conn = open_in_memory().expect("in-memory open should succeed");
103        let fk_val = pragma_int(&conn, "foreign_keys");
104        assert_eq!(fk_val, 1, "foreign_keys must be ON (1) on in-memory connection");
105    }
106
107    /// WAL is not possible on :memory: — SQLite returns "memory". Document + assert.
108    #[test]
109    fn in_memory_journal_mode_is_memory_not_wal() {
110        let conn = open_in_memory().expect("in-memory open should succeed");
111        let mode = pragma_str(&conn, "journal_mode");
112        // "memory" is the expected value; "wal" is structurally impossible for :memory:.
113        // This test documents the known caveat (see module-level doc).
114        assert_eq!(
115            mode, "memory",
116            "in-memory SQLite must use journal_mode=memory (WAL not supported on :memory:)"
117        );
118    }
119
120    // ── file-backed PRAGMA tests (WAL) ────────────────────────────────────────
121
122    #[test]
123    fn file_backed_journal_mode_is_wal() {
124        let dir = tempfile::tempdir().expect("tempdir should create");
125        let path = dir.path().join("test_wal.db");
126        let path_str = path.to_str().unwrap();
127
128        let conn = open(path_str).expect("file-backed open should succeed");
129        // After PRAGMA journal_mode=WAL, rusqlite executes it and SQLite returns the new mode.
130        // We need to re-query it because execute_batch discards the result row.
131        let mode = pragma_str(&conn, "journal_mode");
132        assert_eq!(mode, "wal", "file-backed connection must be WAL");
133
134        drop(conn);
135        // Clean up WAL sidecar files.
136        let _ = fs::remove_file(path.with_extension("db-wal"));
137        let _ = fs::remove_file(path.with_extension("db-shm"));
138    }
139
140    #[test]
141    fn file_backed_synchronous_is_full() {
142        let dir = tempfile::tempdir().expect("tempdir should create");
143        let path = dir.path().join("test_sync.db");
144        let conn = open(path.to_str().unwrap()).expect("file-backed open should succeed");
145        let sync_val = pragma_int(&conn, "synchronous");
146        // SQLite synchronous levels: 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA.
147        assert_eq!(sync_val, 2, "synchronous must be FULL (2) on file-backed connection");
148    }
149
150    #[test]
151    fn file_backed_foreign_keys_is_on() {
152        let dir = tempfile::tempdir().expect("tempdir should create");
153        let path = dir.path().join("test_fk.db");
154        let conn = open(path.to_str().unwrap()).expect("file-backed open should succeed");
155        let fk_val = pragma_int(&conn, "foreign_keys");
156        assert_eq!(fk_val, 1, "foreign_keys must be ON (1) on file-backed connection");
157    }
158
159    // ── migration applied by connection constructor ────────────────────────────
160
161    #[test]
162    fn open_in_memory_runs_migrations() {
163        let conn = open_in_memory().expect("in-memory open should succeed");
164        // The claims table must exist after construction — migrations ran automatically.
165        let count: i64 = conn
166            .query_row(
167                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='claims'",
168                [],
169                |r| r.get(0),
170            )
171            .unwrap();
172        assert_eq!(count, 1, "claims table must exist after open_in_memory");
173    }
174}