c1e52920eb
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.
3.7 KiB
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::updatefunction 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:
- Sync calls from async tasks — call rusqlite directly
inside
update. Blocks the event loop on every DB call. Unworkable as soon as queries grow. tokio::task::spawn_blockingper 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.- 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
oneshotchannels.
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 atokio::sync::oneshot::Senderfor the reply. - A typed
DbError(thiserror-derived) wrapping the union of database-side failure modes, with afriendly_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
oneshotreply channel. - Exits when the last
Senderis 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::updatestays sync and pure. The runtime, on receiving anAction::ExecuteDsl, spawns atokio::spawntask that awaits the DB worker and posts the result back as a newAppEvent. The Elm pattern is preserved.- New database operations are mechanical to add: define a
Requestvariant with itsoneshotreply type, add a method onDatabase, 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
Requestvariants, possibly using SQLite's online backup API. - Errors carry a typed kind (
UniqueViolation,NoSuchTable, etc.). When H1's friendly-error layer lands, the body offriendly_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.