Skip to main content

frust_database/
lib.rs

1//! `frust-database`: a platform-independent, synchronous local SQL API —
2//! one public surface over an engine-swappable SQLite-compatible store.
3//!
4//! # Charter: a pure-Rust plugin, no `frust-plugin`
5//!
6//! Unlike every other crate under `plugins/` (see `docs/ARCHITECTURE.md`'s
7//! Module Structure / `docs/CODE_STANDARDS.md`'s Plugin Conventions), this
8//! crate depends on **no** `frust-plugin` — it needs no JNI/platform
9//! handle. Both SQL engines it can route to (`rusqlite`'s bundled SQLite
10//! today; a future `turso` async engine — see *Engines* below) reach
11//! on-disk storage directly through their own FFI/bindings, never through
12//! an OS capability API a platform handle would gate. The crate's sole
13//! `frust-*` dependency is [`frust_paths`], for [`Database::open`]'s
14//! default `<data_dir>/databases/<name>.db` path resolution. An app adds
15//! this crate to its own `Cargo.toml` alongside `frust`, the same way
16//! every other plugin does; the `frust` facade does not depend on or
17//! re-export it.
18//!
19//! # Engines
20//!
21//! [`Engine`] names the compiled backend a [`Database`] routes through:
22//! [`Engine::Sqlite`] (this crate's default — the `engine-sqlite` feature,
23//! `rusqlite`'s bundled, synchronous, in-process SQLite) and `Engine::Turso`
24//! (the `engine-turso` feature — an async engine bridged onto this crate's
25//! synchronous API, see `turso.rs`; not doc-linkable here since the variant
26//! only exists when that feature is compiled). [`OpenOptions::engine`] picks
27//! one explicitly;
28//! [`Database::open`]/[`Database::open_in_memory`]/[`Database::open_at`]
29//! resolve a default instead — Sqlite when compiled, else Turso, else
30//! (neither compiled) a [`DatabaseError::Storage`] naming the missing
31//! feature, since [`Engine`] itself has no values to report in
32//! [`DatabaseError::EngineUnavailable`] once both its variants are
33//! compiled out (each is individually feature-gated — see the type's own
34//! doc).
35//!
36//! # UI-thread discipline: pair every call with `spawn_blocking`
37//!
38//! Every [`Database`] operation is a **blocking** synchronous call — a
39//! `rusqlite` statement runs on the calling thread, and the `turso` backend
40//! bridges its async engine onto this same blocking call shape (see
41//! [`DatabaseError::AsyncContext`]). Like every other plugin's
42//! blocking/gated call (`docs/PLUGINS_ARCHITECTURE.md`'s Layer
43//! Dependencies), an app must never call [`Database::execute`] /
44//! [`Database::query`] / [`Database::transaction`] directly from the UI
45//! thread — route it through `frust::spawn_blocking`:
46//!
47//! ```ignore
48//! let db = frust_database::Database::open("app")?;
49//! let inserted = frust::spawn_blocking(move || {
50//!     db.execute("INSERT INTO notes (body) VALUES (?1)", ["hello"])
51//! })
52//! .await??;
53//! ```
54//!
55//! Unlike `secure-storage`'s biometric gate or `camera`'s permission/
56//! capture calls, this crate has **no code-level UI-thread guard** — there
57//! is no shared guard helper in this codebase, and adding one here would
58//! need an FFI dependency this pure-Rust crate deliberately carries none
59//! of (see *Charter* above). UI-thread discipline is docs-only here,
60//! matching `secure-storage`'s own precedent for calls it can't cheaply
61//! guard in code.
62//!
63//! # Threading model: one serialized connection per handle
64//!
65//! A [`Database`] wraps exactly one engine connection behind a
66//! crate-private `Mutex<Box<dyn engine::EngineConn>>`, so every call
67//! through one handle is serialized — `Database` is `Send + Sync` and
68//! cheap to share (e.g. behind an `Arc`), but two concurrent calls on the
69//! *same* handle queue rather than run in parallel. Every real backend
70//! opens its file in WAL journal mode (see *Interop discipline* below),
71//! which supports concurrent readers alongside one writer, but only
72//! across separate connections — an app that wants read parallelism opens
73//! more than one `Database` handle onto the same file rather than sharing
74//! one handle across threads expecting internal parallelism.
75//!
76//! **"Open multiple handles for concurrent readers" is an `engine-sqlite`
77//! guarantee, not a cross-engine one.** `rusqlite`'s bundled SQLite really
78//! does run separate connections' reads on separate OS threads, in
79//! parallel. `engine-turso` cannot: every `Database` handle, however many
80//! an app opens onto the same file, routes its calls through one
81//! process-wide, single-threaded bridge (see `turso.rs`'s module doc, *The
82//! bridge*). Additional turso handles still buy correctness and
83//! cross-handle write visibility — each is its own connection, seeing the
84//! others' commits — but not wall-clock parallelism: their calls
85//! serialize/interleave on that one bridge thread exactly as if issued
86//! through a single handle.
87//!
88//! # Interop discipline (enforced by backends, not this module)
89//!
90//! Every real backend opens its connection in **WAL journal mode**, and
91//! neither engine turns on a session-extension-style MVCC layer or
92//! on-disk encryption (no `SQLCipher`/equivalent) — a `frust-database`
93//! file is a plain, unencrypted WAL SQLite file any standard `sqlite3`
94//! tool can open. This crate's public API doesn't enforce that directly;
95//! each backend module's own doc comment documents how it configures its
96//! connection.
97//!
98//! # v1 scope
99//!
100//! Positional parameters only ([`Value`] / [`IntoParams`]); no
101//! prepared-statement cache, no streaming cursors, no migrations — a plain
102//! `execute`/`query`/`transaction` surface. Future enhancements (not v1):
103//! named parameters, a statement cache, streaming query results, and a
104//! migration runner.
105
106mod engine;
107
108// The default engine backend — see `sqlite.rs`'s own module doc.
109#[cfg(feature = "engine-sqlite")]
110mod sqlite;
111
112// The non-default, opt-in engine backend — see `turso.rs`'s own module doc.
113#[cfg(feature = "engine-turso")]
114mod turso;
115
116// The cross-engine conformance suite — see `conformance.rs`'s own module
117// doc. Gated on the `conformance` feature too, not just `test`, so a future
118// harness can compile it without a full test build.
119#[cfg(any(test, feature = "conformance"))]
120pub(crate) mod conformance;
121
122use std::cell::RefCell;
123use std::fs;
124use std::path::{Path, PathBuf};
125use std::sync::atomic::{AtomicU64, Ordering};
126use std::sync::{Mutex, MutexGuard};
127
128use engine::{EngineConn, Target};
129
130/// The five SQLite storage classes — this crate's currency for both
131/// statement parameters and result-row values.
132#[derive(Debug, Clone, PartialEq)]
133pub enum Value {
134    /// SQL `NULL`.
135    Null,
136    /// A signed 64-bit integer.
137    Integer(i64),
138    /// A 64-bit IEEE-754 float.
139    Real(f64),
140    /// A UTF-8 string.
141    Text(String),
142    /// An arbitrary byte string.
143    Blob(Vec<u8>),
144}
145
146impl From<i64> for Value {
147    fn from(v: i64) -> Self {
148        Value::Integer(v)
149    }
150}
151
152impl From<i32> for Value {
153    fn from(v: i32) -> Self {
154        Value::Integer(i64::from(v))
155    }
156}
157
158impl From<f64> for Value {
159    fn from(v: f64) -> Self {
160        Value::Real(v)
161    }
162}
163
164impl From<bool> for Value {
165    /// `false` → `0`, `true` → `1` — SQLite has no native boolean storage
166    /// class.
167    fn from(v: bool) -> Self {
168        Value::Integer(i64::from(v))
169    }
170}
171
172impl From<String> for Value {
173    fn from(v: String) -> Self {
174        Value::Text(v)
175    }
176}
177
178impl From<&str> for Value {
179    fn from(v: &str) -> Self {
180        Value::Text(v.to_string())
181    }
182}
183
184impl From<Vec<u8>> for Value {
185    fn from(v: Vec<u8>) -> Self {
186        Value::Blob(v)
187    }
188}
189
190impl<T: Into<Value>> From<Option<T>> for Value {
191    /// `None` → [`Value::Null`]; `Some(v)` → `v`'s own conversion.
192    fn from(v: Option<T>) -> Self {
193        match v {
194            Some(v) => v.into(),
195            None => Value::Null,
196        }
197    }
198}
199
200/// One result row: column names paired with their [`Value`]s, in
201/// statement column order.
202#[derive(Debug, Clone, PartialEq)]
203pub struct Row {
204    columns: Vec<String>,
205    values: Vec<Value>,
206}
207
208impl Row {
209    /// Build a row from parallel column-name/value lists — the
210    /// constructor a backend module ([`sqlite`], the future `turso`) uses
211    /// to report a query result; not part of this crate's public API.
212    ///
213    /// Unused today: `sqlite.rs`'s stub `query` never actually returns a
214    /// row (see its module doc) — the sqlite-backend task's real
215    /// implementation is this constructor's first caller.
216    #[allow(
217        dead_code,
218        reason = "first real caller lands with the sqlite-backend task"
219    )]
220    pub(crate) fn new(columns: Vec<String>, values: Vec<Value>) -> Self {
221        Self { columns, values }
222    }
223
224    /// The value at positional column `idx`, or `None` if out of range.
225    pub fn get(&self, idx: usize) -> Option<&Value> {
226        self.values.get(idx)
227    }
228
229    /// The value of the column named `name`, or `None` if no column has
230    /// that name. Matches the first column with that name if a statement
231    /// produced duplicate column names.
232    pub fn get_named(&self, name: &str) -> Option<&Value> {
233        self.columns
234            .iter()
235            .position(|c| c == name)
236            .and_then(|i| self.values.get(i))
237    }
238}
239
240mod sealed {
241    /// Closes [`super::IntoParams`] to this crate's own conversions — see
242    /// that trait's doc.
243    pub trait Sealed {}
244}
245
246/// Types that can be passed as SQL statement parameters to
247/// [`Database::execute`] / [`Database::query`] / [`Transaction::execute`] /
248/// [`Transaction::query`].
249///
250/// Sealed (see the private `sealed::Sealed` supertrait): only this
251/// crate's own conversions — `()` (no parameters), and an array or slice
252/// of anything [`Into<Value>`] (including [`Value`] itself, via its
253/// reflexive `Into`) — implement it. Positional only, matching this
254/// crate's v1 scope (module doc).
255pub trait IntoParams: sealed::Sealed {
256    /// Convert into this crate's positional parameter list.
257    fn into_params(self) -> Vec<Value>;
258}
259
260impl sealed::Sealed for () {}
261impl IntoParams for () {
262    fn into_params(self) -> Vec<Value> {
263        Vec::new()
264    }
265}
266
267impl<T: Into<Value> + Clone> sealed::Sealed for &[T] {}
268impl<T: Into<Value> + Clone> IntoParams for &[T] {
269    fn into_params(self) -> Vec<Value> {
270        self.iter().cloned().map(Into::into).collect()
271    }
272}
273
274impl<T: Into<Value>, const N: usize> sealed::Sealed for [T; N] {}
275impl<T: Into<Value>, const N: usize> IntoParams for [T; N] {
276    fn into_params(self) -> Vec<Value> {
277        self.into_iter().map(Into::into).collect()
278    }
279}
280
281/// Errors from a [`Database`] operation.
282///
283/// `thiserror`-derived per `docs/CODE_STANDARDS.md`: callers match on the
284/// variant rather than only displaying it.
285#[derive(thiserror::Error, Debug)]
286#[non_exhaustive]
287pub enum DatabaseError {
288    /// A database path couldn't be resolved, created, or otherwise
289    /// accessed — an unset data directory, an invalid database name, or an
290    /// underlying filesystem failure.
291    #[error("database storage error: {0}")]
292    Storage(String),
293
294    /// The SQL engine reported a statement failure (a syntax error, a
295    /// constraint violation, a type mismatch — whatever the engine itself
296    /// surfaces).
297    #[error("SQL error: {message}")]
298    Sql {
299        /// The engine-reported failure message.
300        message: String,
301    },
302
303    /// Reserved for async-bridging backends: a synchronous call ran from
304    /// inside an async runtime worker thread, which the turso engine's
305    /// async bridge can't safely block on. `engine-sqlite`'s connection
306    /// never raises this — declared now so this enum stays additive-stable
307    /// once the turso backend needs it.
308    #[error("a synchronous database call ran from inside an async runtime worker")]
309    AsyncContext,
310
311    /// The requested (or resolved-default) [`Engine`] isn't compiled into
312    /// this build.
313    ///
314    /// Reserved, and unconstructable by any of today's code paths: an
315    /// [`Engine`] variant only exists at all when the feature compiling its
316    /// backend is on, so an engine a caller can *name* is by construction
317    /// compiled in — see [`Database::open`]'s own *Errors* section for what
318    /// the neither-engine-compiled build reports instead.
319    #[error("engine {0:?} is not available in this build")]
320    EngineUnavailable(Engine),
321
322    /// A call re-entered a [`Database`] handle the calling thread already
323    /// holds — typically an `execute`/`query`/`transaction` issued on a
324    /// captured (e.g. `Arc`-shared) handle from *inside* that same handle's
325    /// [`Database::transaction`] closure.
326    ///
327    /// Reported rather than deadlocking on the handle's non-reentrant
328    /// connection mutex: run statements inside a transaction through the
329    /// [`Transaction`] handle the closure is given. Calls from *other*
330    /// threads are unaffected — they queue on the mutex as the module doc's
331    /// *Threading model* section promises.
332    #[error("a database call re-entered a handle already locked by the calling thread")]
333    Reentrant,
334}
335
336/// The compiled SQL engine backend a [`Database`] routes through.
337///
338/// Each variant is gated on the feature that compiles its backend module,
339/// so a build with only one engine feature on can never even *name* the
340/// other's variant — see the module doc's *Engines* section for how
341/// [`Database`]'s default-engine resolution accounts for this.
342/// `#[non_exhaustive]`: a future backend adds a variant without breaking
343/// an exhaustive match outside this crate.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345#[non_exhaustive]
346pub enum Engine {
347    /// `rusqlite`'s bundled, synchronous, in-process SQLite — this crate's
348    /// default engine.
349    #[cfg(feature = "engine-sqlite")]
350    Sqlite,
351    /// The turso async engine, bridged onto this crate's synchronous API —
352    /// see `turso.rs`'s module doc for the bridge design.
353    #[cfg(feature = "engine-turso")]
354    Turso,
355}
356
357/// Builder for [`Database::open_with`].
358#[derive(Debug, Clone, Default)]
359pub struct OpenOptions {
360    engine: Option<Engine>,
361}
362
363impl OpenOptions {
364    /// A fresh, default builder — no explicit engine (resolves to this
365    /// crate's compiled default at open time).
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Explicitly select the engine to open with, overriding the compiled
371    /// default.
372    pub fn engine(mut self, engine: Engine) -> Self {
373        self.engine = Some(engine);
374        self
375    }
376}
377
378/// A handle onto one SQL database.
379///
380/// `Send + Sync`, cheap to share behind an `Arc` — see the module doc's
381/// *Threading model* section for what that concurrency contract does and
382/// doesn't buy a caller. Every operation is a blocking synchronous call;
383/// see the module doc's *UI-thread discipline* section before calling one
384/// from the platform UI thread.
385pub struct Database {
386    conn: Mutex<Box<dyn EngineConn>>,
387    /// The [`thread_token`] of whichever thread currently holds `conn`, or
388    /// `0` when it's unheld — the whole state behind [`Self::lock_conn`]'s
389    /// reentrancy check. Written only while the lock is held.
390    holder: AtomicU64,
391}
392
393impl Database {
394    fn from_conn(conn: Box<dyn EngineConn>) -> Self {
395        Self {
396            conn: Mutex::new(conn),
397            holder: AtomicU64::new(UNHELD),
398        }
399    }
400
401    /// Lock this handle's connection, reporting [`DatabaseError::Reentrant`]
402    /// instead of deadlocking when the calling thread already holds it.
403    ///
404    /// The connection sits behind a plain, non-reentrant `std::sync::Mutex`,
405    /// so a closure that captured the same handle (an `Arc<Database>` — the
406    /// sharing shape this crate recommends) and called back into
407    /// `execute`/`query`/`transaction` would otherwise park forever on a
408    /// lock only it can release. A blind `try_lock` would be the wrong
409    /// detector: it would also refuse legitimate *cross-thread* contention,
410    /// which the module doc's *Threading model* section promises will queue.
411    /// So the check is by owner identity instead — `holder` carries the
412    /// token of the thread holding the lock, written only under the lock, so
413    /// a caller that finds its own token there is provably re-entering while
414    /// every other caller blocks exactly as before.
415    ///
416    /// # Poison policy
417    /// A poisoned mutex is recovered (`into_inner()`) rather than
418    /// propagated, and that is sound **only because of [`RollbackGuard`]**:
419    /// the only way a panic can escape *with a transaction open* is a
420    /// caller's transaction closure panicking (bare `execute`/`query` can
421    /// also panic while holding the lock, but no `BEGIN` ran there, so
422    /// nothing is stranded), and that guard's `Drop` issues the `ROLLBACK`
423    /// on the way out. So — **assuming that best-effort `ROLLBACK` itself
424    /// succeeds** — the connection a later caller recovers here is not left
425    /// mid-transaction. If the `ROLLBACK` statement itself fails (I/O error
426    /// mid-rollback, disk full), the transaction can remain open with no
427    /// taint recorded — an accepted, narrow residual documented in
428    /// `docs/LIMITATIONS.md` (`db-rollback-failure-residual`). Were the
429    /// guard removed entirely, recovering a poisoned lock would hand out a
430    /// connection with an orphaned transaction still open — a silent
431    /// data-loss path, not a mere lost error.
432    ///
433    /// # Errors
434    /// [`DatabaseError::Reentrant`] if the calling thread already holds this
435    /// handle's connection.
436    fn lock_conn(&self) -> Result<ConnGuard<'_>, DatabaseError> {
437        let me = thread_token();
438        if self.holder.load(Ordering::Acquire) == me {
439            return Err(DatabaseError::Reentrant);
440        }
441        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
442        self.holder.store(me, Ordering::Release);
443        Ok(ConnGuard {
444            conn,
445            holder: &self.holder,
446        })
447    }
448
449    /// Open (creating if absent) the named database at this crate's
450    /// standard location, `<data_dir>/databases/<name>.db`
451    /// ([`frust_paths::data_dir`]), using the compiled default engine.
452    ///
453    /// `name` is sanitized: it must be non-empty and contain no path
454    /// separator (`/` or `\`) — see [`Self::open_with`] for choosing a
455    /// different engine, and [`Self::open_at`] for an explicit path.
456    ///
457    /// The `databases/` directory is deliberately shared by every frust app
458    /// on the machine (no per-binary `app_stem` component), which is also
459    /// why this crate does its own **file-level** read-through on macOS: if
460    /// `<data_dir>/databases/<name>.db` doesn't exist but the same shape
461    /// under [`frust_paths::legacy_data_dir`] does, the legacy file is
462    /// opened where it already lives — nothing is migrated, copied, or
463    /// deleted. `data_dir()`'s own built-in macOS fallback probes
464    /// `<base>/<app_stem>` and so can never see this crate's paths.
465    ///
466    /// Platform locations:
467    /// - **Android**: `<Context.getFilesDir()>/databases/<name>.db`. This directory
468    ///   is installed by the platform shell at `nativeInitPlatform` time; `Database::open`
469    ///   must run *after* this initialization completes (typically from app code, not from
470    ///   a static initializer).
471    /// - **iOS, macOS, Linux, Windows**: `<data_dir>/databases/<name>.db`, where `data_dir`
472    ///   is resolved from environment variables (`HOME`, `XDG_DATA_HOME`, `APPDATA`, etc.).
473    ///
474    /// # Errors
475    /// [`DatabaseError::Storage`] if `name` is invalid, no data directory
476    /// can be resolved (an unset `HOME`/`APPDATA` — this crate never
477    /// guesses a fallback that could silently write into the process's
478    /// current directory, matching `frust-shared-preferences`'s own
479    /// `FileStore::standard` precedent), the `databases` directory can't be
480    /// created, or (only once both engine features are compiled out) no
481    /// engine is available at all — that last case reports `Storage` naming
482    /// the missing feature, *not* [`DatabaseError::EngineUnavailable`],
483    /// which no path here can construct: each [`Engine`] variant is gated on
484    /// the feature compiling its own backend, so an engine an
485    /// [`OpenOptions::engine`] caller can name is by construction compiled
486    /// in (see that variant's own doc).
487    pub fn open(name: &str) -> Result<Self, DatabaseError> {
488        Self::open_with(name, OpenOptions::default())
489    }
490
491    /// Open a private, non-shared in-memory database using the compiled
492    /// default engine — gone once this handle is dropped.
493    ///
494    /// # Errors
495    /// See [`Self::open`].
496    pub fn open_in_memory() -> Result<Self, DatabaseError> {
497        let engine = default_engine()?;
498        let conn = engine::open_conn(engine, Target::Memory)?;
499        Ok(Self::from_conn(conn))
500    }
501
502    /// Open (creating if absent) the database at an explicit path, using
503    /// the compiled default engine — bypasses [`Self::open`]'s standard
504    /// location and name sanitization entirely.
505    ///
506    /// # Errors
507    /// See [`Self::open`].
508    pub fn open_at(path: &Path) -> Result<Self, DatabaseError> {
509        let engine = default_engine()?;
510        let conn = engine::open_conn(engine, Target::Path(path.to_path_buf()))?;
511        Ok(Self::from_conn(conn))
512    }
513
514    /// Open (creating if absent) the named database at this crate's
515    /// standard location, with an explicit [`OpenOptions`] (currently:
516    /// engine selection).
517    ///
518    /// # Errors
519    /// See [`Self::open`].
520    pub fn open_with(name: &str, options: OpenOptions) -> Result<Self, DatabaseError> {
521        let path = resolve_db_path(name)?;
522        let engine = match options.engine {
523            Some(engine) => engine,
524            None => default_engine()?,
525        };
526        let conn = engine::open_conn(engine, Target::Path(path))?;
527        Ok(Self::from_conn(conn))
528    }
529
530    /// Run a non-row-returning statement (`INSERT`/`UPDATE`/`DELETE`/DDL),
531    /// returning the number of rows affected.
532    ///
533    /// # Errors
534    /// [`DatabaseError::Sql`] if the engine rejects the statement;
535    /// [`DatabaseError::Reentrant`] if the calling thread is already inside
536    /// a [`Self::transaction`] closure on this same handle (use the
537    /// [`Transaction`]'s own `execute` there).
538    pub fn execute(&self, sql: &str, params: impl IntoParams) -> Result<u64, DatabaseError> {
539        let mut conn = self.lock_conn()?;
540        conn.conn().execute(sql, &params.into_params())
541    }
542
543    /// Run a row-returning statement (`SELECT`), returning every resulting
544    /// row.
545    ///
546    /// # Errors
547    /// [`DatabaseError::Sql`] if the engine rejects the statement;
548    /// [`DatabaseError::Reentrant`] if the calling thread is already inside
549    /// a [`Self::transaction`] closure on this same handle (use the
550    /// [`Transaction`]'s own `query` there).
551    pub fn query(&self, sql: &str, params: impl IntoParams) -> Result<Vec<Row>, DatabaseError> {
552        let mut conn = self.lock_conn()?;
553        conn.conn().query(sql, &params.into_params())
554    }
555
556    /// Run `f` inside a `BEGIN`/`COMMIT`/`ROLLBACK` transaction — the same
557    /// plain SQL on both engines, run through the engine seam. Commits on
558    /// `Ok`, rolls back on `Err`, and returns whatever `f` returned (or its
559    /// error).
560    ///
561    /// The transaction is rolled back on *every* path out that isn't a
562    /// successful `COMMIT` — including the `COMMIT` statement itself failing
563    /// (SQLite's deferred-constraint check runs there and leaves the
564    /// transaction open) and `f` panicking — so a call always leaves the
565    /// handle's connection ready for the next one, never stranded
566    /// mid-transaction.
567    ///
568    /// `f` must not call `execute`/`query`/`transaction` on the same
569    /// [`Database`] handle: the connection is already locked for the
570    /// transaction's whole span, and re-entering it from the same thread is
571    /// refused with [`DatabaseError::Reentrant`] rather than deadlocking.
572    /// Use the [`Transaction`] handle `f` is given instead. Other threads
573    /// calling this handle meanwhile are unaffected — they queue, per the
574    /// module doc's *Threading model* section.
575    ///
576    /// # Errors
577    /// `f`'s own error, if it returns `Err` (after rolling back). A
578    /// `BEGIN`/`COMMIT`/`ROLLBACK` statement itself failing also surfaces
579    /// as [`DatabaseError::Sql`] (again after rolling back).
580    /// [`DatabaseError::Reentrant`] if the calling thread already holds this
581    /// handle's connection.
582    pub fn transaction<T>(
583        &self,
584        f: impl FnOnce(&Transaction) -> Result<T, DatabaseError>,
585    ) -> Result<T, DatabaseError> {
586        let mut locked = self.lock_conn()?;
587        locked.conn().execute("BEGIN", &[])?;
588        // Armed the moment BEGIN succeeds: from here on, *every* exit but a
589        // successful COMMIT rolls back through this guard's `Drop` — `f`
590        // returning `Err`, the COMMIT itself failing, and the case no match
591        // arm can reach, `f` panicking (see [`RollbackGuard`]).
592        let mut guard = RollbackGuard::new(locked.conn());
593        // Scoped so `txn`'s borrow of the connection ends before the COMMIT
594        // below — `Transaction` holds no resource of its own to release,
595        // only this borrow.
596        let result = {
597            let txn = Transaction {
598                conn: RefCell::new(guard.conn()),
599            };
600            f(&txn)
601        };
602        let value = result?;
603        guard.conn().execute("COMMIT", &[])?;
604        guard.disarm();
605        Ok(value)
606    }
607}
608
609/// The RAII lock on a [`Database`]'s connection: holds the `MutexGuard` and
610/// clears the owner [`Database::lock_conn`] recorded in `holder`, so the
611/// reentrancy check can never read a stale owner. `Drop` runs before the
612/// `MutexGuard` field is dropped, so the owner is always cleared *before*
613/// the mutex opens to the next thread.
614struct ConnGuard<'a> {
615    conn: MutexGuard<'a, Box<dyn EngineConn>>,
616    holder: &'a AtomicU64,
617}
618
619impl ConnGuard<'_> {
620    /// The locked connection. A method rather than a `DerefMut` impl
621    /// because every caller wants the unboxed `&mut dyn EngineConn`, not
622    /// the `Box`.
623    fn conn(&mut self) -> &mut dyn EngineConn {
624        &mut **self.conn
625    }
626}
627
628impl Drop for ConnGuard<'_> {
629    fn drop(&mut self) {
630        self.holder.store(UNHELD, Ordering::Release);
631    }
632}
633
634/// Arms a `ROLLBACK` over the span between a successful `BEGIN` and a
635/// successful `COMMIT` — [`Database::transaction`]'s cleanup for every
636/// abnormal exit, including the one no `match` arm can cover: the closure
637/// **panicking**, which unwinds straight past any commit/rollback logic.
638///
639/// Leaving any of those paths without a rollback strands the shared
640/// connection mid-transaction, and a `Database` handle outlives the call: a
641/// later `transaction` then fails at `BEGIN`, and a later bare
642/// `execute`/`query` silently *joins* the orphaned transaction and loses its
643/// writes when the handle is dropped. [`Database::lock_conn`]'s poison
644/// policy rests on this guard, too.
645///
646/// The rollback is best-effort (`let _ =`): an already-broken connection
647/// must not hide the caller's real error, and a `Drop` running during an
648/// unwind has nowhere to report to anyway.
649struct RollbackGuard<'a> {
650    conn: &'a mut dyn EngineConn,
651    armed: bool,
652}
653
654impl<'a> RollbackGuard<'a> {
655    /// Arm the guard over `conn` — the caller must already have run a
656    /// successful `BEGIN` on it.
657    fn new(conn: &'a mut dyn EngineConn) -> Self {
658        Self { conn, armed: true }
659    }
660
661    /// The guarded connection, reborrowed for as long as the caller holds
662    /// `&mut self`.
663    fn conn(&mut self) -> &mut dyn EngineConn {
664        &mut *self.conn
665    }
666
667    /// Disarm: the transaction ended on its own terms (a successful
668    /// `COMMIT`), so `Drop` must not roll anything back.
669    fn disarm(&mut self) {
670        self.armed = false;
671    }
672}
673
674impl Drop for RollbackGuard<'_> {
675    fn drop(&mut self) {
676        if self.armed {
677            let _ = self.conn.execute("ROLLBACK", &[]);
678        }
679    }
680}
681
682// --- Reentrancy tokens --------------------------------------------------
683
684/// [`Database::holder`]'s "no thread holds this connection" value —
685/// [`thread_token`] never mints it.
686const UNHELD: u64 = 0;
687
688/// A process-unique, never-reused identifier for the calling thread,
689/// allocated on that thread's first database call. Never [`UNHELD`].
690///
691/// `std::thread::ThreadId` is the natural source, but it is neither
692/// storable in an atomic nor numerically readable on stable — its only
693/// accessor, `ThreadId::as_u64`, is still unstable (`thread_id_value`,
694/// rust-lang/rust#67939) — so this crate mints its own token. Tokens are
695/// handed out monotonically and never recycled, so a token can only ever
696/// name the one thread it was minted for, even after that thread exits.
697fn thread_token() -> u64 {
698    static NEXT: AtomicU64 = AtomicU64::new(UNHELD + 1);
699    thread_local! {
700        static TOKEN: u64 = NEXT.fetch_add(1, Ordering::Relaxed);
701    }
702    TOKEN.with(|token| *token)
703}
704
705/// A handle to one open transaction, passed to [`Database::transaction`]'s
706/// closure.
707///
708/// Borrows the parent [`Database`]'s already-locked connection for the
709/// transaction's lifetime — `execute`/`query` take `&self` (via an
710/// internal `RefCell`) so the closure can call either any number of times
711/// without needing `&mut`.
712///
713/// This is the *only* way to run a statement inside the transaction: the
714/// parent handle's own `execute`/`query`/`transaction` report
715/// [`DatabaseError::Reentrant`] for the whole span (see
716/// [`Database::transaction`]).
717pub struct Transaction<'a> {
718    conn: RefCell<&'a mut dyn EngineConn>,
719}
720
721impl Transaction<'_> {
722    /// Run a non-row-returning statement inside this transaction. See
723    /// [`Database::execute`].
724    ///
725    /// # Errors
726    /// [`DatabaseError::Sql`] if the engine rejects the statement.
727    pub fn execute(&self, sql: &str, params: impl IntoParams) -> Result<u64, DatabaseError> {
728        self.conn.borrow_mut().execute(sql, &params.into_params())
729    }
730
731    /// Run a row-returning statement inside this transaction. See
732    /// [`Database::query`].
733    ///
734    /// # Errors
735    /// [`DatabaseError::Sql`] if the engine rejects the statement.
736    pub fn query(&self, sql: &str, params: impl IntoParams) -> Result<Vec<Row>, DatabaseError> {
737        self.conn.borrow_mut().query(sql, &params.into_params())
738    }
739}
740
741// --- Default-engine resolution ---------------------------------------
742//
743// Exactly one of these three definitions compiles for any feature
744// combination (the three `cfg`s are mutually exclusive and exhaustive):
745// prefer Sqlite when compiled, else Turso, else — since `Engine` then has
746// no values at all to embed in `DatabaseError::EngineUnavailable` — a
747// `DatabaseError::Storage` naming the missing feature.
748
749#[cfg(feature = "engine-sqlite")]
750fn default_engine() -> Result<Engine, DatabaseError> {
751    Ok(Engine::Sqlite)
752}
753
754#[cfg(all(not(feature = "engine-sqlite"), feature = "engine-turso"))]
755fn default_engine() -> Result<Engine, DatabaseError> {
756    Ok(Engine::Turso)
757}
758
759#[cfg(not(any(feature = "engine-sqlite", feature = "engine-turso")))]
760fn default_engine() -> Result<Engine, DatabaseError> {
761    Err(DatabaseError::Storage(
762        "no SQL engine compiled into this build — enable the `engine-sqlite` or `engine-turso` \
763         feature"
764            .into(),
765    ))
766}
767
768// --- Path resolution ---------------------------------------------------
769
770/// `<base>/databases/<name>.db` — the join `Self::open`'s standard
771/// location applies on top of a resolved [`frust_paths::data_dir`].
772/// Factored out from [`resolve_db_path`] so it's testable without ever
773/// touching a real data directory.
774fn db_file_path(base: &Path, name: &str) -> Result<PathBuf, DatabaseError> {
775    validate_name(name)?;
776    Ok(base.join("databases").join(format!("{name}.db")))
777}
778
779/// `name` must be non-empty and contain no path separator (`/` or `\`,
780/// checked on every target so behavior doesn't depend on the host OS) — a
781/// database name is a bare identifier, never a path fragment.
782fn validate_name(name: &str) -> Result<(), DatabaseError> {
783    if name.is_empty() {
784        return Err(DatabaseError::Storage(
785            "database name must not be empty".into(),
786        ));
787    }
788    if name.contains('/') || name.contains('\\') {
789        return Err(DatabaseError::Storage(format!(
790            "database name {name:?} must not contain a path separator"
791        )));
792    }
793    Ok(())
794}
795
796/// `<base>/databases/<name>.db`, with the legacy-base **read-through**
797/// [`Database::open`] applies on macOS — factored out from
798/// [`resolve_db_path`] over both base directories so it's testable against
799/// temp dirs without ever touching a real data directory.
800///
801/// When the file under `base` does not exist and `legacy_base` is `Some`
802/// (macOS only — see [`frust_paths::legacy_data_dir`]), the same
803/// `databases/<name>.db` shape is checked under the legacy base and
804/// returned **only if that file exists**; otherwise the `base` path wins.
805/// Nothing is ever migrated, copied, or deleted: an existing database keeps
806/// being opened where it already lives.
807///
808/// This crate cannot rely on `data_dir()`'s own built-in macOS fallback,
809/// which probes `<base>/<app_stem>` — a `databases/` directory is
810/// deliberately shared by every frust app on the machine and joins no
811/// app-stem component, so that probe can never see it.
812fn resolve_db_path_from(
813    base: &Path,
814    legacy_base: Option<&Path>,
815    name: &str,
816) -> Result<PathBuf, DatabaseError> {
817    let path = db_file_path(base, name)?;
818    // `if let Some(legacy_base) = legacy_base` first: on every non-macOS
819    // target `legacy_base` is `None`, so the `&&`'s right-hand side (the
820    // `try_exists` stat) never runs — genuinely syscall-free there, not
821    // just discarded. `try_exists` (not `exists`) so a momentarily
822    // unstat-able `path` (EACCES/ELOOP/dangling symlink) is treated as
823    // PRESENT — never silently diverted to a stale legacy file; the
824    // subsequent open surfaces the real error on the canonical path.
825    if let Some(legacy_base) = legacy_base
826        && !path.try_exists().unwrap_or(true)
827    {
828        let legacy_path = db_file_path(legacy_base, name)?;
829        // `Err(_)` on the legacy probe means treat it as absent — never
830        // divert onto a broken legacy path either.
831        if legacy_path.try_exists().unwrap_or(false) {
832            log::debug!(
833                "frust-database: {} not found, opening legacy {}",
834                path.display(),
835                legacy_path.display()
836            );
837            return Ok(legacy_path);
838        }
839    }
840    Ok(path)
841}
842
843/// The [`DatabaseError::Storage`] text for an unresolvable data directory — cfg-selected: Android names the nativeInitPlatform ordering, every other target keeps the HOME/APPDATA wording (pinned by a host test).
844#[cfg(target_os = "android")]
845const UNRESOLVED_DATA_DIR: &str = "could not resolve the app data directory: the host shell has not installed the Android directories yet (Database::open must run after FrustSurfaceView.nativeInitPlatform, i.e. from app code, not from a static initializer)";
846
847#[cfg(not(target_os = "android"))]
848const UNRESOLVED_DATA_DIR: &str = "could not resolve a user data directory (HOME/APPDATA unset)";
849
850/// [`Database::open`]'s full path resolution: sanitize `name`, resolve the
851/// user data directory, join this crate's standard `databases/<name>.db`
852/// location (reading through to a legacy base where one exists — see
853/// [`resolve_db_path_from`]), and ensure the resolved database's parent
854/// directory exists.
855///
856/// On Android, the user data directory is `<Context.getFilesDir()>`, installed
857/// by the platform shell's `nativeInitPlatform`. Returns [`DatabaseError::Storage`]
858/// if the directory cannot be resolved (e.g. `Database::open` is called before
859/// `nativeInitPlatform` completes).
860fn resolve_db_path(name: &str) -> Result<PathBuf, DatabaseError> {
861    let base = frust_paths::data_dir()
862        .ok_or_else(|| DatabaseError::Storage(UNRESOLVED_DATA_DIR.into()))?;
863    let path = resolve_db_path_from(&base, frust_paths::legacy_data_dir().as_deref(), name)?;
864    if let Some(parent) = path.parent() {
865        fs::create_dir_all(parent)
866            .map_err(|e| DatabaseError::Storage(format!("creating databases directory: {e}")))?;
867    }
868    Ok(path)
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    // --- Value conversions -------------------------------------------
876
877    #[test]
878    fn value_from_conversions() {
879        assert_eq!(Value::from(42i64), Value::Integer(42));
880        assert_eq!(Value::from(7i32), Value::Integer(7));
881        assert_eq!(Value::from(3.5f64), Value::Real(3.5));
882        assert_eq!(Value::from(true), Value::Integer(1));
883        assert_eq!(Value::from(false), Value::Integer(0));
884        assert_eq!(Value::from("hi"), Value::Text("hi".to_string()));
885        assert_eq!(
886            Value::from(String::from("hi")),
887            Value::Text("hi".to_string())
888        );
889        assert_eq!(Value::from(vec![1u8, 2, 3]), Value::Blob(vec![1, 2, 3]));
890        assert_eq!(Value::from(None::<i64>), Value::Null);
891        assert_eq!(Value::from(Some(7i64)), Value::Integer(7));
892    }
893
894    // --- Row -----------------------------------------------------------
895
896    #[test]
897    fn row_get_and_get_named() {
898        let row = Row::new(
899            vec!["id".to_string(), "name".to_string()],
900            vec![Value::Integer(1), Value::Text("a".to_string())],
901        );
902        assert_eq!(row.get(0), Some(&Value::Integer(1)));
903        assert_eq!(row.get(1), Some(&Value::Text("a".to_string())));
904        assert_eq!(row.get(2), None);
905        assert_eq!(row.get_named("name"), Some(&Value::Text("a".to_string())));
906        assert_eq!(row.get_named("missing"), None);
907    }
908
909    // --- IntoParams ------------------------------------------------------
910
911    #[test]
912    fn into_params_shapes() {
913        assert_eq!(().into_params(), Vec::<Value>::new());
914        assert_eq!(
915            [1i64, 2i64].into_params(),
916            vec![Value::Integer(1), Value::Integer(2)]
917        );
918        let values = [Value::Text("a".to_string())];
919        assert_eq!(values.into_params(), vec![Value::Text("a".to_string())]);
920        let slice: &[i64] = &[3, 4];
921        assert_eq!(
922            slice.into_params(),
923            vec![Value::Integer(3), Value::Integer(4)]
924        );
925    }
926
927    // --- Name sanitization / path shape ---------------------------------
928
929    #[test]
930    fn db_file_path_shape() {
931        let base = Path::new("/tmp/frust-database-test-base");
932        let path = db_file_path(base, "app").unwrap();
933        assert_eq!(path, base.join("databases").join("app.db"));
934    }
935
936    #[test]
937    fn db_file_path_rejects_path_separator() {
938        assert!(db_file_path(Path::new("/tmp/x"), "a/b").is_err());
939        assert!(db_file_path(Path::new("/tmp/x"), "a\\b").is_err());
940    }
941
942    #[test]
943    fn db_file_path_rejects_empty_name() {
944        assert!(db_file_path(Path::new("/tmp/x"), "").is_err());
945    }
946
947    // --- Legacy-base read-through (macOS shape, real temp dirs) ----------
948
949    /// A unique scratch directory under the OS temp dir — the read-through
950    /// tests below probe the real filesystem (`Path::try_exists`), so they
951    /// must never touch a real data directory.
952    ///
953    /// Created eagerly with `fs::create_dir` (fails loudly if the name is
954    /// already occupied, e.g. by a planted symlink) rather than left for a
955    /// later `create_dir_all` to walk through silently.
956    fn scratch_dir(tag: &str) -> PathBuf {
957        static COUNTER: AtomicU64 = AtomicU64::new(0);
958        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
959        let dir = std::env::temp_dir().join(format!(
960            "frust-database-test-{}-{tag}-{n}",
961            std::process::id()
962        ));
963        fs::create_dir(&dir).expect("scratch dir must not already exist (planted path?)");
964        dir
965    }
966
967    /// Create `<base>/databases/<name>.db` with placeholder bytes and
968    /// return its path — the on-disk shape `db_file_path` builds.
969    fn seed_db_file(base: &Path, name: &str) -> PathBuf {
970        let path = db_file_path(base, name).unwrap();
971        fs::create_dir_all(path.parent().unwrap()).unwrap();
972        fs::write(&path, b"placeholder").unwrap();
973        path
974    }
975
976    /// **Negative control for the whole fix**: an existing macOS install's
977    /// database lives at `<legacy base>/databases/<name>.db` and there is
978    /// no `<legacy base>/<app_stem>/` directory anywhere — the shape
979    /// `frust_paths::data_dir()`'s own built-in probe looks for. Resolution
980    /// must still find the legacy file. This fails if anyone "simplifies"
981    /// the fallback back into an `<legacy>/<app_stem>` directory probe.
982    #[test]
983    fn resolve_db_path_from_reads_through_to_legacy_file_with_no_app_stem_dir() {
984        let new_base = scratch_dir("legacy-read-through-new");
985        let legacy_base = scratch_dir("legacy-read-through-legacy");
986        let legacy_path = seed_db_file(&legacy_base, "app");
987
988        // The new base is empty, and the legacy base holds *only*
989        // `databases/app.db` — deliberately no `<app_stem>/` subdirectory.
990        let stem_dir = legacy_base.join(frust_paths::app_stem());
991        assert!(!stem_dir.exists(), "test fixture must have no app-stem dir");
992
993        assert_eq!(
994            resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
995            legacy_path,
996        );
997
998        let _ = fs::remove_dir_all(&new_base);
999        let _ = fs::remove_dir_all(&legacy_base);
1000    }
1001
1002    /// A database already at the new location wins, even when a legacy file
1003    /// also exists — read-through never overrides a live new-location file
1004    /// (and never migrates/deletes the legacy one).
1005    #[test]
1006    fn resolve_db_path_from_prefers_the_new_file_when_both_exist() {
1007        let new_base = scratch_dir("both-new");
1008        let legacy_base = scratch_dir("both-legacy");
1009        let new_path = seed_db_file(&new_base, "app");
1010        let legacy_path = seed_db_file(&legacy_base, "app");
1011
1012        assert_eq!(
1013            resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
1014            new_path,
1015        );
1016        assert!(legacy_path.exists(), "legacy file must be left untouched");
1017
1018        let _ = fs::remove_dir_all(&new_base);
1019        let _ = fs::remove_dir_all(&legacy_base);
1020    }
1021
1022    /// `Path::try_exists` (not `Path::exists`) semantics: a new-location
1023    /// probe that errors (here, `EACCES` from an unreadable ancestor
1024    /// directory) must never be treated as "absent" and diverted to a
1025    /// legacy file — `Err(_)` on the new-path probe means treat it as
1026    /// PRESENT, so the unresolvable path is returned unchanged and the
1027    /// subsequent `open` surfaces the real error. This fails under the old
1028    /// `Path::exists()` behaviour, which swallows the permission error into
1029    /// `false` and would wrongly divert to `legacy_path` below.
1030    #[test]
1031    #[cfg(unix)]
1032    fn resolve_db_path_from_does_not_divert_when_new_path_is_unstatable() {
1033        use std::os::unix::fs::PermissionsExt;
1034
1035        let new_base = scratch_dir("unstatable-new");
1036        let locked_dir = new_base.join("locked");
1037        fs::create_dir(&locked_dir).unwrap();
1038        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o000)).unwrap();
1039
1040        let legacy_base = scratch_dir("unstatable-legacy");
1041        let legacy_path = seed_db_file(&legacy_base, "app");
1042
1043        let path = db_file_path(&locked_dir, "app").unwrap();
1044        if path.try_exists().is_ok() {
1045            // Running as root (or under some other permission-bypassing
1046            // capability): a 0o000 directory doesn't block traversal, so
1047            // this fixture can't produce the EACCES this test targets.
1048            // Restore permissions so cleanup below can actually remove the
1049            // directory, then skip rather than assert something the
1050            // fixture didn't exercise.
1051            fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
1052            let _ = fs::remove_dir_all(&new_base);
1053            let _ = fs::remove_dir_all(&legacy_base);
1054            return;
1055        }
1056
1057        let resolved = resolve_db_path_from(&locked_dir, Some(&legacy_base), "app").unwrap();
1058        assert_eq!(
1059            resolved, path,
1060            "an unstatable new path must not divert to the legacy file"
1061        );
1062        assert_ne!(resolved, legacy_path);
1063
1064        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o700)).unwrap();
1065        let _ = fs::remove_dir_all(&new_base);
1066        let _ = fs::remove_dir_all(&legacy_base);
1067    }
1068
1069    /// Neither file exists (a first run): the new-location path is chosen,
1070    /// so `resolve_db_path` creates the database where it belongs today.
1071    #[test]
1072    fn resolve_db_path_from_uses_the_new_path_when_neither_exists() {
1073        let new_base = scratch_dir("neither-new");
1074        let legacy_base = scratch_dir("neither-legacy");
1075
1076        assert_eq!(
1077            resolve_db_path_from(&new_base, Some(&legacy_base), "app").unwrap(),
1078            db_file_path(&new_base, "app").unwrap(),
1079        );
1080    }
1081
1082    /// `legacy_base: None` — every non-macOS target, where
1083    /// `frust_paths::legacy_data_dir()` returns `None` — resolves to the
1084    /// new path unconditionally.
1085    #[test]
1086    fn resolve_db_path_from_without_a_legacy_base_uses_the_new_path() {
1087        let new_base = scratch_dir("no-legacy");
1088        assert_eq!(
1089            resolve_db_path_from(&new_base, None, "app").unwrap(),
1090            db_file_path(&new_base, "app").unwrap(),
1091        );
1092    }
1093
1094    /// Name sanitization still runs ahead of any read-through.
1095    #[test]
1096    fn resolve_db_path_from_rejects_an_invalid_name() {
1097        let new_base = scratch_dir("invalid-name");
1098        assert!(resolve_db_path_from(&new_base, None, "a/b").is_err());
1099        assert!(resolve_db_path_from(&new_base, None, "").is_err());
1100    }
1101
1102    // --- Default-engine resolution ---------------------------------------
1103
1104    #[test]
1105    #[cfg(feature = "engine-sqlite")]
1106    fn default_engine_prefers_sqlite_when_compiled() {
1107        assert_eq!(default_engine().unwrap(), Engine::Sqlite);
1108    }
1109
1110    #[test]
1111    #[cfg(all(not(feature = "engine-sqlite"), feature = "engine-turso"))]
1112    fn default_engine_falls_back_to_turso() {
1113        assert_eq!(default_engine().unwrap(), Engine::Turso);
1114    }
1115
1116    #[test]
1117    #[cfg(not(any(feature = "engine-sqlite", feature = "engine-turso")))]
1118    fn default_engine_errors_when_nothing_compiled() {
1119        assert!(default_engine().is_err());
1120    }
1121
1122    // --- Error messages --------------------------------------------------
1123
1124    #[test]
1125    fn unresolved_data_dir_is_non_empty() {
1126        assert!(!UNRESOLVED_DATA_DIR.is_empty());
1127    }
1128
1129    #[test]
1130    #[cfg(not(target_os = "android"))]
1131    fn non_android_unresolved_data_dir_matches_legacy_wording() {
1132        assert_eq!(
1133            UNRESOLVED_DATA_DIR,
1134            "could not resolve a user data directory (HOME/APPDATA unset)"
1135        );
1136    }
1137}