# 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<Request>` 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.