@ultimat3/db 23.0.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.
package/CLAUDE.md CHANGED
@@ -35,47 +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`.
61
62
  - **`client.listen` is ONE session beside the pool** (`listen.ts`; `Bun.SQL.listen`, PGlite's
62
63
  `listen` under a turn), never a reserved pin. `onListening` fires on every re-dial; a channel is
63
64
  refused unless it is a plain identifier. `listen.test.ts`, `listen.live.test.ts`.
64
65
  - `execute()` trusts the command tag only when `> 0`, in both drivers (`rowsOf`, `affectedBy`).
65
- - **`pool-gauge.ts` derives `db_pool_max` / `db_pool_in_use` / `db_pool_waiting` from DEMAND** —
66
- `Bun.SQL` publishes no occupancy. `client.ts` is the one counter: `run()` from send to settle, a
67
- pin from the ask to its (idempotent) release; a statement ON a pin is not counted again. Declared
68
- on the first tracked pool, never at import. `pool-gauge.test.ts`.
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.
69
69
  - **`client.ts` connects, holds the client and the ambient `db()`**, and opens no socket at import;
70
70
  `pool-profile.ts`, `connection-url.ts`, `bun-sql.ts`, `pool-reserve.ts`, `db-health.ts` (`checkDb`)
71
71
  and `statement-funnel.ts` (`sendOn`/`runOn`) hold the rest.
72
- - **`libpq-options.ts` merges the framework's `options` into the operator's**: the framework wins on
73
- 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.
74
74
  - **`DATABASE_URL`'s scheme is screened at boot** (`POSTGRES_SCHEMES`: `postgres:`, `postgresql:`); the
75
- 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).
76
76
  - **A JS array bound as a parameter is rendered here** (`array-parameter.ts`, `bound-parameters.ts`,
77
77
  called only by `sendOn`) — `Bun.SQL` joins elements with commas. `NULL` bare vs `"NULL"`; quoting by
78
- 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`;
79
80
  `packages/cli/src/pg-array.live.test.ts` is the composition test.
80
81
  - **Every numeric option is screened** through core's `finiteCount` (`replicaClient`'s breaker,
81
82
  `migrate`'s `lockWaitMs`, `readonlyQuery`'s `timeoutMs` — only an explicit `0` disables it — the pool
@@ -83,11 +84,10 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
83
84
 
84
85
  ## Observation
85
86
 
