@ultimat3/db 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,618 @@
1
+ # @ultimat3/db — agent notes
2
+
3
+ Tier 1 — it imports `@ultimat3/core` and nothing else, so tier 1 is the lowest its real imports
4
+ allow. That placement is load-bearing: `@ultimat3/entity` (tier 2) owns the Postgres driver and
5
+ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` or anything higher
6
+ — entity snapshots arrive as a parameter (`EntityDescriptionLike`), never as an import.
7
+
8
+ | Rule | |
9
+ |---|---|
10
+ | Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing |
11
+ | SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
12
+ | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
13
+ | SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
14
+ | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
15
+ | Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
16
+ | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
17
+ | Exports | explicit in `src/index.ts`; no `export *` |
18
+ | Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source |
19
+
20
+ Pinned public seam — `@ultimat3/auth`, `@ultimat3/entity` and `@ultimat3/jobs` are written
21
+ against these exact names: `SqlFragment`, `sql`, `raw`, `identifier`, `join`, `DbClient`, `DbTx`,
22
+ `db`, `setDbClient`, `withTransaction`, `currentTx`. Changing a signature here breaks three
23
+ packages — `entity`'s `postgresDriver()` compiles every statement out of `sql`/`identifier`/`join`
24
+ and finds its connection through `db()`.
25
+
26
+ Deliberate cycle (safe — nothing is referenced at module-evaluation time):
27
+ `client.ts ⇄ transaction.ts`, and `pglite.ts → transaction.ts` for the same reason. `db()`
28
+ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`, or it would re-enter
29
+ itself. Keep both sides `function` declarations so hoisting covers the TDZ.
30
+
31
+ `pglite.ts` is a pool of exactly one: PGlite is a single session, so `reserve()` (backed by
32
+ `pglite-turns.ts`) is what stops two concurrent `BEGIN`s becoming one transaction. Three rules
33
+ hold it together and none is optional — the plain path takes a turn; a statement issued while a
34
+ transaction is **live** (`inLiveTx()`) skips the queue because it is already inside the transaction
35
+ holding it; and a reservation runs direct **only while its turn is held**, re-queueing through
36
+ `turns.run` once `release()` has been called. Drop the first and a rollback is silently lost; drop
37
+ the second and `enqueue(input, { outbox: false })` inside `withTransaction` hangs forever; drop the
38
+ third and a `tx` handle leaked past its scope writes into whichever transaction holds the connection
39
+ next. The first two are pinned by real-database tests in `pglite-embedded.test.ts` and a fake driver
40
+ cannot catch either; the third is a fake-driver test in `pglite.test.ts`, because it is about
41
+ ordering, not SQL. That is the split between the two files: `pglite.test.ts` pins the adapter
42
+ against fakes, `pglite-embedded.test.ts` boots the WASM module once and pins the binding.
43
+ `pglite-observer.test.ts` is the third, split off the first purely for the line ceiling, along the
44
+ seam `observe.ts` already draws.
45
+
46
+ **The second rule fences on `inLiveTx()`, never on `currentTx() !== undefined`** — the two are
47
+ different questions and reading the second as the first was a cross-transaction write. The
48
+ `AsyncLocalStorage` store rides into every promise chain started inside `withTransaction`, so a
49
+ statement the app forgot to `await` still found a store after COMMIT, skipped the turn queue, and
50
+ landed inside whichever unit of work held the single session next: measured `BEGIN`, `select 'inside
51
+ tx'`, `COMMIT`, `BEGIN`, `select 'straggler'`, `select 'inside tx 2'`, `COMMIT` — committed by a
52
+ transaction that never issued it, with nothing anywhere to read. `runRoot` now marks `TxState.live`
53
+ false on every exit and `inLiveTx()` (`transaction.ts`) is the one reader. A closed scope falls
54
+ through to `turns.run` **quietly**, exactly as `client.ts`'s released pin sends a late statement back
55
+ to the pool — one answer to one question, on both drivers. `currentTx()` deliberately still answers
56
+ with the dead handle: its statements go through the reservation, whose own `held` fence already
57
+ re-queues them, and it is a pinned public seam three packages are written against.
58
+
59
+ `Turn` (`pglite-turns.ts`) is `Disposable`, same shape as `DbConnection`: `release()` and
60
+ `[Symbol.dispose]` are the same call, idempotent for free because it is a settled promise's
61
+ `resolve`, not a counter. `TurnQueue.run()` holds its turn with `using`, not a hand-rolled
62
+ `try`/`finally` — the pattern this package uses everywhere a scope-bound resource must go back on
63
+ every exit. `reserve()` in `pglite.ts` cannot use `using` for the turn it takes: the turn outlives
64
+ that function, released later by the caller's own `release()`/`[Symbol.dispose]`, so it calls
65
+ `turn.release()` explicitly instead.
66
+
67
+ The third rule is **both** drivers', not PGlite's alone: `client.ts`'s pinned handle also runs
68
+ direct only while it is held, and once `release()` has been called a late statement goes back
69
+ through the pool for a connection of its own. On a server the leak is quieter than on PGlite and
70
+ worse — the pool has already handed that physical connection to another unit of work, so the stray
71
+ row lands inside *their* transaction and is committed or rolled back with it. `release()` is
72
+ idempotent on both, because nothing in the type stops a caller from also releasing by hand, and a
73
+ second release frees a pin that is no longer ours. `DbConnection` is `Disposable`: `using
74
+ connection = await client.reserve()` is the shape, and `[Symbol.dispose]` is `release()` itself,
75
+ never a second code path.
76
+
77
+ **A pin is held by a `using` declaration, never a hand-rolled `try/finally`** — `withTransaction`
78
+ and `readOnlyQuery` are the two sites, and both now read the same. A `finally` only covers the
79
+ statements someone remembered to put in its `try`, and `withTransaction` proved it: `BEGIN` sat
80
+ *above* the block, so a `BEGIN` that rejected — a dead connection, a server in recovery, a
81
+ `statement_timeout` — returned the pin to nobody. On a pool that leaks one connection per failure;
82
+ on PGlite it holds the single session's turn, and every statement in the process after it waits
83
+ forever. `BEGIN` therefore lives inside the guarded scope, which is what `readonly-query.ts`
84
+ already did. Consequence worth knowing: a failed `BEGIN` now also emits a best-effort `ROLLBACK`
85
+ the server answers with a notice — cheaper than a second code path for the one statement that
86
+ opens nothing.
87
+
88
+ **`sqlstate.ts` is the only place a SQLSTATE is read, and the ordering inside it is the whole
89
+ point.** Measured, bun 1.3.14 against Postgres 17: `Bun.SQL` puts the literal string
90
+ `ERR_POSTGRES_SERVER_ERROR` on `code` and the SQLSTATE on `errno`; PGlite — node-postgres' protocol
91
+ — puts the SQLSTATE on `code` and carries no `errno` at all. So `errno` is read first, `code`
92
+ second, and both are shape-tested (`^[0-9A-Z]{5}$`) so an Ultimate code can never be mistaken for
93
+ one. `isLedgerMissing` used to do this read itself, reading `code` alone: correct on the embedded
94
+ driver and **`false` for a genuinely missing ledger on every production one**, which is exactly the
95
+ split axiom 1 forbids. `DB_SQLSTATE_CODES` is closed — a state the framework has no instruction for
96
+ stays `X_DB_UNAVAILABLE`, and a new instruction is a new row there, never a new `catch` at a call
97
+ site. `driverError()` in `errors.ts` is the only consumer that builds an error out of it, and
98
+ `sendOn` is the only caller of that.
99
+
100
+ **`DbTx.origin` is the client the scope was opened on, never the pin it runs on.** A `DbClient`
101
+ handed to `withTransaction` (or `baseClient()`) is what identifies the *database*; the reservation
102
+ is how this scope keeps its statements on one connection, which is an implementation detail nobody
103
+ above should have to know. The field exists because tier 2 could not answer the question without
104
+ it: `@ultimat3/entity`'s repositories can be pinned (`database(shard)`), and a pinned repository
105
+ inside `withTransaction` sends to its own pool while the `BEGIN` sits on a reservation, so the write
106
+ commits immediately, survives the rollback, and is invisible to reads inside the transaction —
107
+ silent loss of transactionality, not a crash. `{ client: shard }` does not fix it either: the
108
+ transaction runs on a *reservation* of the shard. With nothing to compare, entity's only honest
109
+ answer was `X_REPO_CLIENT_PINNED`; `tx.origin === thePinnedClient` makes the case work and leaves
110
+ the refusal for a genuine two-database mix. A nested scope reports the root's, because a SAVEPOINT
111
+ belongs to the transaction that opened.
112
+
113
+ **`withTransaction(fn, { retry })` re-runs `fn` from the top, and only on `40001`/`40P01`.** Default
114
+ 0, because a retry nobody asked for silently doubles every non-idempotent handler in the framework.
115
+ Each attempt takes its own pin, its own `BEGIN` and its own undo list, so `runRoot` is extracted and
116
+ the loop is around it — a retry reusing the pin would be re-running against a transaction that is
117
+ already gone. A **nested** `retry` is refused through core's `assert` (`X_INVARIANT`), not ignored:
118
+ measured against Postgres 17, a `40001` aborts the whole transaction, so the `ROLLBACK TO SAVEPOINT`
119
+ that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT can only be used in transaction
120
+ blocks`. There is nothing to retry into, and an author who believes they hold a budget they do not
121
+ is worse off than one who is told.
122
+
123
+ **The migration lock is polled, never waited on.** `pg_advisory_lock` blocks with no timeout, so a
124
+ predecessor OOM-killed on a network partition kept its backend — and the lock — for hours while the
125
+ new `ROLE=migrate` pod sat inside one statement printing nothing: `helm upgrade --wait` blocked on a
126
+ pod that was `Running`, and because the job never *failed*, `backoffLimit` never fired.
127
+ `acquireLock` loops on `pg_try_advisory_lock` every `MIGRATION_LOCK_POLL_MS` until
128
+ `MIGRATION_LOCK_WAIT_MS` and then throws `X_MIGRATE_CONCURRENT` — a code reserved since 1.0 and
129
+ never thrown until now. The loop declares itself with `expectedQueryLoop`, like every other
130
+ deliberate loop here. `createRecordingClient` therefore stubs `pg_try_advisory_lock` to `locked:
131
+ true` by default: a fake that answers nothing would read as "held" and make every migration test in
132
+ every app wait out the full budget.
133
+
134
+ **`lock_timeout` is the migration's, not the pool's.** `PoolProfile.lockTimeoutMs` is 0 everywhere
135
+ but `migrate` (3s), and `migrate()`/`rollback()` emit it as `SET LOCAL lock_timeout` inside each
136
+ migration's own transaction rather than on the connection string. `SET LOCAL` reverts at COMMIT, so
137
+ a value chosen for DDL never leaks onto the session the ledger insert runs on — and the profile is
138
+ read by role `migrate` whatever role is running, because an `alter table` takes the same `ACCESS
139
+ EXCLUSIVE` from a laptop as from a deploy hook. Without it the migrator waits forever behind a long
140
+ `SELECT` and, because Postgres' lock queue is FIFO, so does every later query on that table.
141
+
142
+ **The migration advisory lock is held by one pinned session, and `migrate()`/`rollback()` run every
143
+ statement on it.** `pg_advisory_lock` is scoped to a Postgres *session*, so taking it on a pooled
144
+ handle locks whichever connection the pool lent for that one statement and then gives the session
145
+ back: the unlock later lands on a different connection, answers `false`, and the lock stays held
146
+ until that backend dies — the next migrator then waits forever rather than for the migration. The
147
+ same split loses the lock the other way: the locking session sits idle for the whole run and the
148
+ pool's idle timeout (`migrate`'s is 10s) closes it, releasing the lock mid-migration. `ROLE=migrate`
149
+ hid the first half by accident — its pool is `max: 1`, so every statement found the same connection.
150
+ No other role and no test has that. The pin is therefore also why the lock scope hands its session
151
+ *down*: on `max: 1` a statement sent to the pool while the pin is held waits for a connection that
152
+ cannot come back until the migration blocking on it finishes. `lock: false` (`x db branch`, a
153
+ private database) reserves nothing and takes no lock, exactly as before.
154
+
155
+ Pinned by `migrate.live.test.ts` against a real Postgres: two concurrent `migrate()` calls (one
156
+ applies, the other skips — never both, never a unique-violation crash) and a migration that fails
157
+ mid-run (the next `migrate()` still finishes in ~0.3s instead of hanging on a lock the failure
158
+ left stuck). Both are invisible to a recording client, which cannot tell a pinned session from a
159
+ pooled one apart — the statement text is identical either way. Skips unless `TEST_DATABASE_URL` is
160
+ set.
161
+
162
+ Every transaction-control statement is `.catch`ed exactly where a failure would *mask* the error
163
+ that caused it, and nowhere else: `ROLLBACK` and `ROLLBACK TO SAVEPOINT` are best-effort, while
164
+ `SAVEPOINT` and `RELEASE SAVEPOINT` are deliberately uncaught — a savepoint that was never taken
165
+ means the nested scope never opened, and a release that failed means its work is not durable in
166
+ the outer one. Swallowing either would keep running against a transaction that is not the one the
167
+ caller thinks it is in.
168
+
169
+ `close()` reads its cached driver into a local, clears the field, **then** awaits the teardown —
170
+ `client.ts` and `pglite.ts` both. A teardown that rejects has still torn the pool down, so clearing
171
+ after the await left the corpse cached for the next `connect()`, and a second `close()` threw in
172
+ the same place rather than clearing it. The rejection still reaches the caller on `client.ts`
173
+ (`pglite.ts` swallows a failed *boot*, which is a different thing: there is nothing to close).
174
+
175
+ `execute()` trusts the command tag only when it is `> 0`, in **both** drivers — `rowsOf`
176
+ (`pglite.ts`) and `affectedBy` (`client.ts`) are one rule written twice, not two rules. PGlite
177
+ counts MODIFIED rows, so a SELECT that returned rows is tagged `0` and `??` would report 0 for
178
+ every read; a driver that tags a read `0` on the pooled side would have diverged from PGlite the
179
+ same way, and the same guard closes both. A write that modified nothing returned no rows either, so
180
+ the fallback stays 0 there.
181
+
182
+ `observe.ts` is the seam a statement-level diagnostic installs into: one process-wide `StatementObserver`, installed with
183
+ `setStatementObserver()` and read with `statementObserver()`, the same ambient shape as
184
+ `setDbClient()`. Three rules, each load-bearing. **Guard at the call site** — read the accessor,
185
+ branch on `undefined`, and only then build the `StatementEvent`; a `notify(event)` wrapper would
186
+ allocate an event per statement for nobody to receive, and this seam is on the path every statement
187
+ in the process takes. **One observer, not a list** — a second install replaces the first (axiom 1);
188
+ a consumer needing several composes them itself. **The accessor returns the installed identity and
189
+ the seam swallows nothing** — a throw from `onStatement` is how strict test mode fails the test its
190
+ N+1 happened in, so a guarding facade here would silently delete that mode. `onStatement` is
191
+ synchronous, runs on the caller's stack after the statement settled, and must not issue SQL: a
192
+ statement from inside it re-enters the funnel and observes itself. Only two places may invoke it —
193
+ `runOn` (`client.ts`) and `statement()` (`pglite.ts`), the funnels every statement already passes
194
+ through. Reserving a connection, booting PGlite and closing a pool are not statements and stay out.
195
+
196
+ Both funnels are now split in two, and the split is the whole design: `sendOn`/`send` is the raw
197
+ statement plus the `X_DB_UNAVAILABLE` wrap — byte-identical to what the funnel used to be — and
198
+ `runOn`/`statement` is the observed shell around it. Three rules hold, in both drivers:
199
+ **guard first** — read the accessor, and with nothing installed hand straight to `sendOn`/`send`,
200
+ no clock read and no event; **observe both settle paths** — a failed statement is an event with
201
+ `rows: 0` and the already-wrapped error the caller is about to be thrown, because fifty identical
202
+ timeouts are still fifty statements; **notify outside the statement's own `try`** — a throw from
203
+ `onStatement` on the success path is the observer's, and catching it there would wrap a statement
204
+ that succeeded as `X_DB_UNAVAILABLE` and delete strict test mode's failure. On the failing path
205
+ the observer's throw replaces the DB error instead, which is the price of never swallowing — an
206
+ observer that only reports must not throw. `rows` comes from the same helper `execute()` uses
207
+ (`affectedBy` in `client.ts`, `rowsOf` in `pglite.ts`, hoisted to module scope for it), so the
208
+ report and the return value cannot disagree about one statement.
209
+
210
+ `attribution.ts` is `StatementEvent.attribution`'s producer: `withStatementAttribution(entity, op,
211
+ fn)` runs `fn` with every statement it issues — at any depth, across every `await` — attributed to
212
+ that pair, on an `AsyncLocalStorage` the same shape `expected-loop.ts` already uses. Four rules,
213
+ none optional. **Guard first** — it reads `statementObserver()` before touching the scope at all
214
+ and, with nothing installed, hands straight to `fn`: one property read, one branch, no object
215
+ allocated, on the path every statement in the process takes (axiom 6) — which is also why the pair
216
+ arrives as two strings rather than a `StatementAttribution` literal, since a literal at the call
217
+ site would be allocated before the branch could decline it. **A scope, not a parameter** — the
218
+ statement leaves several frames and at least one microtask below the repository call that caused
219
+ it: the coalescer flushes its batch from a `queueMicrotask` (`coalesce.ts`), a wide write is a
220
+ chunked loop, a preload sends through `readByIds`, and threading a parameter through all of those
221
+ is the same fact written five times, with every path an author forgot it emitting unattributed SQL.
222
+ **Nesting keeps the innermost pair**, exactly as `expectedQueryLoop` keeps the innermost reason: a
223
+ relation preloaded during `findMany` reads through the *related* repository, so its statement is
224
+ attributed to that entity and its own operation, not to the read that triggered the preload.
225
+ **The funnels stamp, on both settle paths** — `runOn` (`client.ts`) and `statement()` (`pglite.ts`)
226
+ read `statementAttribution()` inside the branch that already found an observer, next to
227
+ `expectedQueryLoopReason()`, and put it on the event whether the statement succeeded or failed, the
228
+ same argument as `expected`: a diagnostic that judges a whole request runs long after every scope
229
+ in it closed. `@ultimat3/entity`'s `postgresRepo` is the one producer — the last caller that still
230
+ knows both once the SQL exists (`packages/entity/CLAUDE.md`) — and an observer installed *during*
231
+ `fn` sees the statements that follow unattributed, since installation happens once, at boot.
232
+
233
+ `statement-shape.ts` is what a statement's *identity* is, and it lives here because its only input
234
+ is a `StatementEvent`. `statementFingerprint(event)` is `entity.op` when the event is attributed and
235
+ the event's own whitespace-collapsed text when it is not; `statementKind(text)` is read or write off
236
+ `statementVerb(text)`, a closed set of verbs and never a set of repository operations — a soft delete
237
+ is an `update`, an op list would drift with `@ultimat3/entity`'s method names, and hand-written SQL
238
+ carries no operation at all. Two detectors group by that identity (`x dev`'s ledger,
239
+ `@ultimat3/testing`'s `statements` fixture) and `statementSpanName` reads the same verb, so the rule
240
+ is written once — a second copy is two answers to "is this the same statement again". Nothing here
241
+ counts: the threshold is `@ultimat3/entity`'s `N_PLUS_ONE_THRESHOLD`, next to the codes whose `fix`
242
+ depends on it.
243
+
244
+ `statement-span.ts` is the other half of the observed shell: `withStatementSpan` wraps the **send
245
+ alone**, so the span's duration is the statement's and the observer's own work is not charged to
246
+ the database. Three decisions, each load-bearing. **`db.<verb>`** (`db.select`, `db.begin`; a text
247
+ opening with a comment is `db.statement`) — `@ultimat3/cli`'s `dev-traces.ts` reads the `/_x` panel
248
+ kind off the name prefix like it does for `query.`/`cache.`/`job.`, and this package is tier 1 and
249
+ cannot name a tier-5 vocabulary. **The text is `STATEMENT_ATTRIBUTE`** — `db.statement`, OTel's own
250
+ attribute and the one `dev-traces.ts` prefers over the span name, so a repository loop is fifty rows
251
+ of one SQL text in `repeatedSql` and not one `query.feed`. It is **exported** and re-exported from
252
+ `src/index.ts` precisely because it is a contract across two packages: `dev-traces.ts` and its test
253
+ import it, so renaming it here is a compile error there rather than a panel that quietly groups
254
+ nothing while every test stays green. **It opens only when an observer is installed**, inside the
255
+ guarded branch that already exists: installing an observer is the single switch that turns
256
+ statement instrumentation on, event and span together (axiom 1), and an uninstalled process mints
257
+ no span id and allocates no span object per statement — which on this path is every statement in
258
+ the process. The OTel `kind` is `client`; the database is the remote peer.
259
+
260
+ `expected-loop.ts` is the **only** suppression mechanism, and the reason it is a scope rather than
261
+ a pragma or a list is the same reason `observe.ts` is one observer: a second path is the tax
262
+ (axiom 1). `expectedQueryLoop(reason, fn)` rides an `AsyncLocalStorage`, so it survives every
263
+ `await` at any depth and two loops running concurrently never read each other; nesting keeps the
264
+ innermost reason, because the closest scope is the one describing this loop. A blank reason is
265
+ `X_INVARIANT` through core's `assert` — no new code for it, and an exemption with no argument is a
266
+ pragma with extra steps. Three rules. **The funnel stamps, the consumer reads** — `runOn` and
267
+ `statement()` call `expectedQueryLoopReason()` inside the branch that already found an observer and
268
+ put the answer on the event as `expected`; a detector that judges a whole request runs long after
269
+ every scope in it closed, so reading the ALS later would find nothing. **It suppresses a verdict,
270
+ not a statement** — the SQL is still sent, still observed, and the span still opens, so anything
271
+ that measures still sees the loop and only the thing that warns is told the author already
272
+ answered. **It costs nothing uninstalled** — the read lives inside the observer branch, so the
273
+ production path is still one property read and one branch.
274
+
275
+ The framework's own deliberate loops declare themselves at source, and new ones must: `migrate()`
276
+ and `rollback()` (`migrate.ts`) apply and reverse one migration per transaction so a failure leaves
277
+ an exact ledger, and `@ultimat3/admin`'s `search.ts` runs one indexed lookup per text field. Adding
278
+ a `db` dependency to `admin` for that one import is deliberate — the alternative is re-exporting the
279
+ scope from a package `admin` already imports, which is the second path this rule forbids.
280
+
281
+ **`@ultimat3/jobs` never imports this package** (`packages/jobs/CLAUDE.md`), so nothing about the
282
+ observer, the span or `expectedQueryLoop` is this package's concern *from inside* `jobs` —
283
+ `driver-pg.ts` speaks only the two-method `PgExecutor` it declares itself, satisfied by anything
284
+ shaped like `query(sql, params)`. That is a statement about the package boundary, not about what a
285
+ running process does with it: `packages/cli/src/dev-queue.ts`'s `startQueue` — the only place in
286
+ the repo that builds a `PgExecutor`, reached by every role through `dev-runtime.ts`'s
287
+ `startServices` and by `migrate` through `serve.ts`'s `runMigrations` — wraps a real
288
+ `PostgresClient`/`PgliteClient` `.query()` call for it. So today, in this framework's own boot
289
+ code, every job-driver statement (claim, ack, nack, enqueue, heartbeat, step read/write) **does**
290
+ pass through `runOn`/`statement()` and is visible to an installed `StatementObserver` and traced
291
+ exactly like any other statement — just with no `attribution`, which is not a `jobs` gap now
292
+ either: `@ultimat3/entity`'s `postgresRepo` is `attribution.ts`'s producer (above), but `jobs`' own
293
+ statements never reach it — `driver-pg.ts` compiles its SQL directly against `PgExecutor`, not
294
+ through a repository, so nothing calls `withStatementAttribution` on a claim, an ack, a nack or a
295
+ heartbeat's behalf, and every one of those events still reads `attribution: undefined`. An entity
296
+ read or write sharing the same process now carries the pair; a job-driver statement does not, and
297
+ the gap is real, just narrower than it was. This is incidental, not guaranteed:
298
+ `PgExecutor` is duck-typed, so a deployment that hands `createPgDriver` an executor not backed by
299
+ this package — a raw `Bun.SQL` instance, a hand-rolled pool, `driver-redis`/`driver-nats` (which do
300
+ not touch Postgres at all) — gets zero observation of its queue traffic, and nothing here or in
301
+ `jobs` enforces otherwise. A detector reading `attribution` (PR 9's N+1 work) sees a claim loop as
302
+ anonymous SQL, never as a `job` statement, and will keep seeing it that way until `jobs` threads its
303
+ own pair through `driver-pg.ts` the way `postgresRepo` now threads entity's — that is still future
304
+ work, not something this change reaches.
305
+
306
+ **`generate.ts` reads an index, it never re-derives one.** `EntityDescriptionLike.indexes` carries
307
+ `columns`, `unique`, `where` and `order`, and `createIndex` writes every one of them out. It used to
308
+ carry names alone and `parseIndexName` recovered the column list from the `<table>_<a>_<b>_idx`
309
+ convention — which does not run backwards: `_` joins the columns *and* appears inside them, so a
310
+ two-column index emitted `("org_id_created_at")`, a column that does not exist, `42703`, and a
311
+ migration nobody can apply. The same loss took the rest of the declaration with it: a partial index
312
+ emitted as a total one refuses rows the entity allows, and a `desc` index came out ascending. Any
313
+ new part of an index is added to `IndexDescriptionLike` and spelled in `createIndex`, never encoded
314
+ into the name for a reader to parse back out. An index naming no column is `X_INVARIANT` through
315
+ core's `assert` — `entity()` refuses `on: []` at declaration, so nothing the framework produces can
316
+ reach it, and a hand-built description gets the error rather than DDL Postgres cannot parse.
317
+ `generate.test.ts` pins the generated SQL text; `migrate.live.test.ts`'s composite-index describe
318
+ block is the join of that fix with the engine it ships through — an entity description into
319
+ `generateMigration`, applied by `migrate()` itself against a real server, columns confirmed against
320
+ `pg_indexes`, rather than either half alone.
321
+
322
+ **The ledger audit asks one question — does this build ship every migration the ledger records?**
323
+ `auditLedger`'s `foreign` filter is `!known.has(row.id)` and nothing else, `As of 2026-08`. It used
324
+ to also require `row.app_version !== appVersion`, which switched the audit OFF wherever the two
325
+ agree: `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
326
+ earlier `dev` build and since deleted was invisible, and `expectedSchema` (`drift.ts`) then dropped
327
+ its table from the comparison — `x db drift` answering `ok: true` against a database that still has
328
+ the table. The version is a detail of the ANSWER and lives in the cause, never in the predicate.
329
+
330
+ **`rollback({ steps })` refuses anything that is not a positive safe integer, before the lock.**
331
+ `steps` reaches `slice(0, steps)`, where a negative count counts from the END: `steps: -1` selected
332
+ every applied migration but the newest and reversed four of five. `X_INVARIANT` (core's generic
333
+ code, borrowed in `DB_BORROWED_ERROR_CODES` the way `@ultimat3/money`'s `roundRatio` borrows it —
334
+ a bad argument is not a fact about the ledger), thrown by `rollbackStepsInvalid` before the advisory
335
+ lock is taken and before the ledger is read. Same discipline as `poolMaxInvalid`: a number this
336
+ build cannot honour is refused, never reinterpreted.
337
+
338
+ **`reapBranches` skips a `createdAt` it cannot parse; it never reads one as infinitely old.**
339
+ `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
340
+ `COMMENT ON DATABASE` that was truncated or hand-edited used to be a database DROPPED on the next
341
+ nightly sweep whatever `maxAgeMs` said. `Date.parse` + `Number.isFinite`, the discipline
342
+ `@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Distinct from the open
343
+ source-blindness of the reaper, issue #133.)
344
+
345
+ **One send is one statement, so `migrate()` and `rollback()` split the script.** `tx.execute(raw(
346
+ migration.up))` on a text holding two commands is where the two drivers disagreed, and the
347
+ disagreement is the whole reason this is a bug rather than a preference: `pglite.ts` calls
348
+ PGlite's `query()`, which is the extended protocol always and answers `cannot insert multiple
349
+ commands into a prepared statement`, while `client.ts`'s `Bun.SQL.unsafe(text, values)` degrades to
350
+ the *simple* protocol whenever `values` is empty and applies the same script — measured on bun
351
+ 1.3.14, guaranteed by nothing. `createTable` emits the table *and* every index it carries, and
352
+ `x dev`/`x db branch` run on the embedded driver, so the broken case was the common one on the
353
+ path an author uses most. `applyScript` (`migrate.ts`) sends `statementsOf(script)` one at a time
354
+ inside the **same** transaction; a half-applied migration is worse than an unapplied one, and it
355
+ needs no `expectedQueryLoop` of its own because both call sites already run inside the one declared
356
+ for the migration loop. `pglite-embedded.test.ts` is where that is pinned — a recording client
357
+ replies to any text, and only a real engine has an opinion about a script.
358
+
359
+ `statement-split.ts` is that splitter and the only one: `statementsOf(script)` is a left-to-right
360
+ scan, never a `split(';')`, because a `;` inside a string literal, a quoted identifier, a
361
+ dollar-quoted body, a `--` comment or a **nested** block comment is data — and a generated migration
362
+ holds all five, including the `-- backfill "c", then: … set not null;` note. Three rules. `$1` is a
363
+ bound parameter and never a `$tag$`, so a tag may not begin with a digit — otherwise one parameter
364
+ swallows the rest of the script. A backslash escapes only inside an `E''` string, which is also the
365
+ only place the `''` escape is observable: everywhere else, closing and reopening the run lands on
366
+ exactly the same separator. A chunk of whitespace and comments alone is **not** a statement and is
367
+ dropped, so an empty `up` reaches its ledger row instead of sending an empty query. An unterminated
368
+ literal is returned as it stands — Postgres names that syntax error precisely, and a second parser
369
+ competing with it would only report the same fault in worse words. `@ultimat3/entity`'s and
370
+ `@ultimat3/ai`'s live tests import it rather than hand-rolling a seventh copy; splitting a script is
371
+ one question with one answer (axiom 1).
372
+
373
+ `destructive.ts` is the rail, and it decides **what** is destructive — never **whether** a given
374
+ repo has any. `x db gen` reads `isDestructive(up)` to write `-- destructive: true` into the file;
375
+ `x verify`'s `drift` step reads `hasDestructiveMarker`/`destructiveStatements` to refuse a file that
376
+ lacks it (`@ultimat3/cli`'s `db-destructive.ts`). One classifier for both, because a generator that
377
+ wrote no marker where the gate demanded one would ship a migration failing its own gate. Four rules.
378
+ **Only `up`** — reversing a `create table` is a `drop table`, so a rail reading `down` marks every
379
+ migration ever generated and a marker on all of them marks none. **A closed list of four kinds** —
380
+ `drop table`, `drop column`, `truncate`, `alter column … type`; a rail enumerating every Postgres
381
+ foot-gun is a second SQL parser competing with the server's, and every one of these four is a
382
+ statement `generateMigration` emits, so each has a generated case holding it honest. `drop
383
+ constraint`/`default`/`not null` and `drop index` are excluded by name: the database rebuilds them.
384
+ **Decide on blanked text, report the original** — `statementsOf` + `stripSqlNoise` before a keyword
385
+ is looked for, so `-- drop table users` is prose and `values ('drop table users')` is data; but the
386
+ excerpt in the error keeps its identifiers, because `drop table ""` names nothing an author can act
387
+ on. **The marker is a top-level line comment**, like `-- down`, so a file merely mentioning it has
388
+ declared nothing — and one inside a `/* … */` or a dollar-quoted body has declared nothing either,
389
+ which a regex over the raw file could not tell apart. `hasDestructiveMarker` walks `sql-scan.ts`
390
+ for the same reason the classifier does: the marker is a lexical fact, not a substring. It is also SQL the checksum covers, which is deliberate: marking an already-applied
391
+ migration is an edit, and `X_MIGRATION_CONFLICT` is the correct answer to that.
392
+
393
+ `X_MIGRATION_DESTRUCTIVE` and `X_MIGRATION_IRREVERSIBLE` are two questions, not two spellings of
394
+ one. Irreversible refuses to *generate* a plan whose `down` cannot restore the rows, and
395
+ `--allow-destructive` is the override. Destructive refuses to *ship* a plan whose `up` destroys them
396
+ without saying so — and a retype is reversible in DDL, gated by no flag, and still rewrites every
397
+ row, so it is marked without ever being refused.
398
+
399
+ `sql-scan.ts` is the **one** lexer under all of it: `noiseAt(text, index)` names the span starting
400
+ at one offset — line comment, block comment, literal, quoted identifier, dollar-quoted body — or
401
+ `null` for code. `statement-split.ts`, `sql-noise.ts` and `destructive.ts`'s marker all walk it, and
402
+ a splitter that disagreed with a guard about where a literal ends is a `;` sent as data or a
403
+ `delete` read as prose. Two rules it owns. **Source order, never a sequence of replacements**:
404
+ `stripSqlNoise` blanked comments before literals, so the `--` in `select '--'; delete from posts`
405
+ read as a comment and erased the `delete` with it — every reader downstream then judged a SELECT
406
+ where a mutating statement stood. **A `$tag$` needs separating from the identifier before
407
+ it**: `$` is legal in a name after the first character, so `foo$tag$` is one identifier and
408
+ `select foo$tag$; select 2;` is two statements — read as a body opener it went out as one send.
409
+ The run before the delimiter is walked to its start rather than one character being read, because
410
+ `$1$tag$` is a bound parameter followed by a real delimiter and a run opening with a digit or a `$`
411
+ cannot be an identifier at all.
412
+
413
+ `sql-noise.ts` holds `stripSqlNoise` alone, for the two readers that share it —
414
+ `readonly-query.ts`'s cursorable check and `destructive.ts`. It stays its own module rather than
415
+ moving into either: `errors.ts` names the destructive rail's wording and the rail reads SQL text,
416
+ so a blanker living beside a guard puts the error registry, which registers codes at module
417
+ evaluation, inside an import cycle. Its own test is the regression suite for all of them.
418
+
419
+ `runningAppVersion()` delegates to `@ultimat3/core`'s `appVersion()` and keeps its explicit
420
+ override — `x_migrations.app_version` and `@ultimat3/jobs`' `x_backfills.app_version` are two
421
+ durable columns an operator reads side by side, and `jobs` cannot import this package for the
422
+ answer, so the key has one reader at tier 0 rather than one per writer.
423
+
424
+ `checkDrift()` is the **post-migrate verification** and the only drift question that needs a
425
+ database: the live catalog against the ledger the run just wrote. It is asked where a connection is
426
+ open — `@ultimat3/cli`'s `runMigrations`, which is `x db migrate`, `x db reset` and `ROLE=migrate`
427
+ alike — and returned, never thrown. The *other* `X_DB_DRIFT` is `@ultimat3/cli`'s
428
+ `checkSourceDrift`: the entity source hashed against what `x db gen` recorded, no database, which is
429
+ what `x verify`'s `drift` step runs in a CI with nothing listening. Two conditions, two detectors,
430
+ one code — and neither may grow the other's half. Until 1.2.0 both were named `checkDrift`, the
431
+ file-hash one was wired everywhere and this one had no callers at all.
432
+
433
+ `declaredSchema()` answers with the **newest** migration's snapshot or with `undefined`, never with
434
+ the newest one that happens to have a snapshot. `0001` records `posts`, `0002` adds a column and
435
+ writes nothing down, and reaching back to `0001` reports a column the database correctly holds as
436
+ `unexpected-column` — drift against a schema that is exactly right, with `x db gen "add …"` as the
437
+ fix for a migration that already exists. `checkDrift` turns that `undefined` into an
438
+ `unknown-schema` difference rather than `ok: true`, and `x db gen` refuses with
439
+ `X_MIGRATION_SNAPSHOT_MISSING` rather than diffing against the empty schema, which would emit
440
+ `create table` for every table the database already holds.
441
+
442
+ **Those two answers describe one condition, so they must name one remedy — and until 2026-08 they
443
+ named each other.** `unknown-schema`'s fix was `x db gen "snapshot <name>"`, which raises
444
+ `X_MIGRATION_SNAPSHOT_MISSING`, whose fix was "restore … from version control" for a file version
445
+ control never had: reproduced on a pristine `x new` scaffold, whose `0000_initial.sql` ships with no
446
+ sidecar, so the app's first `x db migrate` had no way out at all. Both now lead with the same two
447
+ remedies in the same order — restore the sidecar (`git checkout --`, a real command that fails
448
+ loudly when git has no copy), or, if it was never written, **delete the migration's files first and
449
+ only then** run `x db gen`. The order is the whole point: `x db gen` named before the files are gone
450
+ is the cycle. `snapshotSiblings`/`migrationNameOf` (`errors.ts`) build that second command out of
451
+ the path the caller passed and the id, never out of a directory this tier-1 package invents —
452
+ `unknown-schema` has no path at all and uses a `"*<id>.snapshot.json"` git pathspec for the same
453
+ reason.
454
+
455
+ `compareTable` compares **nullability**, and it is the only column property it compares besides
456
+ existence. `snapshotOf` had recorded `nullable` all along and nothing read it, which made the
457
+ expand/contract flow a one-way door: `generate.ts` emits a `NOT NULL` add as nullable plus a
458
+ `-- backfill "c", then: … set not null;` comment, phase 2 is a thing a human has to remember, and
459
+ with nullability uncompared the column stayed nullable forever against an entity schema that said
460
+ otherwise — `ok: true` on every check until an `undefined` write landed as `NULL` three services
461
+ away. **Primary key columns are excluded, by the union of both sides' keys**: Postgres makes a key
462
+ column `NOT NULL` whether or not anything declared it, so a snapshot spelling `id` nullable would
463
+ otherwise put one finding on every table in a correct database. The type is still not compared —
464
+ the catalog and a snapshot spell types differently often enough that it would report drift on a
465
+ right database, and `x db gen`'s `retypeColumn` owns that question where both sides are generated.
466
+ The `fix:` is the `alter table … set not null` itself and deliberately not `x db gen`, which has
467
+ never emitted one and would answer with an empty migration.
468
+
469
+ `compareTable` judges **declared** indexes: one the migrations name and the catalog does not hold is
470
+ `missing-index`, and one whose column list or uniqueness moved is `changed-index` — which is what
471
+ catches a composite index rebuilt with its columns the other way round while the column diff said
472
+ `ok: true`. A live index no snapshot names is deliberately **not** reported: Postgres creates one for
473
+ every primary key and every unique constraint, so counting those is eight findings against a correct
474
+ database, the same argument `appTables()` makes. The predicate and the direction are not compared
475
+ either — the catalog returns its own rewriting of an expression (`(deleted_at IS NULL)`) and the
476
+ snapshot holds the author's spelling, so a text comparison reports two identical indexes as drift.
477
+ `x db gen` compares them instead (`redefineIndex`), where both sides are generated. Named in
478
+ `wiki/Known-Gaps.md`.
479
+
480
+ `compareForeignKeys` judges **declared** keys the same way, and matches on **where the key points**
481
+ — its columns, its target table, its target columns — never on the constraint name. That identity is
482
+ `foreignKeyTarget` (`foreign-key.ts`), the **one** copy, read by this comparison and by `x db gen`'s
483
+ own diff: a generator and a detector that disagreed about whether two keys are the same key is drift
484
+ reported on a correct database. `snapshotOf` names a key `<table>_<column>_fkey` — what Postgres
485
+ would have called an inline `references` clause — and `addForeignKey` now writes that name out, so
486
+ the snapshot records a name the migration beside it chose rather than one it guessed; a hand-written
487
+ migration may still have said `constraint fk_posts_org`, and a key pointing the same way under
488
+ another name is the same key. `onDelete` is not compared: the catalog spells it `a`/`c`/`r` and no
489
+ generated clause has ever declared one, so a snapshot has nothing truthful to hold there. Before
490
+ this, `snapshotOf` recorded `foreignKeys: []` beside an `up` emitting `references "orgs" ("id")` — a
491
+ snapshot denying a constraint its own migration creates — so `alter table … drop constraint` on the
492
+ database answered `ok: true`.
493
+
494
+ **A foreign key is `alter table … add constraint`, never a clause inside `create table`** — decided
495
+ 2026-08, and it is the difference between a first migration that applies and one that does not.
496
+ Inline, the constraint is created with the table, so the referenced table must already exist; the
497
+ order `generateMigration` walks is `describeEntities()`, which is the app's *import* order and has
498
+ nothing to say about which table a `references()` points at. Measured against PGlite on a scaffolded
499
+ app: `create table "comments" (… references "posts" …)` before `create table "posts"`, statement one,
500
+ `relation "posts" does not exist`. `down` had the mirror fault — `drop table "posts"` while
501
+ `comments` still referenced it is `2BP01`. So `foreignKeyPlan` collects every key into a bucket of
502
+ its own, merged into the plan **after** every table statement; `down` is reversed as a whole, so the
503
+ drops pushed last there come out first. No topological sort and **no cycle error**: two tables
504
+ referencing each other cannot be expressed inline in any order, and separate constraints need no
505
+ order at all. The same call site answers the other half — a `references()` added to a column that
506
+ already exists now emits its `add constraint`, where before `up` came out **empty**, `x db gen`
507
+ wrote no file, and `x verify`'s drift step stayed red forever with `x db gen "…"` as a fix that did
508
+ nothing. Removing a `references()` still emits nothing, exactly as a removed index does.
509
+
510
+ **`snapshot-json.ts` writes the sidecar's bytes, and they must be a fixed point of Biome.** A
511
+ scaffolded app's `lint` step is `biome check .` over `"includes": ["**"]`, and `.sql`/`.hash` are
512
+ types Biome does not process — so the `.snapshot.json` is the first migration artefact lint ever
513
+ sees. `JSON.stringify(value, null, 2)` is not that fixed point: Biome collapses `["id"]` onto one
514
+ line and `JSON.stringify` never does, so `x db gen` wrote a file the app's own gate rejected — axiom
515
+ 3, inverted. Two rules, measured against 2.5.5 and encoded in `print`: an **object** keeps the
516
+ source's shape, so emitting every non-empty one broken is stable by construction; an **array**
517
+ collapses when every element is already on one line and the line fits, *counting the trailing
518
+ comma*, at `<= 100`. `snapshot-json.test.ts` proves it by running the repo's own `biome format` over
519
+ the output and demanding no change — a pinned expected string could not have caught the boundary,
520
+ and the naive spelling is asserted to fail the same check so the test cannot pass by doing nothing.
521
+
522
+ `introspect()` reads an index's columns in **index key order** (`indkey`, not `attnum`) and carries
523
+ its predicate and direction. Ordering by `attnum` returned a composite index's columns in table
524
+ order, which reads correct and compares wrong.
525
+
526
+ A foreign key's two column lists are read the same way and, crucially, **together**: `conkey` and
527
+ `confkey` are unnested in one `unnest(a, b) with ordinality` and ordered by that shared position,
528
+ because they are one ordered pairing and not two sets. Matching each independently
529
+ (`sa.attnum = any(c.conkey)`, `ta.attnum = any(c.confkey)`) is a cross product — a two-column key
530
+ came back as four source columns against four referenced ones, duplicated and misaligned, so
531
+ `compareForeignKeys` judged a correct database as drift and the admin schema view showed a key
532
+ that does not exist. Only a real engine can tell the two queries apart, which is what
533
+ `introspect-embedded.test.ts` is for: it boots PGlite, declares `(org_id, user_id) references users
534
+ (tenant_id, id)` — neither list alphabetical, the two orders deliberately different — and asserts
535
+ the pair comes back whole. Same split as `pglite.test.ts`/`pglite-embedded.test.ts`:
536
+ `introspect.test.ts` pins the row -> description fold against a recording client, and the embedded
537
+ file pins the catalog SQL against Postgres.
538
+
539
+ `appTables()` is why it can run: a table in the `x_` namespace is framework bookkeeping — the
540
+ ledger, `x_jobs`/`x_job_steps`, `x_outbox` and every `@ultimat3/auth` table are `create table if not
541
+ exists` at boot, declared by no migration and carried in no snapshot, so counted as app schema they
542
+ are eight `unexpected-table` findings against a correct database. The prefix is the rule, not a
543
+ list, so a table a future package adds needs no second declaration here. `introspect()` keeps its
544
+ narrower default (`x_migrations` alone) because the admin schema view and the MCP `schema.describe`
545
+ tool legitimately show `x_users` — only drift wants the whole namespace gone.
546
+
547
+ The `X_DB_DRIFT` rendering in `drift.ts` and the title in `DB_ERROR_TITLES` are pinned by the
548
+ framework contract and duplicated in `@ultimat3/entity`. Change them together or not at all.
549
+ `errors.ts` registers `DB_ERROR_TITLES` **unconditionally**, in one call, and that is deliberate:
550
+ a presence guard would turn "a second package claims one of db's codes" from an
551
+ `X_ERROR_CODE_DUPLICATE` at import into whichever module loaded first deciding the title. Entity
552
+ borrows `X_DB_DRIFT` and declares no title for it, for the same reason.
553
+
554
+ **This package owns no "is this SQL a write?" lexer, `As of 2026-08`, and must not grow one back.**
555
+ `readonly.ts` held one — `inspectStatement`/`assertReadOnly`/`readOnly(client)`, a regex-gated
556
+ `DbClient` wrapper on the public API — with **zero callers** in the framework or in either tracked
557
+ app. It was the weakest of the three the framework had shipped — a 22-word list matched with `\b…\b`
558
+ against blanked text, so it judges statement keywords and nothing else: `select pg_sleep(60)`,
559
+ `select pg_read_file('/etc/passwd')`, `select pg_advisory_lock(1)`, `select set_config(…)` and any
560
+ writing function call all read as reads, because `_` is a word character and the keyword never
561
+ stands alone. `@ultimat3/mcp`'s guard refuses each by called-function prefix. And it was the copy an
562
+ app author would find first, because it was the one on a public API. Deleted
563
+ with `readonlyViolation()` and `X_READONLY_VIOLATION`. The two layers that remain are the ones the
564
+ server enforces or a real parser decides: `readOnlyQuery()` (`BEGIN READ ONLY` + statement timeout,
565
+ layer 2) under `ensureReadOnlyRole()` (a `NOLOGIN` SELECT-only role, layer 1), with
566
+ `@ultimat3/mcp`'s `assertReadOnlyQuery` as layer 3. `errors.test.ts` pins `DB_OWNED_ERROR_CODES`,
567
+ so re-adding the code is a failing test; a second keyword list is not something a test can see, so
568
+ it is this line's job to refuse it.
569
+
570
+ **`readOnlyQuery` takes ONE statement**, refused through `statementsOf` before the transaction
571
+ opens (`X_SQL_UNSAFE`, `multipleStatements`). This is not a second mutating-keyword scan — it is a
572
+ different question, and the one the layer's own guards depend on: the statement is *spliced* into
573
+ `DECLARE … CURSOR FOR`, and only the first command of that text is bounded by the `SET LOCAL
574
+ statement_timeout` set moments earlier, so `select 1; set statement_timeout = 0` undid the guard
575
+ while `guards` went on reporting `timeout:5000ms`. `BEGIN READ ONLY` still held, so this was a
576
+ defeated layer reported as an engaged one rather than a write — and a guard list that lies is worse
577
+ than a guard list that is short. `statementsOf` is the package's one splitter, so a `;` inside a
578
+ literal, a comment or a dollar-quoted body stays data.
579
+
580
+ `readonly-role.ts` and `readonly-query.ts` are layers 1–2 of that tool's defence-in-depth: a
581
+ `NOLOGIN` Postgres role (`ensureReadOnlyRole`) and a per-statement `BEGIN READ ONLY` + statement
582
+ timeout (`readOnlyQuery`). Only layer 1 degrades: `ensureReadOnlyRole` returns `null` on a missing
583
+ permission and leaves reporting the degraded layer to the caller. **`readOnlyQuery` throws** — a
584
+ failed reservation (`X_DB_UNAVAILABLE`), `SET LOCAL ROLE`, transaction command or the statement
585
+ itself all reach the caller, and every caller must handle that. Layers 3–4 (pre-parse scan, MCP
586
+ policy) live in `@ultimat3/mcp`, which must still never import this package — the CLI wires the
587
+ two together.
588
+
589
+ **`libpq-options.ts` merges the framework's `options` into the operator's, and `connectionUrl` may
590
+ not `set` that key again.** `DATABASE_URL` is the operator's file: `url.searchParams.set('options',
591
+ …)` REPLACED whatever they had written, and only on the roles whose `statementTimeoutMs` is
592
+ non-zero — so `?options=-c search_path=app` survived on `migrate` and `replicator` and was dropped
593
+ on `web`, `sync`, `worker` and `scheduler`, i.e. the role that runs the migrations and the role that
594
+ serves the traffic looked at different schemas with nothing reporting it. Precedence is **the
595
+ framework wins on the names it sets, the operator keeps every other flag**, and it is enforced by
596
+ removing those names from the operator's tokens before appending, never by position: "the last `-c`
597
+ wins" is backend argument-order behaviour nobody here measured. The bound is emitted for all six
598
+ roles including the two whose value is `0` — `0` is `migrate` saying it may take as long as it
599
+ takes, and left unsaid an `alter database … set statement_timeout` on the server kills the one role
600
+ that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
601
+ survives the round trip whole.
602
+
603
+ ```bash
604
+ bun test # from packages/db
605
+ bun run typecheck
606
+ ```
607
+
608
+ Gotchas:
609
+ - `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
610
+ - `noUncheckedIndexedAccess` — array reads are `T | undefined`; `chunks[i] ?? ''` everywhere.
611
+ - Tests use `createRecordingClient()` + `setDbClient()`; no test may need a live database.
612
+ - A test that must prove a pin came back uses `reservableOver()` (`fake-reservable.ts`), never a
613
+ local copy — the recording client cannot see a leak, so the counter is the whole assertion and
614
+ a second copy of it drifts.
615
+ - `ALTER DEFAULT PRIVILEGES` is scoped to an object's creator, so layer 1 covers future tables
616
+ only for the roles in `creators` (default: the connected user). Migrations running as another
617
+ DB user must name it, or tables created later are not selectable by `ultimate_readonly`.
618
+ - `Bun.SQL` is reached lazily inside `connect()` — importing `client.ts` must not open a socket.