Skip to main content

cratefield_adapter_sqlite/
lib.rs

1//! `cratefield-adapter-sqlite`: the [`Database`] port over `rusqlite`
2//! (bundled SQLite). Native only — never compiled to wasm. Used by every
3//! module test and viable for a single-node self-hosted deployment
4//! (ADR 0004).
5//!
6//! A `rusqlite::Connection` is `Send` but `!Sync`; the harness requires
7//! `Arc<dyn Database>` to be `Send + Sync`, so the connection lives
8//! behind a mutex. This is connection guarding, not request state; the
9//! scoped clippy allow follows the policy documented in the workspace
10//! `clippy.toml`.
11
12#![doc = include_str!("../README.md")]
13#![forbid(unsafe_code)]
14
15use async_trait::async_trait;
16use cratefield_core::{Database, DbError, Row, Rows, SqlMigration, Statement};
17use rusqlite::types::Value as SqliteValue;
18use rusqlite::{Connection, OptionalExtension};
19use sea_query::Value as SeaValue;
20// Connection guarding (see module docs) — not request state.
21#[allow(clippy::disallowed_types)]
22type Guarded<T> = std::sync::Mutex<T>;
23
24/// An open SQLite database (file path or `:memory:`).
25pub struct SqliteDatabase {
26    // Connection guarding (see module docs) — not request state.
27    #[allow(clippy::disallowed_types)]
28    conn: Guarded<Connection>,
29}
30
31fn db_err(err: &rusqlite::Error) -> DbError {
32    DbError::Query(err.to_string())
33}
34
35fn sea_to_sqlite(value: &SeaValue) -> SqliteValue {
36    let null = SqliteValue::Null;
37    match value {
38        SeaValue::Bool(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
39        SeaValue::TinyInt(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
40        SeaValue::SmallInt(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
41        SeaValue::Int(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
42        SeaValue::BigInt(v) => v.map_or(null, SqliteValue::Integer),
43        // sea-query renders `LIMIT n` and unsigned literals as unsigned
44        // variants; SQLite has only INTEGER, so widen here.
45        SeaValue::TinyUnsigned(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
46        SeaValue::SmallUnsigned(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
47        SeaValue::Unsigned(v) => v.map_or(null, |v| SqliteValue::Integer(i64::from(v))),
48        SeaValue::BigUnsigned(v) => v.map_or(null, |v| {
49            if let Ok(i) = i64::try_from(v) {
50                SqliteValue::Integer(i)
51            } else {
52                tracing::error!("BigUnsigned overflow: {} exceeds i64::MAX", v);
53                SqliteValue::Integer(i64::MAX)
54            }
55        }),
56        SeaValue::Float(v) => v.map_or(null, |v| SqliteValue::Real(f64::from(v))),
57        SeaValue::Double(v) => v.map_or(null, SqliteValue::Real),
58        SeaValue::String(v) => v
59            .as_ref()
60            .map_or(null, |v| SqliteValue::Text(v.to_string())),
61        SeaValue::Char(v) => v.map_or(null, |v| SqliteValue::Text(v.to_string())),
62        SeaValue::Bytes(v) => v
63            .as_ref()
64            .map_or(null, |v| SqliteValue::Blob((**v).clone())),
65    }
66}
67
68fn sqlite_to_sea(value: SqliteValue) -> SeaValue {
69    match value {
70        SqliteValue::Null => SeaValue::String(None),
71        SqliteValue::Integer(v) => SeaValue::BigInt(Some(v)),
72        SqliteValue::Real(v) => SeaValue::Double(Some(v)),
73        SqliteValue::Text(v) => SeaValue::String(Some(Box::new(v))),
74        SqliteValue::Blob(v) => SeaValue::Bytes(Some(Box::new(v))),
75    }
76}
77
78impl SqliteDatabase {
79    /// Opens a database file; `:memory:` for an in-memory database.
80    ///
81    /// # Errors
82    ///
83    /// Propagates `rusqlite::Error` when the file cannot be opened.
84    pub fn open(path: &str) -> rusqlite::Result<Self> {
85        Ok(Self {
86            conn: Guarded::new(Connection::open(path)?),
87        })
88    }
89
90    /// An in-memory database (fresh per instance).
91    ///
92    /// # Errors
93    ///
94    /// Only if SQLite cannot allocate the in-memory handle.
95    pub fn in_memory() -> rusqlite::Result<Self> {
96        Ok(Self {
97            conn: Guarded::new(Connection::open_in_memory()?),
98        })
99    }
100}
101
102impl SqliteDatabase {
103    /// Applies a module's unapplied migrations in order, tracked under
104    /// `<module>/<id>` in a `harness_migrations` table; each migration
105    /// runs in its own transaction so a failure rolls back atomically and
106    /// re-running is idempotent.
107    ///
108    /// # Errors
109    ///
110    /// `DbError::Batch` when a migration's SQL fails.
111    ///
112    /// # Panics
113    ///
114    /// Only if the connection mutex is poisoned by a prior panic.
115    pub fn apply_migrations(
116        &self,
117        module: &str,
118        migrations: &[SqlMigration],
119    ) -> Result<(), DbError> {
120        let conn = self.conn.lock().expect("sqlite lock uncontended");
121        conn.execute_batch(
122            "CREATE TABLE IF NOT EXISTS harness_migrations (
123                 id TEXT PRIMARY KEY,
124                 applied_at TEXT NOT NULL,
125                 checksum TEXT
126             );",
127        )
128        .map_err(|err| DbError::Batch(err.to_string()))?;
129        // Databases migrated before checksums were recorded have the
130        // two-column table. Add the column; their existing rows stay
131        // NULL, which reads as "applied, cannot verify" rather than as a
132        // mismatch — the honest answer for SQL nobody hashed.
133        let has_checksum = conn
134            .query_row(
135                "SELECT COUNT(*) FROM pragma_table_info('harness_migrations') \
136                 WHERE name = 'checksum'",
137                [],
138                |row| row.get::<_, i64>(0),
139            )
140            .map(|count| count > 0)
141            .map_err(|err| db_err(&err))?;
142        if !has_checksum {
143            conn.execute_batch("ALTER TABLE harness_migrations ADD COLUMN checksum TEXT;")
144                .map_err(|err| DbError::Batch(err.to_string()))?;
145        }
146
147        for migration in migrations {
148            let key = format!("{module}/{}", migration.id);
149            let checksum = cratefield_core::migration_checksum(migration.sql);
150            let recorded: Option<Option<String>> = conn
151                .query_row(
152                    "SELECT checksum FROM harness_migrations WHERE id = ?1",
153                    [&key],
154                    |row| row.get::<_, Option<String>>(0),
155                )
156                .optional()
157                .map_err(|err| db_err(&err))?;
158            if let Some(recorded) = recorded {
159                if let Some(recorded) = recorded.filter(|hash| hash != &checksum) {
160                    return Err(DbError::Batch(cratefield_core::migration_edited(
161                        &key, &recorded, &checksum,
162                    )));
163                }
164                continue;
165            }
166            if !migration.transactional {
167                // Runs alone, then the tracking row (RECONCILIATION.md §4).
168                // The SQL must be idempotent: a crash between the two steps
169                // re-runs it on the next boot.
170                if !cratefield_core::is_idempotent_sql(migration.sql) {
171                    return Err(DbError::Batch(cratefield_core::migration_missing_guard(
172                        &key,
173                    )));
174                }
175                conn.execute_batch(migration.sql)
176                    .map_err(|err| DbError::Batch(err.to_string()))?;
177                conn.execute(
178                    "INSERT INTO harness_migrations (id, applied_at, checksum) VALUES (?1, ?2, ?3)",
179                    rusqlite::params![key, iso_now(), checksum],
180                )
181                .map_err(|err| DbError::Batch(err.to_string()))?;
182                continue;
183            }
184            let tx = conn
185                .unchecked_transaction()
186                .map_err(|err| DbError::Batch(err.to_string()))?;
187            tx.execute_batch(migration.sql)
188                .map_err(|err| DbError::Batch(err.to_string()))?;
189            tx.execute(
190                "INSERT INTO harness_migrations (id, applied_at, checksum) VALUES (?1, ?2, ?3)",
191                rusqlite::params![key, iso_now(), checksum],
192            )
193            .map_err(|err| DbError::Batch(err.to_string()))?;
194            tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
195        }
196        Ok(())
197    }
198}
199
200fn iso_now() -> String {
201    time::OffsetDateTime::now_utc()
202        .format(&time::format_description::well_known::Rfc3339)
203        .unwrap_or_default()
204}
205
206#[async_trait]
207impl Database for SqliteDatabase {
208    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
209        let conn = self.conn.lock().expect("sqlite lock uncontended");
210        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
211        let changed = conn
212            .execute(&stmt.sql, rusqlite::params_from_iter(params))
213            .map_err(|err| DbError::Execute(err.to_string()))?;
214        Ok(u64::try_from(changed).unwrap_or(u64::MAX))
215    }
216
217    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
218        let conn = self.conn.lock().expect("sqlite lock uncontended");
219        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
220        let mut prepared = conn
221            .prepare(&stmt.sql)
222            .map_err(|err| DbError::Query(err.to_string()))?;
223        let column_names: Vec<String> = prepared
224            .column_names()
225            .into_iter()
226            .map(str::to_owned)
227            .collect();
228        let mut rows = prepared
229            .query(rusqlite::params_from_iter(params))
230            .map_err(|err| DbError::Query(err.to_string()))?;
231        let mut out = Vec::new();
232        while let Some(row) = rows.next().map_err(|err| db_err(&err))? {
233            let mut columns = Vec::with_capacity(column_names.len());
234            for (index, name) in column_names.iter().enumerate() {
235                let value: SqliteValue = row.get(index).map_err(|err| db_err(&err))?;
236                columns.push((name.clone(), sqlite_to_sea(value)));
237            }
238            out.push(Row::new(columns));
239        }
240        Ok(Rows::new(out))
241    }
242
243    /// Runs all statements in one transaction (atomic on SQLite).
244    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
245        let conn = self.conn.lock().expect("sqlite lock uncontended");
246        let tx = conn
247            .unchecked_transaction()
248            .map_err(|err| DbError::Batch(err.to_string()))?;
249        for stmt in stmts {
250            let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
251            tx.execute(&stmt.sql, rusqlite::params_from_iter(params))
252                .map_err(|err| DbError::Batch(err.to_string()))?;
253        }
254        tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
255        Ok(())
256    }
257}