86
- - **`observe.ts`: one process-wide `StatementObserver`** (`setStatementObserver()` /
87
- `statementObserver()`). Guard at the call site; one observer, not a list; the seam swallows nothing;
88
- `onStatement` is synchronous and must not issue SQL. Only `runOn` (`statement-funnel.ts`) and
89
- `statement()` (`pglite.ts`) invoke it; both observe success and failure, and notify outside the
90
- 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`.
91
91
  - **`attribution.ts`**: `withStatementAttribution(entity, op, fn)` — guard first (two strings, no
92
92
  allocation), a scope not a parameter, innermost pair wins; the funnels stamp it on both settle paths.
93
93
  `@ultimat3/entity`'s `postgresRepo` is the one producer.
@@ -95,12 +95,11 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
95
95
  text) and `statementKind(text)` off `statementVerb(text)`. Read by `x dev`'s ledger and
96
96
  `@ultimat3/testing`'s `statements` fixture. It counts nothing.
97
97
  - **`statement-span.ts`**: `withStatementSpan` wraps the send alone — `db.<verb>`, attribute
98
- `STATEMENT_ATTRIBUTE` (exported; `@ultimat3/cli`'s `dev-traces.ts` imports it), OTel kind `client`,
99
- 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.
100
100
  - **`expected-loop.ts` is the ONLY suppression**: `expectedQueryLoop(reason, fn)`, innermost reason,
101
- blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement. The
102
- framework's own loops declare themselves (`migrate()`, `rollback()`, `@ultimat3/admin`'s
103
- `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`).
104
103
  - `@ultimat3/jobs` never imports this package; its statements pass the observer only because
105
104
  `packages/cli/src/dev-queue.ts` wraps a real client for its `PgExecutor`, unattributed.
106
105
 
@@ -130,8 +129,8 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
130
129
  (`rollbackStepsInvalid`, `X_INVARIANT`).
131
130
  - **`refuseDependentViews(tx, script)`** (`dependent-view.ts`) runs before each migration's first
132
131
  statement: a word scan over `sql-scan.ts` finds retyped columns, one catalog round trip, the pair
133
- filtered in JS, and `X_MIGRATION_VIEW_DEPENDS` carries the `drop view` / `create view` from
134
- `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`).
135
134
  - `runningAppVersion()` delegates to core's `appVersion()`.
136
135
 
137
136
  ## Generation (`x db gen`)
@@ -175,11 +174,10 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
175
174
  NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds the
176
175
  column (`regenerate` answers `rebuilt`) and moves its dependents aside; a generated column's own type
177
176
  change deliberately does not. `introspect` never reads `generation_expression` back.
178
- `generate-generated-column.live.test.ts`, `generate-generated-rebuild.live.test.ts`.
179
- - **`REPLICA IDENTITY FULL` is emitted by a PARAMETER** (`GenerateOptions.replicaIdentityFull`, passed
180
- by `@ultimat3/cli`'s `db-generate.ts` from `describeQueries()`' `subscribes:`), in
181
- `replica-identity.ts`: recorded as `replicaIdentityFull: true` or absent; the snapshot records the
182
- 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;
183
181
  `down` is `replica identity default` except on a table this migration creates.
184
182
  - **A foreign key is `alter table … add constraint`**, collected into a bucket merged after every table
185
183
  statement (`foreign-key-plan.ts`); **dropping a table has its own bucket emitted BEFORE the table
@@ -191,20 +189,19 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
191
189
  - **`snapshot-json.ts` writes bytes that are a fixed point of Biome** (arrays collapse when they fit at
192
190
  `<= 100` counting the trailing comma); `snapshot-json.test.ts` runs the repo's own `biome format`.
193
191
  - **`declaredSchema()` answers the NEWEST migration's snapshot or `undefined`**; `checkDrift` turns
194
- that into `unknown-schema`, and `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING`. Both lead with
195
- the same two remedies in the same order: restore the sidecar (`git checkout --`), or delete the
196
- migration's files FIRST and only then run `x db gen`. `snapshotSiblings` / `migrationNameOf` build
197
- the second command from the caller's path; both commands are screened (`unknownSchema` through
198
- `shellInertIdentifier`, `migrationSnapshotMissing` through `renderFixShellArg`), degrading the whole
199
- 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.
200
196
 
201
197
  ## Drift and introspection
202
198
 
203
199
  - **`checkDrift()` is the post-migrate verification** (live catalog vs the ledger just written, asked
204
200
  by `@ultimat3/cli`'s `runMigrations`), returned never thrown. The OTHER `X_DB_DRIFT` is the CLI's
205
201
  `checkSourceDrift`. Neither grows the other's half.
206
- - `compareTable` compares existence and **nullability** (primary-key columns excluded by the union of
207
- 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.
208
205
  - **A missing CHECK is drift, compared by NAME**: `TableDescription.checks` (declared: name and
209
206
  expression) vs `TableDescription.checkNames` (catalog: `conname` for `contype = 'c'`, always written
210
207
  by `introspect()`, `[]` included). Only the declared side is judged; no `changed-check`, ever.
@@ -212,18 +209,18 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
212
209
  - `compareTable` judges declared indexes (`missing-index`, `changed-index` over method, column list,
213
210
  uniqueness, predicate presence and direction; `asc` normalises to `null`); never the predicate text.
214
211
  - `compareForeignKeys` matches on where a key points (`foreignKeyTarget`, the one copy) and compares
215
- `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`).
216
214
  - `introspect()` reads index columns in key order (`indkey`) and a foreign key's two column lists
217
215
  together (`unnest(a, b) with ordinality`), pinned by `introspect-embedded.test.ts`.
218
216
  - **`appTables()`** excludes the whole `x_` namespace for drift; `introspect()` alone excludes
219
217
  `x_migrations` by default. **`app-relation.ts`**: `nonAppRelations(client, schema)` — extension
220
218
  ownership from `pg_depend` (`deptype = 'e'`) plus views, materialised views and foreign tables —
221
219
  merged into `excluded` unconditionally.
222
- - **`unexpectedTable`'s `fix:` never names `x db gen`**: a `create table if not exists` in a migration
223
- (accepted by `@ultimat3/cli`'s `acceptCreatedTables`) or dropping a table nothing owns.
224
- - **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`); the `X_DB_DRIFT`
225
- rendering and title are duplicated in `@ultimat3/entity`, held equal by
226
- `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.
227
224
  - `drift-findings.ts` holds every `DriftDifference` constructor and `DriftKind`; `drift.ts` keeps the
228
225
  comparisons.
229
226
 
@@ -239,8 +236,9 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
239
236
  - **`renderSchemaDump()` is pure**; `schema-dump-table.ts` spells one table. `quoted()` escapes any
240
237
  catalog name — `identifier()` refuses whitespace and `"`, which a migration may have created.
