ADR 0013: The unit of work
ADR 0013: The unit of work
- Status: Accepted
- Date: 2026-10-06
Context
The target architecture runs each use case in one transaction,
uow.run(async (tx) => …), where tx holds the repositories and record(change). After commit,
and before the writer lock is released, the kernel calls publish for in-process subscribers (the
jobs nudge now, the feed hub later), so they see changes in seq order. The snapshot reads its
state and its seq in one read transaction.
Today repositories open their own transactions, SqliteSessionsRepository logs session changes in
its own (ADR 0011), so a use case that writes through two repository calls is not atomic (ADR 0012),
and session use cases call nudgeJobs() themselves. Facts that constrain the replacement:
- better-sqlite3 transactions are synchronous and per connection.
db.transaction(fn)throws iffnreturns a promise. An async transaction needs a manualBEGIN…COMMIT, and while it awaits, every other request’s statement on that connection runs inside it: it reads the uncommitted rows, and its writes commit or roll back with it. - The ports are async, the adapters are not. Every method in
db/repositories/interfaces.tsreturns a promise, but every SQLite repository runs synchronous statements. Anawaiton them still yields to other requests between statements. - better-sqlite3 blocks the event loop. A second connection in the same process isolates readers from an open write transaction but runs no query in parallel with it.
- The database is not in WAL mode.
db/container.tsopens one connection with the default rollback journal and sets onlyforeign_keys. The target (06) asks for WAL, one write connection and a small read pool. - The test helpers need one in-memory connection.
tests/helpers/db.tsserializes the migrated in-memory database once and restores it from the buffer before each test. A second connection cannot open that database. - Other processes may share the file. The jobs lease (ADR 0011) exists because a second process
can run against the same database, so writes keep
BEGIN IMMEDIATEwhatever the in-process scheme is.
Options
(a) One writer connection behind a mutex, reads on their own connection
uow.run takes an in-process async mutex, opens BEGIN IMMEDIATE on the write connection and
publishes after COMMIT before it releases the mutex. Queries and uow.read use a second
connection, which in WAL mode sees only committed data.
- Isolation: full. Reads never see an open write transaction; writes are serial. As in ©, a
write outside
uow.runjoins the open transaction. - Throughput: the same as today in one process (the event loop is the limit); in WAL a reader in another process no longer blocks the writer.
- Test helpers: a buffer cannot be shared, so each test file gets a database file (a migrated
template copied per test). Slower than a restore from memory, and
restoreDbmust reopen both connections. - WAL in production: required. Self-hosted databases switch on first start;
data.db-waland-shmsit next to the file. The documented backup uses SQLite’s backup command, which handles WAL; a network file system does not. - Snapshot:
uow.readis aBEGINon the read connection. It does not hold off writers, and its view stays fixed while writes commit. Twouow.reads cannot share one read connection, so it needs its own mutex or a pool.
(b) Synchronous transaction callbacks
uow.run((tx) => …) takes a synchronous callback: tx holds synchronous repositories, and the
transaction is better-sqlite3’s own. A use case awaits whatever is truly async (image storage, mail)
before or after it, never inside.
- Isolation: full, with no lock: nothing else runs while the callback runs.
publishright after commit is inseqorder for the same reason. - Throughput: the same as today.
- Test helpers: unchanged.
- WAL in production: not needed now; it can follow on its own merits.
- Snapshot: a synchronous deferred transaction, consistent by construction.
- Cost: every repository the use cases call inside
txneeds a synchronous interface next to, or in place of, the async port. Use cases stop matching the target’s shape, and a Postgres adapter, which D6 keeps possible, would mean rewriting them. A callback that returns a promise only fails at run time, unless thetxtype rules it out.
© One connection, one async mutex
uow.run(async (tx) => …) takes the mutex, runs BEGIN IMMEDIATE, and publishes after COMMIT
before it releases the mutex. uow.read takes the same mutex for multi-statement reads that must
agree, such as the snapshot. Single-statement queries take neither.
- Isolation: between use cases, full: they are serial. A query outside the mutex can run while a
use case awaits, and then reads its uncommitted rows. If that use case rolls back, the query
showed state that never existed. A write outside
uow.run(a single-write path step 3 leaves alone, the jobs loop) joins the open transaction and rolls back with it, so the default repositories’ writes must take the mutex too. - Throughput: the same as today, provided a callback awaits no I/O outside the database (the target already forbids that); one that does stalls every other use case.
- Test helpers: unchanged.
- WAL in production: not needed.
- Snapshot:
uow.readholds off writes while it assembles, a few milliseconds at the target’s capacity. - Cost: the least code. The kernel interface is that of (a), so moving to (a) later changes the kernel and the test helpers, not the use cases.
Recommendation
© now, with (a) as its later form. It keeps the async ports and the target’s use-case shape,
leaves the test helpers and the production journal as they are, and gives the guarantees the feed
depends on: atomic use cases, changes published in seq order, a consistent snapshot, provided
every write outside uow.run also takes the mutex. Its remaining gap, a single-statement query
reading an open transaction’s rows, closes when (a) adds WAL and a read connection, and no use case
changes for that. (b) gives the strongest guarantee for the least locking, but at the price of a
second, synchronous set of repository interfaces that the rest of the target does not want.
Decision
(a), chosen by the user on 2026-10-06 over the recommendation: reads must never see a write that is still open, also outside the unit of work.
- The database runs in WAL mode with
synchronous=NORMAL.db/container.tsopens a write connection and a read-only connection. - Repository methods named
find…(but notfindOrCreate…),list…,search…,count…orget…run on the read connection. Every other method takes the in-process write lock and runs on the write connection, so a write outsideuow.run(the jobs loop, a single-write path) waits for an open use case instead of joining its transaction. - The lock is on
globalThis, like the jobs nudge, so module copies under Next share it.withWriter(fn)holds it and handsfnthe write connection;uow.runbuilds on it. Writes keepBEGIN IMMEDIATEfor other processes. - Test databases are files: each worker migrates a template once per test file and copies it before each test.
Consequences
- A repository method that writes must not have a read name: the read connection is read-only, so such a method fails on its first call.
- A read sees each write as soon as it commits, and never one in progress.
- Self-hosted databases switch to WAL on first start;
data.db-walanddata.db-shmsit next todata.dbin the/datavolume. The documented backup (.backup) and restore already handle them. The database must stay on a local file system. uow.runopensBEGIN IMMEDIATEon the write connection under the write lock and keeps it open across the callback’s awaits; a repository’s own transaction inside it becomes a savepoint. A throw or a failureResultrolls it back.tx.record()collects changes, which are written beforeCOMMITand published after it, still under the lock.uow.readhas a third, read-only connection behind a lock of its own: a plain read on the shared read connection would otherwise join an open read transaction and see its older view.- A use case inside
uow.runmust write throughtx, never throughgetRepositories(): that waits for the lock it holds.