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