241
238
  - **`x_` owner → `framework/` twin** (`FRAMEWORK_TABLE_PREFIX`). No list is handed in.
242
- - **What cannot be rendered is named** (`unrenderedRows`, a closed list → `unrendered.sql`). A kind
243
- rendered later leaves that list in the same diff. Never render a partition as a plain table.
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`.
244
242
  - **`loadSchemaDump()`**: one transaction, `check_function_bodies` off, a savepoint per file, and
245
243
  only `42P01`/`42883`/`42704` are retried. Its refusal is `X_SCHEMA_DUMP_DRIFT` (`dump-drift.ts`).
246
244
  - **`object-drift.ts`**: `unexpectedObjects(live, expected)` compares IDENTITY (kind, table, name,
@@ -253,16 +251,15 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
253
251
  by `pgliteExtensionExport` before it reaches a specifier; `plpgsql` is built in, never missing.
254
252
  Only a not-found import is `missing`; a bundle that throws is `X_DB_UNAVAILABLE`.
255
253
  - **`pglite-snapshot.ts`**: `snapshotDir` makes a `memory://` boot a restore. Key = PGlite version
256
- (read off its `package.json`; unreadable = no cache), never the extension set. One file, checksum in its
257
- header; unsound or unopenable → deleted and rebuilt; unreadable → a miss. Temp name + `rename`. Uncompressed by
258
- measurement: gzip taxes the boot that writes, and a CI checkout always writes.
259
- - **One embedded boot** serves every database-backed dump test: `schema-dump.test.ts`.
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`.
260
257
 
261
258
  ## Branches, replicas, read-only
262
259
 
263
- - **`reapBranches` sweeps branches of THIS database**: the marker is `ultimate:branch:<base>:<iso>`
264
- (`BranchInfo.base`), split on the ISO tail; an older one-segment marker is skipped, never dropped; an
265
- 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.
266
263
  - **Read replicas are opt-in twice**: a pool when `DATABASE_REPLICA_URL` names one
267
264
  (`default-client.ts`), and a read offered only inside `withReplicaReads(fn)` (`replica-scope.ts`);
268
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 |
@@ -55,7 +56,7 @@ await withTransaction(async (tx) => {
55
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) |
56
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 |
57
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 |
58
- | `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 |
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 |
59
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 |
60
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 |
61
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 |
@@ -75,13 +76,37 @@ String interpolation is how every SQL injection ships, and an agent writing SQL
75
76
  trusted to remember the difference between a value and a fragment. So:
76
77
 
77
78
  - scalars (`string`, `number`, `boolean`, `bigint`, `Date`, `Uint8Array`, arrays, `null`) become
78
- `$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;
79
82
  - a nested fragment is spliced and its parameters are renumbered;
80
83
  - **anything else throws `X_SQL_UNSAFE`** — including an object shaped like a `SqlFragment` that
81
84
  `sql`/`raw` did not produce;
82
85
  - `raw(trusted)` is the one audited escape hatch, `identifier(name)` the safe way to interpolate
83
86
  a table or column, `literal(text)` for utility statements that reject bound parameters.
84
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
+
85
110
  ## Read-only access for anything an LLM drives
86
111
 
87
112
  `As of 2026-07`: a bug in one defence must not become a write, so `db.query` on the MCP dev
@@ -199,8 +224,9 @@ back as `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`), so
199
224
  read as `conname` alone and lands on `TableDescription.checkNames`, a **separate field** from the
200
225
  declaration's `checks`. Only the declared side is judged, so a NOT NULL, an `enumerated()` column's
201
226
  old anonymous form and an extension's own constraint are all silent; a declared one the catalog
202
- does not hold is `missing-check`, whose `fix:` is the `add constraint` statement itself, because the
203
- 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
204
230
  no `changed-check`: presence is a boolean, a predicate is text, and normalising the text is an
205
231
  expression parser competing with the server's.
206
232
 
@@ -226,10 +252,12 @@ X_DB_DRIFT: schema differs from migrations
226
252
  |---|---|---|
227
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) |
228
254
  | migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
229
- | 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>` |
230
256
  | migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
