@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 +62 -65
- package/README.md +42 -8
- package/package.json +2 -2
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/catalog-fold.ts +4 -1
- package/src/catalog-objects.ts +29 -0
- package/src/catalog.ts +10 -2
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +209 -59
- package/src/drift.ts +19 -13
- package/src/errors.ts +6 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +5 -3
- package/src/introspect-catalog.ts +18 -1
- package/src/introspect.ts +45 -8
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +77 -20
- package/src/pglite-branch.ts +6 -6
- package/src/pglite.ts +13 -1
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-table.ts +4 -1
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +30 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
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
|
|
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,
|
|
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`:
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
`
|
|
57
|
-
-
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
73
|
-
the names it sets, the operator keeps
|
|
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
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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` (
|
|
99
|
-
|
|
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.
|
|
102
|
-
framework's own loops declare themselves (`migrate()`, `rollback()`,
|
|
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` /
|
|
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
|
|
179
|
-
- **`REPLICA IDENTITY FULL` is emitted by a PARAMETER** (`GenerateOptions.replicaIdentityFull`,
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
195
|
-
the same two remedies in
|
|
196
|
-
migration's files FIRST and
|
|
197
|
-
|
|
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
|
|
207
|
-
|
|
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
|
|
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`**:
|
|
223
|
-
(
|
|
224
|
-
- **`dbDrift()` lives in `drift-errors.ts`** (it needs `shellInertIdentifier`)
|
|
225
|
-
|
|
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
|
|
243
|
-
|
|
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
|
-
(
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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": "
|
|
36
|
+
"@ultimat3/core": "24.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/array-parameter.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/bound-parameters.ts
CHANGED
|
@@ -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
|
|
39
|
+
if (value instanceof Date) return instantText(value, index + 1);
|
|
21
40
|
return value;
|
|
22
41
|
});
|
|
23
42
|
}
|
package/src/catalog-fold.ts
CHANGED
|
@@ -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:
|
|
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
|
package/src/catalog-objects.ts
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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
|
+
}
|
package/src/dependent-view.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
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
|
-
`${
|
|
196
|
-
`${
|
|
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 (
|