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            SqliteValue::Integer(i64::try_from(v).unwrap_or(i64::MAX))
50        }),
51        SeaValue::Float(v) => v.map_or(null, |v| SqliteValue::Real(f64::from(v))),
52        SeaValue::Double(v) => v.map_or(null, SqliteValue::Real),
53        SeaValue::String(v) => v
54            .as_ref()
55            .map_or(null, |v| SqliteValue::Text(v.to_string())),
56        SeaValue::Char(v) => v.map_or(null, |v| SqliteValue::Text(v.to_string())),
57        SeaValue::Bytes(v) => v
58            .as_ref()
59            .map_or(null, |v| SqliteValue::Blob((**v).clone())),
60    }
61}
62
63fn sqlite_to_sea(value: SqliteValue) -> SeaValue {
64    match value {
65        SqliteValue::Null => SeaValue::String(None),
66        SqliteValue::Integer(v) => SeaValue::BigInt(Some(v)),
67        SqliteValue::Real(v) => SeaValue::Double(Some(v)),
68        SqliteValue::Text(v) => SeaValue::String(Some(Box::new(v))),
69        SqliteValue::Blob(v) => SeaValue::Bytes(Some(Box::new(v))),
70    }
71}
72
73impl SqliteDatabase {
74    /// Opens a database file; `:memory:` for an in-memory database.
75    ///
76    /// # Errors
77    ///
78    /// Propagates `rusqlite::Error` when the file cannot be opened.
79    pub fn open(path: &str) -> rusqlite::Result<Self> {
80        Ok(Self {
81            conn: Guarded::new(Connection::open(path)?),
82        })
83    }
84
85    /// An in-memory database (fresh per instance).
86    ///
87    /// # Errors
88    ///
89    /// Only if SQLite cannot allocate the in-memory handle.
90    pub fn in_memory() -> rusqlite::Result<Self> {
91        Ok(Self {
92            conn: Guarded::new(Connection::open_in_memory()?),
93        })
94    }
95}
96
97impl SqliteDatabase {
98    /// Applies a module's unapplied migrations in order, tracked under
99    /// `<module>/<id>` in a `harness_migrations` table; each migration
100    /// runs in its own transaction so a failure rolls back atomically and
101    /// re-running is idempotent.
102    ///
103    /// # Errors
104    ///
105    /// `DbError::Batch` when a migration's SQL fails.
106    ///
107    /// # Panics
108    ///
109    /// Only if the connection mutex is poisoned by a prior panic.
110    pub fn apply_migrations(
111        &self,
112        module: &str,
113        migrations: &[SqlMigration],
114    ) -> Result<(), DbError> {
115        let conn = self.conn.lock().expect("sqlite lock uncontended");
116        conn.execute_batch(
117            "CREATE TABLE IF NOT EXISTS harness_migrations (
118                 id TEXT PRIMARY KEY,
119                 applied_at TEXT NOT NULL,
120                 checksum TEXT
121             );",
122        )
123        .map_err(|err| DbError::Batch(err.to_string()))?;
124        // Databases migrated before checksums were recorded have the
125        // two-column table. Add the column; their existing rows stay
126        // NULL, which reads as "applied, cannot verify" rather than as a
127        // mismatch — the honest answer for SQL nobody hashed.
128        let has_checksum = conn
129            .query_row(
130                "SELECT COUNT(*) FROM pragma_table_info('harness_migrations') \
131                 WHERE name = 'checksum'",
132                [],
133                |row| row.get::<_, i64>(0),
134            )
135            .map(|count| count > 0)
136            .map_err(|err| db_err(&err))?;
137        if !has_checksum {
138            conn.execute_batch("ALTER TABLE harness_migrations ADD COLUMN checksum TEXT;")
139                .map_err(|err| DbError::Batch(err.to_string()))?;
140        }
141
142        for migration in migrations {
143            let key = format!("{module}/{}", migration.id);
144            let checksum = cratefield_core::migration_checksum(migration.sql);
145            let recorded: Option<Option<String>> = conn
146                .query_row(
147                    "SELECT checksum FROM harness_migrations WHERE id = ?1",
148                    [&key],
149                    |row| row.get::<_, Option<String>>(0),
150                )
151                .optional()
152                .map_err(|err| db_err(&err))?;
153            if let Some(recorded) = recorded {
154                if let Some(recorded) = recorded.filter(|hash| hash != &checksum) {
155                    return Err(DbError::Batch(cratefield_core::migration_edited(
156                        &key, &recorded, &checksum,
157                    )));
158                }
159                continue;
160            }
161            if !migration.transactional {
162                // Runs alone, then the tracking row (RECONCILIATION.md §4).
163                // The SQL must be idempotent: a crash between the two steps
164                // re-runs it on the next boot.
165                if !cratefield_core::is_idempotent_sql(migration.sql) {
166                    return Err(DbError::Batch(cratefield_core::migration_missing_guard(
167                        &key,
168                    )));
169                }
170                conn.execute_batch(migration.sql)
171                    .map_err(|err| DbError::Batch(err.to_string()))?;
172                conn.execute(
173                    "INSERT INTO harness_migrations (id, applied_at, checksum) VALUES (?1, ?2, ?3)",
174                    rusqlite::params![key, iso_now(), checksum],
175                )
176                .map_err(|err| DbError::Batch(err.to_string()))?;
177                continue;
178            }
179            let tx = conn
180                .unchecked_transaction()
181                .map_err(|err| DbError::Batch(err.to_string()))?;
182            tx.execute_batch(migration.sql)
183                .map_err(|err| DbError::Batch(err.to_string()))?;
184            tx.execute(
185                "INSERT INTO harness_migrations (id, applied_at, checksum) VALUES (?1, ?2, ?3)",
186                rusqlite::params![key, iso_now(), checksum],
187            )
188            .map_err(|err| DbError::Batch(err.to_string()))?;
189            tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
190        }
191        Ok(())
192    }
193}
194
195fn iso_now() -> String {
196    time::OffsetDateTime::now_utc()
197        .format(&time::format_description::well_known::Rfc3339)
198        .unwrap_or_default()
199}
200
201#[async_trait]
202impl Database for SqliteDatabase {
203    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
204        let conn = self.conn.lock().expect("sqlite lock uncontended");
205        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
206        let changed = conn
207            .execute(&stmt.sql, rusqlite::params_from_iter(params))
208            .map_err(|err| DbError::Execute(err.to_string()))?;
209        Ok(u64::try_from(changed).unwrap_or(u64::MAX))
210    }
211
212    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
213        let conn = self.conn.lock().expect("sqlite lock uncontended");
214        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
215        let mut prepared = conn
216            .prepare(&stmt.sql)
217            .map_err(|err| DbError::Query(err.to_string()))?;
218        let column_names: Vec<String> = prepared
219            .column_names()
220            .into_iter()
221            .map(str::to_owned)
222            .collect();
223        let mut rows = prepared
224            .query(rusqlite::params_from_iter(params))
225            .map_err(|err| DbError::Query(err.to_string()))?;
226        let mut out = Vec::new();
227        while let Some(row) = rows.next().map_err(|err| db_err(&err))? {
228            let mut columns = Vec::with_capacity(column_names.len());
229            for (index, name) in column_names.iter().enumerate() {
230                let value: SqliteValue = row.get(index).map_err(|err| db_err(&err))?;
231                columns.push((name.clone(), sqlite_to_sea(value)));
232            }
233            out.push(Row::new(columns));
234        }
235        Ok(Rows::new(out))
236    }
237
238    /// Runs all statements in one transaction (atomic on SQLite).
239    async fn batch_atomic(&self, stmts: &[Statement]) -> Result<(), DbError> {
240        let conn = self.conn.lock().expect("sqlite lock uncontended");
241        let tx = conn
242            .unchecked_transaction()
243            .map_err(|err| DbError::Batch(err.to_string()))?;
244        for stmt in stmts {
245            let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
246            tx.execute(&stmt.sql, rusqlite::params_from_iter(params))
247                .map_err(|err| DbError::Batch(err.to_string()))?;
248        }
249        tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
250        Ok(())
251    }
252}