# frust-database
A platform-independent, **synchronous, pure-Rust local SQL database** for frust
apps — an in-process SQLite store (the default `engine-sqlite` backend via
`rusqlite`) or the in-process turso store (the optional `engine-turso`
backend, an async engine bridged onto this crate's synchronous API). Unlike
every other plugin under `plugins/`, this crate carries **no** `frust-plugin`
dependency: it needs no JNI/platform handle, since both SQL engines reach
on-disk storage directly through their own FFI/bindings rather than through an
OS capability API. File compatibility is maintained across engines via a
strict interop discipline (WAL journal mode, no MVCC/encryption pragmas).
Like every frust **platform plugin**, this crate is added to your app's own
`Cargo.toml` alongside `frust` (the pubspec model) — the `frust` facade does
not re-export it.
**Platform support:** every target the SQLite engine builds for; the data directory is resolved per platform, including Android once the host shell has installed its directories.
More about Frust: <https://frust.dev> and <https://github.com/frust-rs/frust>.
---
## 1. Add the dependency (the only step)
```toml
# app Cargo.toml — [dependencies]
frust-database = { path = "<frust>/plugins/database" } # crates.io later
```
`<frust>` is the path to your frust checkout — derive it from the `frust = {
path = "…" }` line the scaffold already wrote. That's it: with just this line,
SQLite-backed storage works on every platform.
**No manifest, plist, permission, Gradle module, or Swift package is
needed** — local SQL storage requires no OS permission on any platform, and
both the SQLite and turso backends are plain Rust-to-C FFI (or pure Rust, for
turso) with no Kotlin/Swift glue to wire in. The frust TUI's **Add Plugin**
dialog still lists this plugin (for a consistent workflow across every
plugin), but all it applies is the Cargo dependency line above.
---
## 2. Quick start
Open a database at the standard location (`<data_dir>/databases/<name>.db`),
create a table, insert, and query. Every [`Database`] operation is a
**blocking** synchronous call and must run on a background thread via
`frust_reactive::spawn_blocking`:
```rust
use frust_database::Database;
let db = Database::open("app")?;
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)",
(),
)?;
db.execute("INSERT INTO notes (body) VALUES (?1)", ["hello world"])
})
.await??;
println!("Inserted {inserted} rows");
```
A more complex example with a transaction:
```rust
use frust_database::{Database, Value};
let db = Database::open("app")?;
txn.execute(
"INSERT INTO notes (body) VALUES (?1)",
["first note"],
)?;
txn.execute(
"INSERT INTO notes (body) VALUES (?1)",
["second note"],
)?;
// Commits on Ok; rolls back on Err
Ok(())
})
})
.await??;
```
Query and read results:
```rust
use frust_database::{Database, Value};
let db = Database::open("app")?;
})
.await??;
for row in rows {
if let Some(Value::Text(body)) = row.get(1) {
println!("Note: {body}");
}
// Or by column name:
if let Some(Value::Text(body)) = row.get_named("body") {
println!("Note: {body}");
}
}
```
Every parameter in the examples above — each `["value"]` or `[1i64]` — is a
statement parameter list. The crate accepts arrays and slices of anything
that converts into [`Value`] (the five SQL storage classes: `Null`,
`Integer`, `Real`, `Text`, `Blob`), or an empty tuple `()` for no parameters:
```rust
use frust_database::{Database, Value};
let db = Database::open("app")?;
"INSERT INTO items (id, name, price, data) VALUES (?1, ?2, ?3, ?4)",
[
Value::Integer(42),
Value::Text("item name".into()),
Value::Real(19.99),
Value::Blob(vec![1, 2, 3]),
],
)?;
// Or derive conversions from primitive types
db.execute(
"INSERT INTO items (id, name, price) VALUES (?1, ?2, ?3)",
[42i64, "item", 19.99],
)?;
// Empty tuple for no parameters
db.execute("VACUUM", ())
})
.await??;
```
---
## 3. Never call a database operation on the UI thread
Every [`Database`] operation (`execute`, `query`, `transaction`) is a
**blocking** synchronous call — it runs on the calling thread and blocks until
the SQL operation completes. Calling it directly on the platform UI thread
freezes the frame loop. Pair every database call with `frust_reactive::spawn_blocking`
(an app-tier concern — the plugin itself stays framework-free per the
platform-plugin charter):
```rust
// ✗ Wrong: freezes the UI
let rows = db.query("SELECT * FROM notes", ())?;
// ✓ Right: runs on a background thread
})
.await??;
```
UI-thread discipline is **docs-only here, matching `frust-secure-storage`'s
own precedent for calls it can't cheaply guard in code** — there is no
code-level guard, and adding one would require an FFI dependency this
pure-Rust crate deliberately carries none of.
The `engine-turso` backend additionally reports `DatabaseError::AsyncContext`
rather than panicking if a database call is made directly from inside an
async runtime's `block_on` body (as opposed to from inside a
`spawn_blocking` closure, which is the sanctioned path and is never
rejected) — see §5.2.
---
## 4. Threading model: one serialized connection per handle
A [`Database`] wraps exactly one engine connection behind a `Mutex`, so every
call through one handle is serialized — `Database` is `Send + Sync` and cheap
to share (e.g. behind an `Arc`), but two concurrent calls on the *same*
handle queue rather than run in parallel. `Database` does **not** implement
`Clone` — open a separate handle per connection you want instead.
**Open multiple handles for concurrent readers — `engine-sqlite` only.**
Every real backend opens its file in **WAL (write-ahead logging) journal
mode** (see *Interop discipline* below), which supports concurrent readers
alongside one writer, but only across separate connections — an app that
wants read parallelism opens more than one `Database` handle onto the same
file rather than sharing one handle across threads expecting internal
parallelism. On `engine-sqlite`, this is real wall-clock parallelism:
`rusqlite`'s bundled SQLite runs separate connections' reads on separate OS
threads.
```rust
use frust_database::Database;
let db1 = Database::open("app")?;
let db2 = Database::open("app")?; // Same file, different connection
// engine-sqlite: two independent background tasks that genuinely read in
// parallel. Each handle moves into its own closure — Database isn't Clone.
let (rows1, rows2) = tokio::join!(task1, task2);
```
**`engine-turso` does not get this parallelism.** Every `Database` handle
routes its operations through one process-wide, single-threaded bridge
(`src/turso.rs`'s module doc, *The bridge*) — however many turso handles an
app opens onto the same file, their calls serialize/interleave on that one
bridge thread rather than run in wall-clock parallel. The `tokio::join!`
shape above still works and is still correct on turso — `db1` and `db2`
are independent connections, each seeing the other's committed writes —
but it buys correctness and visibility, not a faster read path. Do not
reach for multiple turso handles expecting the sqlite-style speedup.
---
## 4a. Transaction hazards and how they're handled
`Database::transaction` runs its closure inside `BEGIN`/`COMMIT`/`ROLLBACK`
on the handle's one shared connection. Three ways that could go wrong are
handled explicitly rather than left to hang or silently corrupt state:
- **Same-thread reentrant use.** Calling `execute`/`query`/`transaction` on
the *same* `Database` handle from inside its own `transaction` closure —
e.g. an `Arc<Database>` captured and called back into — returns
`DatabaseError::Reentrant` immediately. It is a typed error, not a hang:
the connection's lock is non-reentrant, but re-entry is detected by
thread identity before ever blocking on it. Cross-thread contention on
the same handle is unaffected — it still queues, per §4 above. Run
statements inside the transaction through the `Transaction` handle the
closure is given instead.
- **A failing `COMMIT`.** If the `COMMIT` statement itself is rejected (for
example, a deferred constraint check that only runs at commit time), the
transaction is rolled back before the error is returned — the handle is
left clean, not stranded mid-transaction. A later call on the same
handle works normally.
- **A panicking closure.** If the closure passed to `transaction` panics, a
`Drop` guard armed since the `BEGIN` rolls back on unwind, so the
connection is not left mid-transaction for whoever holds the handle
next. This guard runs on ordinary unwind (the dev/debug profile). The
release profile is `panic = "abort"` (`docs/DEVELOPMENT.md`'s
release-profile hardening) — under abort the process exits before any
`Drop` runs, so there is no surviving handle left to strand in the first
place; the guard's job is specifically the unwind case.
All of the above assumes the recovery `ROLLBACK` itself succeeds. It is
issued best-effort: if that statement also fails (an I/O error mid-rollback,
disk full), the transaction can remain open on the handle with no taint
recorded — a narrow, accepted residual documented in `docs/LIMITATIONS.md`
(`db-rollback-failure-residual`). If you must be robust against that class
of failure, drop the handle on any `transaction` error and open a fresh one.
---
## 5. Engine selection
This crate supports multiple SQL engine backends, selected at compile time
(via Cargo feature) and optionally overridden at open time (via
[`OpenOptions::engine`]). The default is SQLite.
### 5.1 SQLite (default, `engine-sqlite`)
The `engine-sqlite` feature compiles `rusqlite`'s bundled SQLite — an
in-process, synchronous, on-disk SQL database. It is **always available**
when this crate is in your dependency graph unless you explicitly disable
the default features:
```toml
# Your app's Cargo.toml
frust-database = { path = "<frust>/plugins/database" } # engine-sqlite enabled by default
```
**What it costs:** ~1.0–1.7 MB (measured in the ship profile — see §6).
**What it buys:**
- Zero external service dependencies — the entire database lives in a file
on your device
- Immediate synchronous operations on every platform
- Standard SQLite `3` format: readable by the desktop `sqlite3` CLI tool and
any third-party SQLite library
- Full ACID transaction support
- Platform support: Android, iOS, macOS, Linux, Windows
To use it, just open a database the normal way:
```rust
let db = Database::open("app")?;
```
**Android platform note:** On Android, `Database::open` stores databases in
`<Context.getFilesDir()>/databases/<name>.db`. This directory is installed by
the platform shell's `nativeInitPlatform` during app initialization. `Database::open`
must run *after* this initialization completes — typically from application code,
not from static initializers. No `HOME` or XDG environment variable is consulted
on Android; the directories are set up through platform-specific calls.
### 5.2 Turso (optional, `engine-turso`)
The `engine-turso` feature compiles the [`turso`](https://crates.io/crates/turso)
crate (exact-pinned to `=0.7.2`) — a **pure-Rust, SQLite-compatible database
engine that runs in-process against a local file**, not a network service.
This is the embedded `turso` crate, distinct from Turso's separately-branded
hosted-cloud offering — nothing in this backend talks to a network, requires
an account, or requires an API token. A `turso`-backed database is a plain
local file, opened and queried entirely in-process, exactly like the
`engine-sqlite` backend.
Turso's own API is `async`; this crate bridges it onto its synchronous
`EngineConn` seam via a single crate-owned background thread running a
current-thread tokio runtime (see `src/turso.rs`'s module doc for the full
bridge design, including the `DatabaseError::AsyncContext` guard mentioned in
§3).
**What it costs:**
- A significant binary-size delta — **+9.83 MB** measured (Linux x86_64
desktop host, release/ship profile, `turso =0.7.2`, 2026-08-09): enabling
`engine-turso` alongside the always-on `engine-sqlite` default roughly
doubles this crate's own contribution to a shipped binary. See §6 for the
full measurement procedure and both absolute totals.
- Pre-1.0 upstream churn — this crate exact-pins the dependency
(`turso = "=0.7.2"`) rather than allowing a range, precisely because the
crate hasn't reached a stable 1.0 API yet
- Experimental upstream indexes — see *Caveats* (§7)
**What it buys:**
- A pure-Rust engine with no C FFI in the dependency graph
- The same on-disk file format and interop discipline as the sqlite engine
(§5.3) — a `turso`-backed file is not distinguishable from a
`rusqlite`-backed one by any standard SQLite tool
- A seam this crate can extend later toward turso-specific capabilities such
as vector search or cloud sync — out of scope for v1, and nothing in this
crate's public API exposes them today, but the engine-selection seam
(`Engine`/`OpenOptions`) doesn't preclude adding them behind a future
feature
To use it, enable the feature and pass the engine explicitly:
```toml
# Your app's Cargo.toml
frust-database = { path = "<frust>/plugins/database", features = ["engine-turso"] }
```
```rust
use frust_database::{Database, Engine, OpenOptions};
let db = Database::open_with(
"app",
OpenOptions::new().engine(Engine::Turso),
)?;
// ... the rest of your code is identical to SQLite
})
.await??;
```
### 5.3 Cross-engine file compatibility
Both backends follow a strict **interop discipline** to maintain file
compatibility:
- Every file-backed connection ends up in **WAL journal mode** before any
statement runs — the two backends get there differently. The
`engine-sqlite` backend *sets* WAL journal mode itself immediately after
open. The `engine-turso` backend cannot write a rollback-journal file at
all, so it never issues a `journal_mode` pragma; instead it *asserts* WAL
by reading `PRAGMA journal_mode` back after open and refusing the
connection if the file reports anything else.
- No MVCC session extensions (`PRAGMA journal_mode = mvcc`)
- No encryption pragmas (`SQLCipher`, `cipher`, `hexkey`)
- `PRAGMA foreign_keys = ON` is set on every connection by both backends
(per-connection, never persisted to the file)
This discipline means a `frust-database` file is always a **plain, unencrypted,
standard-SQLite-tool-readable file** — you can open it with the desktop
`sqlite3` CLI, migrate to/from other SQLite libraries, or switch engines at
will. An empty in-memory database uses the same pragmas for consistency, even
though WAL mode doesn't apply to memory.
---
## 6. Size figures
The binary size impact of `frust-database` dependencies at the shipped profile
(optimized release build):
| SQLite | 1.0–1.7 MB | `rusqlite`'s bundled SQLite; measured locally |
| Turso | **+9.83 MB** (`engine-turso` added on top of the default `engine-sqlite` build) | `turso =0.7.2`; measured 2026-08-09 on a Linux x86_64 desktop host release binary — see *Turso size measurement procedure* below for the full method and raw figures |
These figures are approximate, varies by target platform, and assume default
link configuration (you may reduce size further by enabling LTO or other
optimizations).
### Turso size measurement procedure
Reproducible on a `turso` pin bump — re-run this exact procedure and update
the table row above plus the date/pin in this section.
`scripts/size-report.sh` is this repo's standard release-artifact snapshot
tool (see `docs/DEVELOPMENT.md`'s Instrumentation section), but it is a
**whole-app snapshot, not a delta tool**: its primary target is a release
`arm64-v8a` `.so` via `cargo ndk`, with a desktop `cargo-bloat` top-20 crate
breakdown as a secondary host-proxy. It doesn't isolate one dependency's
contribution by itself — getting a delta means running the underlying
release build twice (once per feature set) against the same app and diffing
the resulting artifact, which is what the steps below do explicitly. The
figure recorded above was measured this way, on a Linux x86_64 host, against
the release **desktop binary** (no Android SDK/NDK cross-compile was
involved in this specific run — state which artifact your own re-run
measures if it differs):
1. Scaffold a minimal probe app **outside this repo** (a temp dir), with a
`Cargo.toml` `path`-dependency on this crate (`frust-database`) plus the
`frust` facade, and a couple of lines of `src/lib.rs` that actually call
`Database::open`/`execute`/`query` (not just list the dependency) — under
LTO, an unused dependency can get linked out entirely, which would silently
zero out the very delta being measured.
2. Match this crate's `[profile.release]` shape (`lto = "fat"`,
`codegen-units = 1`, `strip = "symbols"`, `panic = "abort"` — the same
profile `crates/frust-drive/templates/app`'s generated `Cargo.toml` ships) in the probe app's
own manifest, so the measurement reflects the ship floor rather than an
unoptimized default release build.
3. `export CARGO_TARGET_DIR=<probe-app-dir>/target` before building, so the
probe app's build neither reads from nor writes into any other target
directory (including this repo's own, and any sibling in-flight build).
4. Build once with default features (`engine-sqlite` only): `cargo build
--release`. Record the resulting binary's size (`ls -l`/`wc -c` on the
`target/release/<bin>` executable — an exact byte count, not an estimate).
5. Add `features = ["engine-turso"]` (additive — `engine-sqlite` stays on,
matching this crate's own default-doesn't-disable-alongside shape) and
rebuild the same probe app: `cargo build --release --features
engine-turso`. Record the new binary's size the same way.
6. Delta = (step 5's size) − (step 4's size). That delta, plus both raw
sizes, the date, the exact `turso` pin, and which artifact was measured
(desktop host binary vs. Android `.so`), is what belongs in the §6 table
and in this procedure section on every re-run.
**2026-08-09 raw figures** (Linux x86_64 desktop host, release/ship profile,
`turso =0.7.2`):
| `engine-sqlite` only (default features) | 12.97 MB | 13,601,560 |
| `engine-sqlite` + `engine-turso` | 22.80 MB | 23,904,912 |
| **Delta** | **+9.83 MB** | **+10,303,352** |
The probe app was never committed.
---
## 7. Caveats
- **v1 is basic SQL only.** No prepared-statement caching, no streaming
cursors, no migrations, no named parameters (positional only, matching this
crate's v1 scope). Future enhancements (not v1) include named parameters,
statement cache, streaming query results, and a migration runner.
- **Param-count strictness differs by engine.** Supplying fewer positional
parameters than the statement declares placeholders is an error
(`DatabaseError::Sql`) on the SQLite engine — a client-side guard rusqlite
adds — but on the turso engine the missing placeholders silently bind as
`NULL` and the statement succeeds (turso `=0.7.2` performs no count check
and exposes no parameter-count API this crate could enforce one with).
Always supply exactly as many parameters as the SQL declares; the
conformance suite pins the strict behavior as sqlite-only.
- **No code-level UI-thread guard.** Like `frust-secure-storage`, blocking-call
discipline is docs-only. There is no shared guard helper in this codebase,
and adding one would require an FFI dependency this pure-Rust crate
deliberately carries none of. Respect the pairing with
`frust_reactive::spawn_blocking`.
- **Turso indexes are experimental upstream.** This crate's own cross-engine
conformance suite deliberately excludes `CREATE INDEX` from the shared
behavioral contract for this reason — the sqlite backend has its own
sqlite-only index test, but no equivalent guarantee is made for turso. Use
indexes cautiously in production turso-backed databases until the upstream
crate marks them stable.
- **`frust create --overwrite` is a non-issue here** — this plugin adds
nothing to the generated project besides the Cargo dependency line, so
there is nothing for `--overwrite` to drop.
---
## 8. Testing this crate itself
The cross-engine **conformance suite** (`src/conformance.rs`) exercises the
`engine-sqlite` backend's basic operations (execute, query, transaction) plus
edge cases (empty parameters, NULL values, type conversions) through one
engine-agnostic suite. The `engine-turso` backend has its own dedicated test
module (`src/turso.rs`), covering the same execute/query/transaction/WAL
contract directly, plus cases specific to its async bridge (the
`DatabaseError::AsyncContext` guard, concurrent callers sharing the bridge
thread).
Run the full test suite (default features — `engine-sqlite` only):
```bash
cargo test -p frust-database
```
Run the turso backend's own tests too:
```bash
cargo test -p frust-database --features engine-turso
```
## License
Licensed under either of MIT or Apache-2.0 (SPDX: `MIT OR Apache-2.0`), at your
option. See `LICENSE-MIT` and `LICENSE-APACHE` beside this README.