231
257
  | index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
232
- | 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 |
233
261
 
234
262
  `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
235
263
  them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
@@ -271,7 +299,10 @@ object kind, numbered in the order a database is built in, one file per named ob
271
299
  - A file over 500 lines (`SCHEMA_DUMP_MAX_LINES`, the `filesize` ceiling) is split `<name>.1.sql`, `<name>.2.sql`;
272
300
  `loadSchemaDump()` joins the parts in numeric order.
273
301
  - What cannot be spelled — partitions, inheritance, foreign tables, composite and range types,
274
- aggregates, row-security policies, rules — is named in `unrendered.sql` as comments.
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.
275
306
 
276
307
  `loadSchemaDump()` runs kind by kind, the framework twin first, each file in a savepoint. A file
277
308
  refused with `42P01`, `42883` or `42704` — "not created yet" — is retried after the rest; a pass
@@ -495,6 +526,9 @@ job the moment Postgres fails over.
495
526
  | `X_DB_UNIQUE_VIOLATION` | `23505` — `fix:` names `upsertAll(rows, { onConflict: [...] })` and the constraint the server named |
496
527
  | `X_DB_FOREIGN_KEY_VIOLATION` | `23503` |
497
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 |
498
532
  | `X_DB_STATEMENT_TIMEOUT` | `57014` — the statement ran past `statement_timeout` |
499
533
  | `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
