@ultimat3/db 1.1.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 +580 -0
- package/README.md +147 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +10 -4
- package/src/client.ts +241 -25
- package/src/destructive.ts +126 -0
- package/src/drift.ts +256 -13
- package/src/errors.ts +232 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +37 -11
- package/src/introspect.ts +34 -8
- package/src/migrate.ts +272 -63
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
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.
|