@ultimat3/db 22.15.0 → 24.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.
Files changed (43) hide show
  1. package/CLAUDE.md +91 -56
  2. package/README.md +121 -6
  3. package/package.json +5 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/bun-sql.ts +12 -0
  7. package/src/catalog-fold.ts +116 -0
  8. package/src/catalog-objects.ts +229 -0
  9. package/src/catalog-relations.ts +184 -0
  10. package/src/catalog.ts +174 -0
  11. package/src/client.ts +57 -9
  12. package/src/commit-tag.ts +21 -0
  13. package/src/dependent-view.ts +6 -4
  14. package/src/drift-errors.ts +3 -3
  15. package/src/drift-findings.ts +213 -60
  16. package/src/drift.ts +19 -13
  17. package/src/dump-drift.ts +142 -0
  18. package/src/errors.ts +8 -7
  19. package/src/foreign-key.ts +0 -34
  20. package/src/generate.ts +5 -0
  21. package/src/index.ts +9 -3
  22. package/src/introspect-catalog.ts +171 -0
  23. package/src/introspect.ts +45 -8
  24. package/src/listen.ts +62 -0
  25. package/src/migrate.ts +3 -3
  26. package/src/object-drift.ts +162 -0
  27. package/src/pglite-branch.ts +6 -6
  28. package/src/pglite-extensions.ts +112 -0
  29. package/src/pglite-package.ts +11 -0
  30. package/src/pglite-snapshot.ts +121 -0
  31. package/src/pglite.ts +146 -11
  32. package/src/pool-gauge.ts +70 -0
  33. package/src/primary-key.ts +180 -0
  34. package/src/schema-dump-entry.ts +39 -0
  35. package/src/schema-dump-table.ts +75 -0
  36. package/src/schema-dump.ts +192 -0
  37. package/src/schema-load.ts +119 -0
  38. package/src/sibling-turn.ts +49 -0
  39. package/src/sqlstate.ts +33 -10
  40. package/src/statement-funnel.ts +16 -5
  41. package/src/transaction-errors.ts +66 -0
  42. package/src/transaction-options.ts +122 -0
  43. package/src/transaction.ts +131 -121
package/CLAUDE.md CHANGED
@@ -14,7 +14,7 @@ Tier 1 — it imports `@ultimat3/core` and nothing else. That placement is load-
14
14
  | SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
15
15
  | Reading a caught value | `renderThrowable()` from core (`checkDb` backs `/readyz`) |
16
16
  | Errors | subclass `DbError`; never `throw new Error` in source. A test simulating a database failure throws `dbUnavailable()`; one simulating the caller's body failing throws a bare `Error` on purpose |
