# ADR-0010: Database access via a dedicated worker thread ## Status Accepted ## Context `rusqlite::Connection` is `Send` but `!Sync`, and its API is synchronous. Our application loop is asynchronous (Tokio, ADR-0001). We need a clean async boundary that keeps: - The `App::update` function pure — same input, same state transition, same outputs (per ADR-0001's Elm-style design); this is what makes ADR-0008's Tier 1 and Tier 3 tests tractable. - The path to future requirements (B3 query timeout/cancellation, U1 snapshot capture, P3 auto-save) free of architectural refactors. - Errors surfaceable in a way that the future H1 friendly-error layer can plug into without callsite changes. Considered alternatives: 1. **Sync calls from async tasks** — call rusqlite directly inside `update`. Blocks the event loop on every DB call. Unworkable as soon as queries grow. 2. **`tokio::task::spawn_blocking` per call** — uses tokio's blocking thread pool. Acceptable for short ops but the pool is sized for short tasks, and a long-lived "this is the database connection" task is not its intended use. 3. **Dedicated worker thread with typed request/response channels** — the connection lives on a thread we own; the App holds a sender; replies come back via per-request `oneshot` channels. ## Decision A single dedicated OS thread (`std::thread::Builder::new().name("rdbms-db-worker")`) owns the `rusqlite::Connection`. The App talks to it through: - A bounded `tokio::sync::mpsc::Sender` carrying typed request enum variants, each of which embeds a `tokio::sync::oneshot::Sender` for the reply. - A typed `DbError` (`thiserror`-derived) wrapping the union of database-side failure modes, with a `friendly_message()` method whose body today is a passthrough — this is the hook point for the future H1 friendly-error layer. The worker: - Receives requests via `Receiver::blocking_recv` (no Tokio context required on the worker thread). - Performs the operation synchronously against the connection. - Sends the result to the per-request `oneshot` reply channel. - Exits when the last `Sender` is dropped (clean shutdown via Drop, no shutdown protocol). The public `Database` handle is `Clone` (it's a wrapper around a `Sender`), so multiple components can hold it. ## Consequences - `App::update` stays sync and pure. The runtime, on receiving an `Action::ExecuteDsl`, spawns a `tokio::spawn` task that awaits the DB worker and posts the result back as a new `AppEvent`. The Elm pattern is preserved. - New database operations are mechanical to add: define a `Request` variant with its `oneshot` reply type, add a method on `Database`, add a handler in the worker. - B3 (query timeout/cancellation) lands on top of this without architecture change: a cancellation token can be stored alongside the in-flight request, and the worker can interrupt via `rusqlite::Connection::interrupt`. - U1 (snapshot capture) and P3 (auto-save) are also additive: more `Request` variants, possibly using SQLite's online backup API. - Errors carry a typed kind (`UniqueViolation`, `NoSuchTable`, etc.). When H1's friendly-error layer lands, the body of `friendly_message()` becomes the translation table; callsites do not change. - Cost: an extra OS thread per database. For a desktop tool this is negligible; this would be different for a server application that hosts many databases. ## Implementation notes - Channel capacity 64 is sufficient bursts head-room for an interactive tool. - The connection is opened in the spawning thread and *moved* into the worker. This gives us fail-fast behaviour: a bad path or permissions issue surfaces immediately to the caller before any worker is started.