500
534
  | `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "23.0.0"
36
+ "@ultimat3/core": "24.0.0"
37
37
  },
38
38
  "peerDependencies": {
39
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
  }
@@ -51,7 +51,10 @@ function columnOf(row: ColumnRow, sequences: readonly SequenceRow[]): CatalogCol
51
51
  type: row.type,
52
52
  notNull: row.not_null,
53
53
  default: generated ? null : row.expression,
54
- generated: generated ? row.expression : null,
54
+ generated:
55
+ generated && row.expression !== null
56
+ ? { expression: row.expression, storage: row.generated === 'v' ? 'virtual' : 'stored' }
57
+ : null,
55
58
  identity:
56
59
  identity === undefined
57
60
  ? null
@@ -148,6 +148,15 @@ export const triggerRows = (client: DbClient, schema: string): Promise<readonly
148
148
  * What exists in the schema and the dump cannot spell. One query, one closed list — a kind added
149
149
  * here is a kind the dump admits it does not carry, and a kind rendered later leaves this list in
150
150
  * the same diff.
151
+ *
152
+ * The last four are facts ABOUT an object the dump does render, and each was absent from both the
153
+ * dump and this list: `create statistics`, `force row level security` (a second flag beside
154
+ * `relrowsecurity`), a column whose `set storage` departs from its type's own, and a materialized
155
+ * view created `with no data` — which the dump's `create materialized view` would populate. A
156
+ * load-equals-replay check cannot see any of them, because both sides are this same reading.
157
+ *
158
+ * A trigger on a relation the dump does not create is the one kind NOT read here: which relations
159
+ * are rendered is the fold's answer, so `introspectCatalog` names those itself.
151
160
  */
152
161
  export const unrenderedRows = (
153
162
  client: DbClient,
@@ -194,6 +203,26 @@ export const unrenderedRows = (
194
203
  from pg_rewrite r
195
204
  join pg_class c on c.oid = r.ev_class
196
205
  where r.rulename <> '_RETURN'
206
+ union all
207
+ select 'extended statistics', s.stxname, c.relname, s.stxnamespace
208
+ from pg_statistic_ext s
209
+ join pg_class c on c.oid = s.stxrelid
210
+ union all
211
+ select 'forced row security', c.relname, c.relname, c.relnamespace
212
+ from pg_class c
213
+ where c.relforcerowsecurity
214
+ union all
215
+ select 'column storage', a.attname, c.relname, c.relnamespace
216
+ from pg_attribute a
217
+ join pg_class c on c.oid = a.attrelid
218
+ join pg_type t on t.oid = a.atttypid
219
+ where c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
220
+ and a.attstorage <> t.typstorage
221
+ and ${notExtensionOwned('pg_class', 'c.oid')}
222
+ union all
223
+ select 'unpopulated materialized view', c.relname, null::text, c.relnamespace
224
+ from pg_class c
225
+ where c.relkind = 'm' and not c.relispopulated
197
226
  ) objects
198
227
  join pg_namespace n on n.oid = objects.namespace
199
228
  where n.nspname = ${schema}
package/src/catalog.ts CHANGED
@@ -56,8 +56,16 @@ export interface CatalogColumn {
56
56
  readonly notNull: boolean;
57
57
  /** `pg_get_expr` of the default; `null` when there is none or the column is generated. */
58
58
  readonly default: string | null;
59
- /** The stored generation expression, when `attgenerated` says the column has one. */
60
- readonly generated: string | null;
59
+ /**
60
+ * The generation expression and HOW it is kept, when `attgenerated` says the column has one:
61
+ * `s` is `stored`, `v` (Postgres 18) is `virtual` — computed on read, nothing on disk. Both
62
+ * halves, because a dump that spelled every one `stored` loaded a virtual column as a stored
63
+ * one and round-tripped "equal": both sides of that comparison were this reading.
64
+ */
65
+ readonly generated: {
66
+ readonly expression: string;
67
+ readonly storage: 'stored' | 'virtual';
68
+ } | null;
61
69
  /** `always` / `by default`, with the identity sequence's own options. */
62
70
  readonly identity: {
63
71
  readonly mode: 'always' | 'by default';
@@ -0,0 +1,21 @@
1
+ // Single responsibility: the server's answer to `COMMIT`, read. Postgres answers a COMMIT on an
2
+ // aborted transaction with the command tag `ROLLBACK` and NO error, so a driver that reports only
3
+ // rejections calls a rolled-back unit of work committed. Both funnels (`statement-funnel.ts`,
4
+ // `pglite.ts`) ask here, so every COMMIT in the process is covered and not only `withTransaction`'s.
5
+
6
+ import { stringField } from '@ultimat3/core';
7
+ import { transactionAborted } from './transaction-errors';
8
+
9
+ /** `COMMIT` and its alias `END`, as the first word. Only consulted once the tag already disagrees. */
10
+ const COMMIT_STATEMENT = /^\s*(?:commit|end)\b/i;
11
+
12
+ /**
13
+ * Throws `X_DB_TRANSACTION_ABORTED` when `text` asked for a commit and the tag says `ROLLBACK`.
14
+ * The tag is read first: one property read per statement on the path every statement takes, and
15
+ * the regular expression runs only for a statement that really was answered `ROLLBACK`. Both
16
+ * drivers carry the tag on `command` — measured on Bun.SQL against Postgres 17 and on PGlite.
17
+ */
18
+ export function refuseRolledBackCommit(text: string, result: unknown): void {
19
+ if (stringField(result, 'command') !== 'ROLLBACK') return;
20
+ if (COMMIT_STATEMENT.test(text)) throw transactionAborted();
21
+ }
@@ -139,6 +139,7 @@ async function dependentViews(
139
139
  join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
140
140
  where v.relkind in ('v', 'm') and v.oid <> c.oid
141
141
  and c.relname in (${tables}) and a.attname in (${columns})
142
+ and pg_table_is_visible(c.oid)
142
143
  order by v.relname
143
144
  `);
144
145
  }
@@ -155,7 +156,8 @@ async function dependentViews(
155
156
  const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
156
157
 
157
158
  /** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
158
- const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
159
+ export const psqlCommand = (statement: string): string =>
160
+ `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
159
161
 
160
162
  /**
161
163
  * The two statements that unblock the deploy, as one line an operator pastes.
@@ -166,7 +168,7 @@ const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(
166
168
  * a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
167
169
  *
168
170
  * `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
169
- * quoted Postgres name — and a `fix:` may not throw: the rule `rebuildForeignKey` already states,
171
+ * quoted Postgres name — and a `fix:` may not throw: the rule `changedForeignKey` (`drift-findings.ts`) states,
170
172
  * with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
171
173
  * operator an exception where a verdict was asked for, over a view name that is perfectly legal.
172
174
  * The fallback still leads with a command that runs — a psql session — because quoting that name
@@ -192,8 +194,8 @@ function restoreView(view: string, definition: string, relkind: string): string
192
194
  try {
193
195
  const name = identifier(view).text;
194
196
  return (
195
- `${psql(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
196
- `${psql(`create ${kind} ${name} as ${body}`)}${note}`
197
+ `${psqlCommand(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
198
+ `${psqlCommand(`create ${kind} ${name} as ${body}`)}${note}`
197
199
  );
198
200
  } catch {
199
201
  return (