17
- | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts`, whichever file holds the constructor (`migration-errors.ts`, `invariant-errors.ts`, `drift-errors.ts`); `src/index.ts` re-exports all |
17
+ | New code | `bun run new-error-code <CODE> --package db --title '…' --fix '…'` writes `DB_OWNED_ERROR_CODES`, `DB_ERROR_TITLES` and the wiki row together; then add it to `errors.test.ts`'s pinned list. The constructor lives where its imports allow (`migration-errors.ts`, `invariant-errors.ts`, `drift-errors.ts`, `dump-drift.ts`); `src/index.ts` re-exports all |
18
18
  | A value ambient across an `await` | `asyncContext<T>(subject)` from core — never `new AsyncLocalStorage`. Three scopes: `transaction.ts`, `attribution.ts`, `expected-loop.ts` |
19
19
  | Exports | explicit in `src/index.ts`; no `export *` |
20
20
  | Files | < 200 LOC (the `packages/db/src/**/*.ts` path instruction), one responsibility, `kebab-case.ts`, test beside source |
@@ -35,40 +35,48 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
35
35
  only while its turn is held. `pglite-embedded.test.ts`, `pglite.test.ts`,
36
36
  `pglite-two-clients.test.ts`, `pglite-observer.test.ts`.
37
37
  - **The third rule is both drivers'**: `client.ts`'s pinned handle also runs direct only while held.
38
- `release()` is idempotent on both; `DbConnection` and `Turn` are `Disposable` (`[Symbol.dispose]` is
39
- `release()`).
38
+ `release()` is idempotent on both; `DbConnection` and `Turn` are `Disposable`.
40
39
  - **A pin is held by `using`, never a hand-rolled `try/finally`** (`withTransaction`,
41
40
  `readOnlyQuery`); `BEGIN` lives inside the guarded scope.
42
- - **`sqlstate.ts`**: `errno` first, `code` second, both shape-tested (`^[0-9A-Z]{5}$`).
43
- `DB_SQLSTATE_CODES` is closed; `driverError()` is its one consumer, `sendOn` its one caller.
41
+ - **`sqlstate.ts`**: `errno` first, `code` second, shape AND provenance (`isState`: `severity` =
42
+ server; `syscall`/numeric `errno` = socket; else needs a digit). `DB_SQLSTATE_CODES` is closed; `driverError()` is its one consumer, `sendOn` its one caller.
44
43
  - **`DbTx.origin` is the client the scope was opened on**, never the pin (entity's pinned-repository
45
44
  check reads it); a nested scope reports the root's.
46
45
  - **`withTransaction(fn, { retry })` re-runs `fn` only on `40001`/`40P01`**, default 0; each attempt
47
46
  its own pin, `BEGIN` and undo list (`runRoot`); a nested `retry` is `X_INVARIANT`. A re-run waits
48
- (`transaction-backoff.ts`: core's `backoffDelay`, 10 ms → 500 ms, full jitter; `{ sleep, random }`
49
- are injection seams); nothing waits at retry 0 or after the last attempt.
50
- - **Four codes are classified `retryable`** (`DB_ERROR_RETRY`: `X_DB_SERIALIZATION_FAILURE`,
51
- `X_DB_LOCK_TIMEOUT`, `X_DB_POOL_EXHAUSTED`, `X_MIGRATE_CONCURRENT`); terminal ones are deliberately
52
- unclassified (`errors-retry.test.ts` asserts the absence). Core's `retry()` executor is NOT adopted.
53
- - **`BEGIN` re-derives its isolation level from the closed set** (`isolationMode` switch with a `never`
54
- default; anything else `X_SQL_UNSAFE`).
55
- - Transaction control: `ROLLBACK` / `ROLLBACK TO SAVEPOINT` are best-effort; `SAVEPOINT` and
56
- `RELEASE SAVEPOINT` are deliberately uncaught.
57
- - **`close()` is BOUNDED by the driver's own `{ timeout }` in SECONDS** (`drainTimeoutMs / 1000`);
58
- `drainTimeoutMs: 0` sends no option; the verdict is elapsed time on `performance.now()`
59
- (`X_DB_DRAIN_TIMEOUT`). `pool-drain.test.ts`, `pool-drain.live.test.ts`. `close()` clears the cached
60
- driver before awaiting the teardown.
47
+ (`transaction-backoff.ts`: 10 ms → 500 ms, full jitter; `{ sleep, random }` are seams).
48
+ - **Four codes are `retryable`** (`DB_ERROR_RETRY`); terminal ones stay unclassified
49
+ (`errors-retry.test.ts`). Core's `retry()` executor is NOT adopted.
50
+ - **`BEGIN` re-derives its isolation level from the closed set** (`isolationMode`; else `X_SQL_UNSAFE`).
51
+ - **An aborted transaction is reported** (`X_DB_TRANSACTION_ABORTED`; README "Transactions that end
52
+ badly"): a failure carrying a SQLSTATE marks the root's `abort`, only a successful `ROLLBACK TO`
53
+ clears it (a failed one sets it), and `COMMIT` is then refused unsent. `commit-tag.ts` reads the
54
+ COMMIT tag in BOTH funnels. `COMMIT` rejecting with no SQLSTATE is `X_DB_COMMIT_UNKNOWN`: neither
55
+ list runs. `transaction-errors.ts`; `transaction-options.ts` holds `DbTx`, options, `BEGIN` text.
56
+ - **Sibling nested scopes take turns** (`TxState.children`; savepoints are a stack) under a deadline
57
+ (`sibling-turn.ts`, `siblingWaitMs`, `X_DB_SIBLING_SCOPE_TIMEOUT`). A nested
58
+ `isolation`/`readOnly`/`deferrable`/foreign `client` is `X_INVARIANT`.
59
+ - **`close()` is BOUNDED by the driver's own `{ timeout }` in SECONDS** (`drainTimeoutMs / 1000`; `0`
60
+ sends none); the verdict is elapsed `performance.now()` (`X_DB_DRAIN_TIMEOUT`). It clears the
61
+ cached driver before awaiting the teardown. `pool-drain{,.live}.test.ts`.
62
+ - **`client.listen` is ONE session beside the pool** (`listen.ts`; `Bun.SQL.listen`, PGlite's
63
+ `listen` under a turn), never a reserved pin. `onListening` fires on every re-dial; a channel is
64
+ refused unless it is a plain identifier. `listen.test.ts`, `listen.live.test.ts`.
61
65
  - `execute()` trusts the command tag only when `> 0`, in both drivers (`rowsOf`, `affectedBy`).
66
+ - **`pool-gauge.ts` derives `db_pool_max` / `db_pool_in_use` / `db_pool_waiting` from DEMAND**
67
+ (`Bun.SQL` publishes no occupancy). `client.ts` is the one counter: `run()` from send to settle, a
68
+ pin from ask to release; a statement ON a pin is not counted again. Declared on the first pool.
62
69
  - **`client.ts` connects, holds the client and the ambient `db()`**, and opens no socket at import;
63
70
  `pool-profile.ts`, `connection-url.ts`, `bun-sql.ts`, `pool-reserve.ts`, `db-health.ts` (`checkDb`)
64
71
  and `statement-funnel.ts` (`sendOn`/`runOn`) hold the rest.
65
- - **`libpq-options.ts` merges the framework's `options` into the operator's**: the framework wins on
66
- the names it sets, the operator keeps every other flag; the bound is emitted for all six roles.
72
+ - **`libpq-options.ts` merges the framework's `options` into the operator's**: the framework wins
73
+ on the names it sets, the operator keeps the rest; emitted for all six roles.
67
74
  - **`DATABASE_URL`'s scheme is screened at boot** (`POSTGRES_SCHEMES`: `postgres:`, `postgresql:`); the
68
- received scheme is never echoed (it may be a host or a credential). `connection-url.test.ts`.
75
+ received scheme is never echoed (it may be a credential).
69
76
  - **A JS array bound as a parameter is rendered here** (`array-parameter.ts`, `bound-parameters.ts`,
70
77
  called only by `sendOn`) — `Bun.SQL` joins elements with commas. `NULL` bare vs `"NULL"`; quoting by
71
- content; a `Uint8Array` is BYTEA; a ragged nest is refused. `array-parameter.live.test.ts`;
78
+ content; a `Uint8Array` is BYTEA, in an array too; a ragged nest and an Invalid Date are refused
79
+ (`X_INVARIANT`), ABOVE the driver's `try` in both funnels. `array-parameter.live.test.ts`;
72
80
  `packages/cli/src/pg-array.live.test.ts` is the composition test.
73
81
  - **Every numeric option is screened** through core's `finiteCount` (`replicaClient`'s breaker,
74
82
  `migrate`'s `lockWaitMs`, `readonlyQuery`'s `timeoutMs` — only an explicit `0` disables it — the pool
@@ -76,11 +84,10 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
76
84
 
77
85
  ## Observation
78
86
 
79
- - **`observe.ts`: one process-wide `StatementObserver`** (`setStatementObserver()` /
80
- `statementObserver()`). Guard at the call site; one observer, not a list; the seam swallows nothing;
81
- `onStatement` is synchronous and must not issue SQL. Only `runOn` (`statement-funnel.ts`) and
82
- `statement()` (`pglite.ts`) invoke it; both observe success and failure, and notify outside the
83
- statement's own `try`.
87
+ - **`observe.ts`: one process-wide `StatementObserver`** (`setStatementObserver()`). Guard at the
88
+ call site; one observer, not a list; the seam swallows nothing; `onStatement` is synchronous and
89
+ must not issue SQL. Only `runOn` (`statement-funnel.ts`) and `statement()` (`pglite.ts`) invoke
90
+ it; both observe success and failure, and notify outside the statement's own `try`.
84
91
  - **`attribution.ts`**: `withStatementAttribution(entity, op, fn)` — guard first (two strings, no
85
92
  allocation), a scope not a parameter, innermost pair wins; the funnels stamp it on both settle paths.
86
93
  `@ultimat3/entity`'s `postgresRepo` is the one producer.
@@ -88,12 +95,11 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
88
95
  text) and `statementKind(text)` off `statementVerb(text)`. Read by `x dev`'s ledger and
89
96
  `@ultimat3/testing`'s `statements` fixture. It counts nothing.
90
97
  - **`statement-span.ts`**: `withStatementSpan` wraps the send alone — `db.<verb>`, attribute
91
- `STATEMENT_ATTRIBUTE` (exported; `@ultimat3/cli`'s `dev-traces.ts` imports it), OTel kind `client`,
92
- opened only when an observer is installed.
98
+ `STATEMENT_ATTRIBUTE` (`@ultimat3/cli`'s `dev-traces.ts` imports it), OTel kind `client`, opened
99
+ only when an observer is installed.
93
100
  - **`expected-loop.ts` is the ONLY suppression**: `expectedQueryLoop(reason, fn)`, innermost reason,
94
- blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement. The
95
- framework's own loops declare themselves (`migrate()`, `rollback()`, `@ultimat3/admin`'s
96
- `search.ts`).
101
+ blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement.
102
+ The framework's own loops declare themselves (`migrate()`, `rollback()`, admin's `search.ts`).
97
103
  - `@ultimat3/jobs` never imports this package; its statements pass the observer only because
98
104
  `packages/cli/src/dev-queue.ts` wraps a real client for its `PgExecutor`, unattributed.
99
105
 
@@ -123,8 +129,8 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
123
129
  (`rollbackStepsInvalid`, `X_INVARIANT`).
124
130
  - **`refuseDependentViews(tx, script)`** (`dependent-view.ts`) runs before each migration's first
125
131
  statement: a word scan over `sql-scan.ts` finds retyped columns, one catalog round trip, the pair
126
- filtered in JS, and `X_MIGRATION_VIEW_DEPENDS` carries the `drop view` / `create view` from
127
- `pg_get_viewdef` (built through `identifier()` inside a `try`).
132
+ filtered in JS (visible tables only), and `X_MIGRATION_VIEW_DEPENDS` carries the `drop view` /
133
+ `create view` from `pg_get_viewdef` (built through `identifier()` inside a `try`).
128
134
  - `runningAppVersion()` delegates to core's `appVersion()`.
129
135
 
130
136
  ## Generation (`x db gen`)
@@ -168,11 +174,10 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
168
174
  NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds the
169
175
  column (`regenerate` answers `rebuilt`) and moves its dependents aside; a generated column's own type
170
176
  change deliberately does not. `introspect` never reads `generation_expression` back.
171
- `generate-generated-column.live.test.ts`, `generate-generated-rebuild.live.test.ts`.
172
- - **`REPLICA IDENTITY FULL` is emitted by a PARAMETER** (`GenerateOptions.replicaIdentityFull`, passed
173
- by `@ultimat3/cli`'s `db-generate.ts` from `describeQueries()`' `subscribes:`), in
174
- `replica-identity.ts`: recorded as `replicaIdentityFull: true` or absent; the snapshot records the
175
- union; dead last in `up`; never destructive; a name no entity declares is skipped; never reverted;
177
+ `generate-generated-{column,rebuild}.live.test.ts`.
178
+ - **`REPLICA IDENTITY FULL` is emitted by a PARAMETER** (`GenerateOptions.replicaIdentityFull`, from
179
+ `@ultimat3/cli`'s `db-generate.ts`), in `replica-identity.ts`: recorded `true` or absent; the
180
+ snapshot records the union; dead last in `up`; never destructive; an undeclared name is skipped;
176
181
  `down` is `replica identity default` except on a table this migration creates.
177
182
  - **A foreign key is `alter table … add constraint`**, collected into a bucket merged after every table
178
183
  statement (`foreign-key-plan.ts`); **dropping a table has its own bucket emitted BEFORE the table
@@ -184,20 +189,19 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
184
189
  - **`snapshot-json.ts` writes bytes that are a fixed point of Biome** (arrays collapse when they fit at
185
190
  `<= 100` counting the trailing comma); `snapshot-json.test.ts` runs the repo's own `biome format`.
186
191
  - **`declaredSchema()` answers the NEWEST migration's snapshot or `undefined`**; `checkDrift` turns
187
- that into `unknown-schema`, and `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING`. Both lead with
188
- the same two remedies in the same order: restore the sidecar (`git checkout --`), or delete the
189
- migration's files FIRST and only then run `x db gen`. `snapshotSiblings` / `migrationNameOf` build
190
- the second command from the caller's path; both commands are screened (`unknownSchema` through
191
- `shellInertIdentifier`, `migrationSnapshotMissing` through `renderFixShellArg`), degrading the whole
192
- line to prose.
192
+ that into `unknown-schema`, and `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING`. Both lead
193
+ with the same two remedies in order: restore the sidecar (`git checkout --`), or delete the
194
+ migration's files FIRST and then run `x db gen`. Both commands are screened (`shellInertIdentifier`
195
+ / `renderFixShellArg`), degrading the whole line to prose.
193
196
 
194
197
  ## Drift and introspection
195
198
 
196
199
  - **`checkDrift()` is the post-migrate verification** (live catalog vs the ledger just written, asked
197
200
  by `@ultimat3/cli`'s `runMigrations`), returned never thrown. The OTHER `X_DB_DRIFT` is the CLI's
198
201
  `checkSourceDrift`. Neither grows the other's half.
199
- - `compareTable` compares existence and **nullability** (primary-key columns excluded by the union of
200
- both sides' keys); the type is not compared. The `fix:` is the `alter table … set not null` itself.
202
+ - `compareTable` compares existence, **nullability** (the DECLARED key's columns excluded) and the
203
+ **primary key** in column order (`changed-primary-key`, fix = one `psql -c` of the pair; `primary-key.ts`
204
+ holds `x db gen`'s arm, its `drop not null`s, its two refusals). The type is not compared.
201
205
  - **A missing CHECK is drift, compared by NAME**: `TableDescription.checks` (declared: name and
202
206
  expression) vs `TableDescription.checkNames` (catalog: `conname` for `contype = 'c'`, always written
203
207
  by `introspect()`, `[]` included). Only the declared side is judged; no `changed-check`, ever.
@@ -205,26 +209,57 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
205
209
  - `compareTable` judges declared indexes (`missing-index`, `changed-index` over method, column list,
206
210
  uniqueness, predicate presence and direction; `asc` normalises to `null`); never the predicate text.
207
211
  - `compareForeignKeys` matches on where a key points (`foreignKeyTarget`, the one copy) and compares
208
- `onDelete` through `onDeleteRule` (`changed-foreign-key`, fix = drop/add pair).
212
+ `onDelete` through `onDeleteRule` (`changed-foreign-key`; its fix, `changed-column`'s and
213
+ `missing-check`'s are one `psql -c` too — `repair()`, schema-scoped off `public`).
209
214
  - `introspect()` reads index columns in key order (`indkey`) and a foreign key's two column lists
210
215
  together (`unnest(a, b) with ordinality`), pinned by `introspect-embedded.test.ts`.
211
216
  - **`appTables()`** excludes the whole `x_` namespace for drift; `introspect()` alone excludes
212
217
  `x_migrations` by default. **`app-relation.ts`**: `nonAppRelations(client, schema)` — extension
213
218
  ownership from `pg_depend` (`deptype = 'e'`) plus views, materialised views and foreign tables —
214
219
  merged into `excluded` unconditionally.
215
- - **`unexpectedTable`'s `fix:` never names `x db gen`**: a `create table if not exists` in a migration
216
- (accepted by `@ultimat3/cli`'s `acceptCreatedTables`) or dropping a table nothing owns.
217
- - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`); the `X_DB_DRIFT`
218
- rendering and title are duplicated in `@ultimat3/entity`, held equal by
219
- `packages/entity/src/errors.test.ts`. `errors.ts` registers `DB_ERROR_TITLES` unconditionally.
220
+ - **`unexpectedTable`'s `fix:` never names `x db gen`**: `psql -c '\d "T"'`, commented with the two
221
+ repairs (claim it with `create table if not exists`, or drop it).
222
+ - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`) and is the only one —
223
+ `@ultimat3/entity`'s copy is deleted. `errors.ts` registers `DB_ERROR_TITLES` unconditionally.
220
224
  - `drift-findings.ts` holds every `DriftDifference` constructor and `DriftKind`; `drift.ts` keeps the
221
225
  comparisons.
222
226
 
227
+ ## The schema dump
228
+
229
+ - **Its own entry: `@ultimat3/db/schema-dump`** (`schema-dump-entry.ts`). The barrel is in every
230
+ role's boot graph and must evaluate none of this family — `schema-dump-entry.test.ts`.
231
+ - **Two readings of one catalog, never mixed.** `introspect()` → `SchemaDescription`, the entity
232
+ vocabulary a snapshot is diffed in. `introspectCatalog()` (`catalog.ts`; queries in `catalog-relations.ts` and
233
+ `catalog-objects.ts`; the pure fold in `catalog-fold.ts`) → `CatalogDescription`, Postgres' own `pg_get_*def` text, compared only to
234
+ itself. A catalog spelling on a `SchemaDescription` field is the `checks`/`checkNames` mistake.
235
+ - **Sorted in JS** (`byCodeUnit`), never `order by` and never `localeCompare`: collations differ.
236
+ - **`renderSchemaDump()` is pure**; `schema-dump-table.ts` spells one table. `quoted()` escapes any
237
+ catalog name — `identifier()` refuses whitespace and `"`, which a migration may have created.
238
+ - **`x_` owner → `framework/` twin** (`FRAMEWORK_TABLE_PREFIX`). No list is handed in.
239
+ - **What cannot be rendered is named** (`unrenderedRows`, a closed list → `unrendered.sql`; a trigger
240
+ on an unrendered relation is named by the fold). A kind rendered later leaves that list in the
241
+ same diff. Never render a partition as a plain table. `generated` carries `stored`/`virtual`.
242
+ - **`loadSchemaDump()`**: one transaction, `check_function_bodies` off, a savepoint per file, and
243
+ only `42P01`/`42883`/`42704` are retried. Its refusal is `X_SCHEMA_DUMP_DRIFT` (`dump-drift.ts`).
244
+ - **`object-drift.ts`**: `unexpectedObjects(live, expected)` compares IDENTITY (kind, table, name,
245
+ arguments). The live side is the operator's Postgres, the expected side a PGlite replay.
246
+ - **No app path here.** Where the dump lives, which engine replays, and when it is written are
247
+ `@ultimat3/cli`'s (`db-schema-dump.ts`). `schema-load.contract.test.ts` reads the reference app's
248
+ migrations as text and runs on Postgres when `TEST_DATABASE_URL` is set.
249
+ - **`pglite-extensions.ts` is the one linker** (`linkPgliteExtensions` → `{ linked, missing }`):
250
+ `contrib/<name>` then the package root; the name is data (read from migration text), screened
251
+ by `pgliteExtensionExport` before it reaches a specifier; `plpgsql` is built in, never missing.
252
+ Only a not-found import is `missing`; a bundle that throws is `X_DB_UNAVAILABLE`.
253
+ - **`pglite-snapshot.ts`**: `snapshotDir` makes a `memory://` boot a restore. Key = PGlite version
254
+ (unreadable = no cache), never the extension set. One file, checksum in its header; unsound or
255
+ unopenable → deleted and rebuilt; unreadable → a miss. Temp name + `rename`. Uncompressed.
256
+ - **Embedded boots for dump tests**: `schema-dump.test.ts` and `schema-dump-fidelity.test.ts`.
257
+
223
258
  ## Branches, replicas, read-only
