frust-database 0.5.0

Synchronous local SQL database for Frust apps, backed by SQLite (default) or the optional turso engine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# 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")?;

let inserted = frust_reactive::spawn_blocking(move || {
    db.execute(
        "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")?;

let result = frust_reactive::spawn_blocking(move || {
    db.transaction(|txn| {
        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")?;

let rows = frust_reactive::spawn_blocking(move || {
    db.query("SELECT id, body FROM notes WHERE id = ?1", [1i64])
})
.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")?;

frust_reactive::spawn_blocking(move || {
    db.execute(
        "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
let rows = frust_reactive::spawn_blocking(move || {
    db.query("SELECT * FROM notes", ())
})
.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 task1 = frust_reactive::spawn_blocking(move || db1.query("SELECT * FROM notes", ()));
let task2 = frust_reactive::spawn_blocking(move || db2.query("SELECT * FROM items", ()));

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
let rows = frust_reactive::spawn_blocking(move || {
    db.query("SELECT * FROM notes", ())
})
.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):

| Engine | Binary impact | Notes |
|--------|---------------|-------|
| 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`):

| Build | Binary size | Bytes |
|-------|------------:|------:|
| `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.