cratestack_rusqlite/runtime.rs
1//! `RusqliteRuntime` — the on-device storage handle.
2//!
3//! Owns a single `rusqlite::Connection` behind a `Mutex`. Mobile apps are
4//! single-user; pooling adds binary size and a concurrency story we don't
5//! need. If a future use case wants a pool, swap the `Mutex<Connection>`
6//! for a connection-pool wrapper without touching the delegate code.
7
8use std::path::Path;
9use std::sync::Mutex;
10
11use rusqlite::Connection;
12
13/// Errors surfaced by the on-device runtime. Stays close to `rusqlite::Error`
14/// for now — wrapping in a cratestack-specific variant only when we cross
15/// the FFI boundary (Phase 5).
16#[derive(Debug)]
17pub enum RusqliteError {
18 /// Underlying SQLite error.
19 Sqlite(rusqlite::Error),
20 /// Operation expected exactly one row but got a different count.
21 NotFound,
22 /// Locked or poisoned mutex around the connection.
23 Locked,
24 /// Batch request exceeded the documented per-call item cap.
25 BatchTooLarge { actual: usize, maximum: usize },
26 /// Batch request contained the same primary key twice. The first/duplicate
27 /// indices are surfaced so callers can immediately pinpoint the offender
28 /// in their input list.
29 DuplicateBatchKey { first: usize, duplicate: usize },
30 /// Caller-side input rejected before any SQL ran (e.g. `update_many`
31 /// without a filter, an empty patch set). Distinct from a SQLite-level
32 /// error so callers can surface a fast-fail validation message rather
33 /// than a generic database error.
34 Validation(String),
35}
36
37impl std::fmt::Display for RusqliteError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::Sqlite(error) => write!(f, "sqlite error: {error}"),
41 Self::NotFound => write!(f, "not found"),
42 Self::Locked => write!(f, "connection mutex poisoned"),
43 Self::BatchTooLarge { actual, maximum } => {
44 write!(f, "batch size {actual} exceeds maximum of {maximum}",)
45 }
46 Self::DuplicateBatchKey { first, duplicate } => write!(
47 f,
48 "duplicate primary key in batch at positions {first} and {duplicate}",
49 ),
50 Self::Validation(message) => write!(f, "validation error: {message}"),
51 }
52 }
53}
54
55impl std::error::Error for RusqliteError {
56 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
57 match self {
58 Self::Sqlite(error) => Some(error),
59 _ => None,
60 }
61 }
62}
63
64impl From<rusqlite::Error> for RusqliteError {
65 fn from(value: rusqlite::Error) -> Self {
66 Self::Sqlite(value)
67 }
68}
69
70/// The on-device storage handle. Cheap to clone via `Arc` at the call site;
71/// the runtime itself is not `Clone` because the underlying connection
72/// shouldn't be silently duplicated.
73pub struct RusqliteRuntime {
74 conn: Mutex<Connection>,
75}
76
77impl RusqliteRuntime {
78 /// Open an in-memory database. Intended for tests; mobile apps will
79 /// almost always use [`Self::open`].
80 pub fn open_in_memory() -> Result<Self, RusqliteError> {
81 let conn = Connection::open_in_memory()?;
82 configure(&conn)?;
83 Ok(Self {
84 conn: Mutex::new(conn),
85 })
86 }
87
88 /// Open or create a database at the given path. Applies the default
89 /// pragmas (foreign keys on, journal mode WAL) appropriate for a
90 /// mobile app's storage characteristics.
91 pub fn open(path: impl AsRef<Path>) -> Result<Self, RusqliteError> {
92 let conn = Connection::open(path)?;
93 configure(&conn)?;
94 Ok(Self {
95 conn: Mutex::new(conn),
96 })
97 }
98
99 /// Run a closure with exclusive access to the underlying connection.
100 /// Use for migrations, multi-statement transactions, and anywhere the
101 /// ORM delegate isn't expressive enough.
102 pub fn with_connection<F, T>(&self, f: F) -> Result<T, RusqliteError>
103 where
104 F: FnOnce(&mut Connection) -> Result<T, RusqliteError>,
105 {
106 let mut guard = self.conn.lock().map_err(|_| RusqliteError::Locked)?;
107 f(&mut guard)
108 }
109}
110
111fn configure(conn: &Connection) -> Result<(), rusqlite::Error> {
112 conn.pragma_update(None, "foreign_keys", "ON")?;
113 // WAL is the right default for an app holding the file open across the
114 // session — better read concurrency, fewer fsync stalls.
115 // pragma_update returns an error if the journal mode pragma isn't
116 // recognised (in-memory connections), so swallow that silently.
117 let _ = conn.pragma_update(None, "journal_mode", "WAL");
118 Ok(())
119}