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