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            let tx = conn
162                .unchecked_transaction()
163                .map_err(|err| DbError::Batch(err.to_string()))?;
164            tx.execute_batch(migration.sql)
165                .map_err(|err| DbError::Batch(err.to_string()))?;
166            tx.execute(
167                "INSERT INTO harness_migrations (id, applied_at, checksum) VALUES (?1, ?2, ?3)",
168                rusqlite::params![key, iso_now(), checksum],
169            )
170            .map_err(|err| DbError::Batch(err.to_string()))?;
171            tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
172        }
173        Ok(())
174    }
175}
176
177fn iso_now() -> String {
178    time::OffsetDateTime::now_utc()
179        .format(&time::format_description::well_known::Rfc3339)
180        .unwrap_or_default()
181}
182
183#[async_trait]
184impl Database for SqliteDatabase {
185    async fn execute(&self, stmt: &Statement) -> Result<u64, DbError> {
186        let conn = self.conn.lock().expect("sqlite lock uncontended");
187        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
188        let changed = conn
189            .execute(&stmt.sql, rusqlite::params_from_iter(params))
190            .map_err(|err| DbError::Execute(err.to_string()))?;
191        Ok(u64::try_from(changed).unwrap_or(u64::MAX))
192    }
193
194    async fn query(&self, stmt: &Statement) -> Result<Rows, DbError> {
195        let conn = self.conn.lock().expect("sqlite lock uncontended");
196        let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
197        let mut prepared = conn
198            .prepare(&stmt.sql)
199            .map_err(|err| DbError::Query(err.to_string()))?;
200        let column_names: Vec<String> = prepared
201            .column_names()
202            .into_iter()
203            .map(str::to_owned)
204            .collect();
205        let mut rows = prepared
206            .query(rusqlite::params_from_iter(params))
207            .map_err(|err| DbError::Query(err.to_string()))?;
208        let mut out = Vec::new();
209        while let Some(row) = rows.next().map_err(|err| db_err(&err))? {
210            let mut columns = Vec::with_capacity(column_names.len());
211            for (index, name) in column_names.iter().enumerate() {
212                let value: SqliteValue = row.get(index).map_err(|err| db_err(&err))?;
213                columns.push((name.clone(), sqlite_to_sea(value)));
214            }
215            out.push(Row::new(columns));
216        }
217        Ok(Rows::new(out))
218    }
219
220    /// Runs all statements in one transaction (atomic on SQLite).
221    async fn batch(&self, stmts: &[Statement]) -> Result<(), DbError> {
222        let conn = self.conn.lock().expect("sqlite lock uncontended");
223        let tx = conn
224            .unchecked_transaction()
225            .map_err(|err| DbError::Batch(err.to_string()))?;
226        for stmt in stmts {
227            let params: Vec<SqliteValue> = stmt.values.0.iter().map(sea_to_sqlite).collect();
228            tx.execute(&stmt.sql, rusqlite::params_from_iter(params))
229                .map_err(|err| DbError::Batch(err.to_string()))?;
230        }
231        tx.commit().map_err(|err| DbError::Batch(err.to_string()))?;
232        Ok(())
233    }
234}