Files
rdbms-playground/docs/adr/0010-database-access-via-worker-thread.md
T
claude@clouddev1 c1e52920eb DSL parser, async DB worker, types, history, metadata, polish
Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
2026-05-07 13:32:19 +00:00

3.7 KiB

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.