224
259
 
225
- - **`reapBranches` sweeps branches of THIS database**: the marker is `ultimate:branch:<base>:<iso>`
226
- (`BranchInfo.base`), split on the ISO tail; an older one-segment marker is skipped, never dropped; an
227
- unparseable `createdAt` is skipped. `@ultimat3/cli`'s `ls`/`drop` scope by name prefix.
260
+ - **`reapBranches` sweeps branches of THIS database**: the marker is `ultimate:branch:<base>:<iso>`,
261
+ split on the ISO tail; an older one-segment marker or an unparseable `createdAt` is skipped, never
262
+ dropped. `@ultimat3/cli`'s `ls`/`drop` scope by name prefix.
228
263
  - **Read replicas are opt-in twice**: a pool when `DATABASE_REPLICA_URL` names one
229
264
  (`default-client.ts`), and a read offered only inside `withReplicaReads(fn)` (`replica-scope.ts`);
230
265
  read-your-writes is `ReplicaScope.wrote`, never a request-id map. **`withTransaction` is on the
package/README.md CHANGED
@@ -29,7 +29,8 @@ await withTransaction(async (tx) => {
29
29
  | `shellInertIdentifier()` | `As of 2026-08-26`: a quoted identifier that is also inert wherever a human PASTES it — or `null`. The one screen a catalog name goes through before it reaches a `fix:`. `identifier()` answers about SQL and **accepts** a backtick and a `$`, which are exactly what a shell substitutes inside double quotes, so a column called `$(id)` inside `x db gen "add $(id)"` runs `id` on paste |
30
30
  | `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
31
31
  | `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused |
32
- | `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all |
32
+ | `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all. **A transaction the server aborted is reported as one**, `As of 2026-10-02` → [Transactions that end badly](#transactions-that-end-badly) |
33
+ | `liveTxConnection()` | the connection of the transaction still **open** on this async context, or `undefined`. `currentTx()` keeps answering a finished scope's handle to a promise chain the body forgot to await; this is the one that says whether a statement sent now is really inside a transaction. `@ultimat3/action` reads it before binding an idempotency settlement to a commit |
33
34
  | `sqlState()` / `sqlStateCode()` / `isRetryableState()` / `SQLSTATE` | `As of 2026-08`: the SQLSTATE a driver error carries, and the closed table from it to a code. `Bun.SQL` puts it on `errno`; PGlite puts it on `code`; **one** reader answers for both |
34
35
  | `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
35
36
  | `statementsOf()` | `As of 2026-08`: a SQL script → the statements a driver sends one at a time. One send is one statement, so `migrate()` splits with this — a `;` inside a literal, an identifier, a dollar-quoted body or a comment is data |
@@ -51,6 +52,13 @@ await withTransaction(async (tx) => {
51
52
  | `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructive()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |
52
53
  | `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |
53
54
  | `introspect()` | live schema → `SchemaDescription`. **App tables only**, `As of 2026-08-24`: a relation an extension owns (`pg_depend`, `deptype = 'e'`) and anything that is not an ordinary or partitioned table are excluded before the fold, and an explicit `exclude` cannot bring them back |
55
+ | `introspectCatalog()` / `CatalogDescription` / `emptyCatalog()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: the WHOLE schema in the catalog's own spelling — extensions, enum and domain types, sequences, tables, indexes, foreign keys, views, functions, triggers — sorted in code-unit order, plus `unrendered`: what exists and the dump cannot spell. Comparable only to another reading of itself; `introspect()` stays the entity-vocabulary reading a snapshot is diffed in |
56
+ | `renderSchemaDump()` / `SchemaDumpFile` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: a catalog → the schema dump's files. Pure and byte-deterministic. [The schema dump](#the-schema-dump) |
57
+ | `loadSchemaDump()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: build a schema from those files, in one transaction, retrying a file that names something not created yet |
58
+ | `compareSchemaDump()` / `reloadDifferences()` / `schemaDumpDrift()` / `schemaDumpDifferenceOf()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: `X_SCHEMA_DUMP_DRIFT` — committed files against rendered ones in both directions, and load-equals-replay as a comparison |
59
+ | `unexpectedObjects()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: the triggers, functions, views, types and sequences a live catalog holds and an expected one does not, as `unexpected-object` drift. Identity, never definition text. Its `fix:` is one `psql -c` command that prints the object's definition, qualified by the catalog's schema — `\d+` a view, `\dT+` an enum, `\dD+` a domain, `\d` a sequence or a trigger's table, `pg_get_functiondef` by namespace for a function — with the repair as its comment |
60
+ | `PgliteOptions.extensions` / `linkPgliteExtensions()` | `As of 2026-10`: Postgres extensions to link at boot, by name — a list, or a function for a caller whose list is read from disk. `linkPgliteExtensions(names)` answers `{ linked, missing }` without booting anything: `missing` is what the installed PGlite ships no bundle for, which is how `@ultimat3/cli` decides a replay needs a real Postgres. A missing name is skipped at boot and refused by `create extension` itself |
61
+ | `PgliteOptions.snapshotDir` | `As of 2026-10`: a directory for the post-`initdb` snapshot, so an in-memory boot is a restore (~0.4 s against ~2.7 s). Keyed on the PGlite version alone — `initdb` never sees a linked extension, so one snapshot serves every set; checksummed, never trusted, written by temp-name-then-rename. Ignored for a data directory on disk |
54
62
  | `createBranch()` / `dropBranch()` / `reapBranches()` | copy-on-write branch databases. `As of 2026-08-19` the marker comment records the **base** as well as the instant (`ultimate:branch:<base>:<iso>`, on `BranchInfo.base`), and `reapBranches()` sweeps only branches of the database it is connected to — one Postgres hosting two Ultimate apps used to mean one app's nightly reap dropped the other's branches. A pre-3.x marker records no base and is skipped, never dropped |
55
63
  | `createPgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
56
64
  | `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
@@ -68,13 +76,37 @@ String interpolation is how every SQL injection ships, and an agent writing SQL
68
76
  trusted to remember the difference between a value and a fragment. So:
69
77
 
70
78
  - scalars (`string`, `number`, `boolean`, `bigint`, `Date`, `Uint8Array`, arrays, `null`) become
71
- `$1..$n` and never touch `.text`;
79
+ `$1..$n` and never touch `.text`. A value that cannot be SENT — an Invalid Date, a ragged array —
80
+ is `X_INVARIANT` before the driver is called, never `X_DB_UNAVAILABLE`; a `Uint8Array` inside an
81
+ array is one `bytea` element. Both drivers refuse alike;
72
82
  - a nested fragment is spliced and its parameters are renumbered;
73
83
  - **anything else throws `X_SQL_UNSAFE`** — including an object shaped like a `SqlFragment` that
74
84
  `sql`/`raw` did not produce;
75
85
  - `raw(trusted)` is the one audited escape hatch, `identifier(name)` the safe way to interpolate
76
86
  a table or column, `literal(text)` for utility statements that reject bound parameters.
77
87
 
88
+ ## Transactions that end badly
89
+
90
+ `As of 2026-10-02`. Postgres aborts the WHOLE transaction on any statement error and answers the
91
+ `COMMIT` that follows with the tag `ROLLBACK` and no error. `withTransaction` used to resolve on
92
+ that, fire `onCommit`, and store nothing.
93
+
94
+ | Situation | What happens |
95
+ |---|---|
96
+ | the body catches a failed statement and returns | `X_DB_TRANSACTION_ABORTED`, cause = the first failing statement; `ROLLBACK`, `onRollback` undos run, `onCommit` never fires |
97
+ | the fallible statement sits in a nested `withTransaction` and THAT is caught | only the savepoint rolls back; the outer scope commits. This is the fix the error names |
98
+ | a nested body swallows a failed statement | the nested call rejects with `X_DB_TRANSACTION_ABORTED`; its savepoint is rolled back and the outer scope is usable again |
99
+ | `ROLLBACK TO SAVEPOINT` itself fails | the nested call still rejects with the body's error; the root is marked aborted and its `COMMIT` is refused, never sent |
100
+ | two nested scopes under `Promise.all` | run one after the other, per parent scope — savepoints are a stack, and interleaved ones destroyed each other |
101
+ | a nested scope waits past `siblingWaitMs` (default `SIBLING_SCOPE_WAIT_MS`, 30 s; `0` = no deadline) for its sibling | `X_DB_SIBLING_SCOPE_TIMEOUT`, naming the parent and the savepoint holding the turn. The shape it exists for: a body awaiting a sibling started after it, which waits for itself — a permanent hang before the deadline. The waiter never opened and its place is handed on |
102
+ | any `COMMIT` answered `ROLLBACK`, on either driver | `X_DB_TRANSACTION_ABORTED` from the statement funnel (`commit-tag.ts`) — covers an abort the scope never saw and a hand-written `COMMIT` |
103
+ | `COMMIT` rejects with a SQLSTATE (a deferred constraint, `40001`) | rolled back: the server's error surfaces, undos run |
104
+ | `COMMIT` rejects with NO SQLSTATE | `X_DB_COMMIT_UNKNOWN`: neither `onCommit` nor `onRollback` runs, and it is never retried |
105
+ | a nested scope passes `isolation`, `readOnly: true`, `deferrable: true`, or a `client` other than the root's | `X_INVARIANT` — a savepoint can honour none of them; `retry` was already refused |
106
+
107
+ Only a failure carrying a SQLSTATE marks the scope aborted: a refusal raised before the send left
108
+ the transaction untouched.
109
+
78
110
  ## Read-only access for anything an LLM drives
79
111
 
80
112
  `As of 2026-07`: a bug in one defence must not become a write, so `db.query` on the MCP dev
@@ -192,8 +224,9 @@ back as `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`), so
192
224
  read as `conname` alone and lands on `TableDescription.checkNames`, a **separate field** from the
193
225
  declaration's `checks`. Only the declared side is judged, so a NOT NULL, an `enumerated()` column's
194
226
  old anonymous form and an extension's own constraint are all silent; a declared one the catalog
195
- does not hold is `missing-check`, whose `fix:` is the `add constraint` statement itself, because the
196
- migration that declares it is already in the ledger and `x db migrate` would apply nothing. There is
227
+ does not hold is `missing-check`, whose `fix:` is the `add constraint` statement as one
228
+ `psql "$DATABASE_URL" -c '…'` command against that database, because the migration that declares
229
+ it is already in the ledger and `x db migrate` would apply nothing. There is
197
230
  no `changed-check`: presence is a boolean, a predicate is text, and normalising the text is an
198
231
  expression parser competing with the server's.
199
232
 
@@ -219,10 +252,12 @@ X_DB_DRIFT: schema differs from migrations
219
252
  |---|---|---|
220
253
  | live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` — the name goes through `shellInertIdentifier()`, and one it refuses is left OUT of the command rather than escaped into it (`x db gen "add the undeclared column"`, the name in the cause) |
221
254
  | migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
222
- | live table, no migration | `table "T" is not present in any migration` | a `create table if not exists` in a migration, then `x db migrate` — or `drop table` in `psql` where nothing owns it. Never `x db gen`, which diffs a table nothing declares against nothing and writes no file (issue #345). The name goes through `shellInertIdentifier()`, and one it refuses leaves the fix as prose |
255
+ | live table, no migration | `table "T" is not present in any migration` | `psql "$DATABASE_URL" -c '\d "T"'` — one harmless command that shows the table — with the two repairs as its comment: a `create table if not exists` in a migration, then `x db migrate`, or `drop table` where nothing owns it. Never `x db gen`, which diffs a table nothing declares against nothing and writes no file (issue #345). The name goes through `shellInertIdentifier()`, and one it refuses leaves `psql "$DATABASE_URL" # <the steps>` |
223
256
  | migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
224
257
  | index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
225
- | foreign key, rule moved | `foreign key on "T" (C) to "R" is on delete cascade, not what migrations declare` | the `drop constraint` + `add constraint` pair, in a new migration |
258
+ | foreign key, rule moved | `foreign key "K" on "T" (C) to "R" is on delete cascade, not what migrations declare` — `K` is the constraint the database holds | `psql "$DATABASE_URL" -c '<the drop constraint + add constraint pair>'` — one command, against the drifted database — then `x db migrate` |
259
+ | column nullability differs (`changed-column`) | `table "T" allows NULL in column "C" that migrations declare not null` / `forbids NULL in column "C" that migrations declare nullable` | `psql "$DATABASE_URL" -c 'alter table "T" alter column "C" set not null;'` (or `drop not null`) — one command, then `x db migrate`. A table read from a schema other than `public` gets `set search_path = "<schema>";` ahead of the statement, in the same `psql -c` word, so it and every table it references resolve there. A name `shellInertIdentifier()` refuses degrades every one of these three to `psql "$DATABASE_URL" # <the steps>`: still a command that runs, with no name in it |
260
+ | primary key differs (`changed-primary-key`, `As of 2026-10-02`) | `table "T" has primary key (id) as constraint "T_pkey", and migrations declare (slug)` — the constraint named is the one the DATABASE holds — compared in column ORDER; `has no primary key` when the database holds none | `psql "$DATABASE_URL" -c '<drop constraint "<the live key>"; add constraint "T_pkey" primary key (…)>'` — one command, against the drifted database — then `x db migrate`. Nullability is skipped for the DECLARED key's columns only |
226
261
 
227
262
  `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
228
263
  them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
@@ -231,6 +266,55 @@ the exit code — and a deploy that rolled on past a schema nobody can reconstru
231
266
  drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
232
267
  detector (`checkSourceDrift`), which needs no database and never calls this.
233
268
 
269
+ ## The schema dump
270
+
271
+ **Imported from `@ultimat3/db/schema-dump`, never from the barrel** (23.0.0): `introspectCatalog`,
272
+ `emptyCatalog`, the `Catalog*` types, `renderSchemaDump`, `loadSchemaDump`, `compareSchemaDump`,
273
+ `reloadDifferences`, `schemaDumpDrift`, `schemaDumpDifferenceOf` and `unexpectedObjects`. Three
274
+ callers run them — `x db gen`, `x db migrate`, the gate's `drift` step — and `@ultimat3/db` is in
275
+ every role's boot graph, where these ten modules served nothing. `schema-dump-entry.test.ts` holds
276
+ both halves: the barrel evaluates none of them, and no name has two homes.
277
+
278
+ `renderSchemaDump(await introspectCatalog({ client }))` is the schema as files: one directory per
279
+ object kind, numbered in the order a database is built in, one file per named object.
280
+
281
+ | Directory | Holds |
282
+ |---|---|
283
+ | `01_extensions/` | `create extension if not exists` — never `plpgsql` |
284
+ | `02_types/` | enums, domains |
285
+ | `03_sequences/` | sequences no column owns |
286
+ | `04_tables/` | the table; a `serial` column's sequence before it and its ownership after; `replica identity full` |
287
+ | `05_indexes/` | per table, indexes no constraint backs; `replica identity using index` |
288
+ | `06_foreign_keys/` | per table, `alter table … add constraint` |
289
+ | `07_views/` | views; a materialized view with its indexes |
290
+ | `08_functions/` | per name, overloads together |
291
+ | `09_triggers/` | per table |
292
+
293
+ - An object whose owner — its table, else itself — starts with `x_` goes to a `framework/` twin of
294
+ the same layout. The rule is `FRAMEWORK_TABLE_PREFIX`, the one `appTables()` already holds.
295
+ - Postgres' own spellings (`format_type`, `pg_get_expr`, `pg_get_*def`), so a loaded dump renders
296
+ the same bytes. Sorted in JS, never by `order by`: a name's order follows the server's collation.
297
+ - No timestamp, version, owner or grant. Every sequence option is written, defaults included.
298
+ - A name becomes a file name with everything outside `[A-Za-z0-9_-]` percent-encoded.
299
+ - A file over 500 lines (`SCHEMA_DUMP_MAX_LINES`, the `filesize` ceiling) is split `<name>.1.sql`, `<name>.2.sql`;
300
+ `loadSchemaDump()` joins the parts in numeric order.
301
+ - What cannot be spelled — partitions, inheritance, foreign tables, composite and range types,
302
+ aggregates, row-security policies (and `force row level security`), rules, extended statistics,
303
+ a column's non-default `storage`, a materialized view created `with no data`, and a trigger on
304
+ a relation the dump does not create — is named in `unrendered.sql` as comments.
305
+ - A generated column is spelled `stored` or `virtual` (Postgres 18), as `attgenerated` says.
306
+
307
+ `loadSchemaDump()` runs kind by kind, the framework twin first, each file in a savepoint. A file
308
+ refused with `42P01`, `42883` or `42704` — "not created yet" — is retried after the rest; a pass
309
+ that loads nothing ends with `X_SCHEMA_DUMP_DRIFT` naming the file.
310
+
311
+ **Not the snapshot.** `<id>.snapshot.json` is `x db gen`'s diff base, in the entity's vocabulary:
312
+ both sides of that diff are generator spellings. The dump is what the database holds, in SQL:
313
+ both sides of ITS comparison are catalog spellings. Neither can be compared with the other.
314
+
315
+ Which engine, where the files live, when they are written and what holds them is `@ultimat3/cli`'s
316
+ (`x db gen`, `x db migrate`, the `drift` step). This package renders, loads and compares.
317
+
234
318
  ## The embedded database
235
319
 
236
320
  No `DATABASE_URL` means no Docker: `createPgliteClient()` runs Postgres as WASM inside this
@@ -312,6 +396,33 @@ so nothing else would ever end that wait. `migrate()` emits it as `SET LOCAL loc
312
396
  each migration's own transaction — it reverts at COMMIT, so a DDL value never leaks onto the session
313
397
  the ledger insert runs on.
314
398
 
399
+ ## `LISTEN` on a session of its own
400
+
401
+ A pooled statement cannot hold a subscription: the next one runs on another connection. Both
402
+ clients hold ONE session beside the pool for it.
403
+
404
+ ```ts
405
+ import { createPostgresClient } from '@ultimat3/db';
406
+
407
+ const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
408
+ const subscription = await client.listen(
409
+ 'x_jobs_wake',
410
+ (payload) => console.log('notified', payload),
411
+ () => console.log('listening'), // again after every re-dial
412
+ );
413
+ await subscription.unlisten();
414
+ ```
415
+
416
+ | Fact | Detail |
417
+ |---|---|
418
+ | the session | `Bun.SQL.listen` on the pooled client — outside `max`, untouched by `idleTimeout`, ended by `unlisten()` or `close()`; PGlite's own `listen` on the embedded one. Every channel a client listens on shares it |
419
+ | `onListening` | fires each time the subscription is (re-)established. The driver re-dials a session that died, and what was notified in between is LOST — re-read the source of truth there |
420
+ | the channel | a lower-case identifier of at most 63 characters, refused otherwise before a driver sees it |
421
+ | a transaction-pooling proxy | `listen()` resolves and nothing is ever delivered. Only a notification that arrives proves the path |
422
+ | `canListen(client)` | `true` for both shipped clients. `false` for a replicated pair — listen on the primary it was built from, as the boot does |
423
+
424
+ `@ultimat3/jobs`' `startQueueWake` is the framework's one caller.
425
+
315
426
  ## Migrations
316
427
 
317
428
  `migrate()` takes the advisory lock by **polling** `pg_try_advisory_lock(4919202607)` every 500ms
@@ -415,6 +526,9 @@ job the moment Postgres fails over.
415
526
  | `X_DB_UNIQUE_VIOLATION` | `23505` — `fix:` names `upsertAll(rows, { onConflict: [...] })` and the constraint the server named |
416
527
  | `X_DB_FOREIGN_KEY_VIOLATION` | `23503` |
417
528
  | `X_DB_SERIALIZATION_FAILURE` | `40001` / `40P01`, and an exhausted `withTransaction(fn, { retry: n })` budget |
529
+ | `X_DB_TRANSACTION_ABORTED` | a statement failed inside a transaction and its error was caught; the server rolled the unit of work back. Also any `COMMIT` answered with the tag `ROLLBACK` |
530
+ | `X_DB_COMMIT_UNKNOWN` | `COMMIT` was sent and the connection failed before the answer: durable or rolled back, unknowable from here |
531
+ | `X_DB_SIBLING_SCOPE_TIMEOUT` | a nested `withTransaction` waited past `siblingWaitMs` for a sibling scope that never finished |
418
532
  | `X_DB_STATEMENT_TIMEOUT` | `57014` — the statement ran past `statement_timeout` |
419
533
  | `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
420
534
  | `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
@@ -424,6 +538,7 @@ job the moment Postgres fails over.
424
538
  | `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
425
539
  | `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
426
540
  | `X_BRANCH_EXISTS` | branch database already exists (or is the connected one) |
541
+ | `X_SCHEMA_DUMP_DRIFT` | the committed schema dump is not what the migrations produce, or does not load back |
427
542
  | `X_NOT_IMPLEMENTED` | branching an in-memory PGlite — a copy needs a directory |
428
543
  | `X_ENV_MISSING` | core's — `DATABASE_POOL_MAX` is set to something that is not a positive integer |
429
544
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,11 +14,13 @@
14
14
  "provenance": true
15
15
  },
16
16
  "exports": {
17
- ".": "./src/index.ts"
17
+ ".": "./src/index.ts",
18
+ "./schema-dump": "./src/schema-dump-entry.ts"
18
19
  },
19
20
  "files": [
20
21
  "src",
21
22
  "!src/**/*.test.ts",
23
+ "!src/**/*-fixture.ts",
22
24
  "CLAUDE.md",
23
25
  "README.md",
24
26
  "LICENSE"
@@ -31,7 +33,7 @@
31
33
  "test": "bun test"
32
34
  },
33
35
  "dependencies": {
34
- "@ultimat3/core": "22.15.0"
36
+ "@ultimat3/core": "24.0.0"
35
37
  },
36
38
  "peerDependencies": {
37
39
  "@electric-sql/pglite": ">=0.5.0"
@@ -28,18 +28,45 @@ import { assert } from '@ultimat3/core';
28
28
  * backslash, or leading/trailing whitespace the parser would strip — plus the empty string, which
29
29
  * unquoted is not an element at all.
30
30
  */
31
+ const hexOf = (bytes: Uint8Array): string =>
32
+ Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
33
+
34
+ /**
35
+ * A `Date` as the instant Postgres reads, or `X_INVARIANT` for one that holds no instant.
36
+ * `toISOString()` on an Invalid Date is a bare `RangeError`, and inside the funnel that became
37
+ * "cannot reach the database". `position` names the parameter when the caller knows it.
38
+ */
39
+ export function instantText(value: Date, position?: number): string {
40
+ assert(
41
+ !Number.isNaN(value.getTime()),
42
+ `${position === undefined ? 'an array element' : `parameter $${position}`} is an Invalid Date, which names no instant Postgres could store`,
43
+ 'new Date(input) answers Invalid Date for text it cannot parse — parse it with t.date first, or bind null: Number.isNaN(value.getTime()) ? null : value',
44
+ );
45
+ return value.toISOString();
46
+ }
47
+
31
48
  function element(value: unknown): string {
32
49
  if (value === null || value === undefined) return 'NULL';
33
50
  // A Date is ALWAYS quoted, even though an ISO-8601 instant carries no character the grammar
34
51
  // reads as structure. A timestamp element is conventionally quoted, and the alternative is a
35
52
  // rule that holds only while nothing ever renders a timestamp with a space in it.
36
- if (value instanceof Date) return `"${value.toISOString()}"`;
53
+ if (value instanceof Date) return `"${instantText(value)}"`;
54
+ // BYTEA's hex form, never `String(bytes)` — that is `1,2,3`, three elements where one was bound,
55
+ // and no error anywhere. The backslash is doubled because a quoted element reads `\\` as one.
56
+ if (value instanceof Uint8Array) return `"\\\\x${hexOf(value)}"`;
37
57
  const text = String(value);
38
58
  const structural = /[{},"\\\s]/.test(text) || text.length === 0 || text.toUpperCase() === 'NULL';
39
59
  if (!structural) return text;
40
60
  return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
41
61
  }
42
62
 
63
+ /** The extents along a value's FIRST path, `2x3` for two rows of three — `''` for a scalar. */
64
+ function extentsOf(value: unknown): string {
65
+ if (!Array.isArray(value)) return '';
66
+ const inner = extentsOf(value[0]);
67
+ return inner === '' ? String(value.length) : `${value.length}x${inner}`;
68
+ }
69
+
43
70
  /**
44
71
  * The array literal for one parameter: `{a,b,c}`, elements escaped.
45
72
  *
@@ -71,5 +98,15 @@ export function pgArrayLiteral(values: readonly unknown[]): string {
71
98
  `a nested array parameter is ragged — its rows are ${nested.map((row) => row.length).join(', ')} long, and Postgres has no jagged array`,
72
99
  'give every row the same length, or bind one array per row',
73
100
  );
101
+ // Every extent, not only this level's: `[[['a']], [['b', 'c']]]` is two rows of one element each,
102
+ // each rectangular on its own, and `{{{a}},{{b,c}}}` is the same 22P02 — measured on 17. Each
103
+ // row is checked against itself by the recursion below, so comparing one path's extents per row
104
+ // is comparing all of them.
105
+ const depth = extentsOf(nested[0]);
106
+ assert(
107
+ nested.every((row) => extentsOf(row) === depth),
108
+ `a nested array parameter is ragged below its first level — its rows have the extents ${nested.map((row) => extentsOf(row)).join(' | ')}, and Postgres has no jagged array`,
109
+ 'give every branch the same length at every depth, or bind one array per row',
110
+ );
74
111
  return `{${values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : element(value))).join(',')}}`;
75
112
  }
@@ -4,7 +4,7 @@
4
4
  // described type to go by the driver sent `Date.prototype.toString()`, a local-zone string
5
5
  // Postgres refuses (`22007`), so every entity write carrying a timestamp failed.
6
6
 
7
- import { pgArrayLiteral } from './array-parameter';
7
+ import { instantText, pgArrayLiteral } from './array-parameter';
8
8
 
9
9
  const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value instanceof Date;
10
10
 
@@ -12,12 +12,31 @@ const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value
12
12
  * A NEW ARRAY ONLY WHEN SOMETHING CHANGED — every statement in the process passes through here, so
13
13
  * the common path is one `some` and the caller's own array, byte for byte (axiom 6). A
14
14
  * `Uint8Array` is BYTEA, never an array: `Array.isArray` answers `false` for a typed array.
15
+ *
16
+ * It REFUSES what cannot be sent — a ragged array, an Invalid Date — with `X_INVARIANT`, so the
17
+ * funnel calls it BEFORE the driver's `try`: inside it, a refusal was re-wrapped as a driver
18
+ * failure and read "cannot reach the database".
15
19
  */
20
+ /**
21
+ * The refusals alone, for a driver that does its own encoding. PGlite renders an array and a
22
+ * `Date` correctly, so `pglite.ts` sends the caller's values untouched — but what cannot be sent
23
+ * must be refused alike on both funnels: an Invalid Date (its serializer answers a bare
24
+ * `RangeError`) and a ragged or mixed-depth array, which the pooled path refuses in
25
+ * `pgArrayLiteral`. That function IS the shape rule, so it is asked and its literal discarded
26
+ * rather than restated here; only a statement that binds an array pays for it.
27
+ */
28
+ export function refuseUnsendable(values: readonly unknown[]): void {
29
+ for (const [index, value] of values.entries()) {
30
+ if (value instanceof Date) instantText(value, index + 1);
31
+ else if (Array.isArray(value)) pgArrayLiteral(value);
32
+ }
33
+ }
34
+
16
35
  export function encodeBoundParameters(values: readonly unknown[]): readonly unknown[] {
17
36
  if (!values.some(needsEncoding)) return values;
18
- return values.map((value) => {
37
+ return values.map((value, index) => {
19
38
  if (Array.isArray(value)) return pgArrayLiteral(value);
20
- if (value instanceof Date) return value.toISOString();
39
+ if (value instanceof Date) return instantText(value, index + 1);
21
40
  return value;
22
41
  });
23
42
  }
package/src/bun-sql.ts CHANGED
@@ -58,6 +58,18 @@ export interface BunSqlDriver {
58
58
  unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
59
59
  reserve(): Promise<BunSqlReserved>;
60
60
  close(options?: { readonly timeout?: number }): Promise<void>;
61
+ /**
62
+ * `LISTEN` on a connection the driver opens for it, OUTSIDE the pool. Measured on Bun 1.4.0
63
+ * against Postgres 17: a `max: 1` pool still answers statements while it is held;
64
+ * `idleTimeout` does not close it; a backend killed under it is re-dialled by the driver, with
65
+ * its own growing gap between attempts, and `onlisten` fires again once it is back; `close()`
66
+ * and `unlisten()` both end the session. Optional because a fake driver has none.
67
+ */
68
+ listen?(
69
+ channel: string,
70
+ onnotify: (payload: string) => void,
71
+ onlisten?: () => void,
72
+ ): Promise<{ unlisten(): Promise<void> }>;
61
73
  }
62
74
 
63
75
  export type BunSqlFactory = new (