@ultimat3/db 29.4.0 → 30.1.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 +74 -110
- package/README.md +163 -15
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +20 -0
- package/src/concurrent-plan.ts +135 -17
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +17 -3
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +67 -0
- package/src/errors.ts +77 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +6 -1
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +105 -79
- package/src/index.ts +129 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +12 -4
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +45 -4
- package/src/migration-errors.ts +27 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +50 -22
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Postgres access for Ultimate: parameterised SQL, transactions, migrations, drift detection,
|
|
4
4
|
branch databases, and a read-only client for anything an LLM drives.
|
|
5
5
|
|
|
6
|
+
`bun add @ultimat3/db` · guide: [Entities And Migrations](https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations)
|
|
7
|
+
|
|
6
8
|
Tier 1. Imports `@ultimat3/core` only. No runtime dependencies; `@electric-sql/pglite` is an
|
|
7
9
|
**optional peer**, loaded at first query and only by the embedded driver. **No ORM backs any of
|
|
8
10
|
this** — `@ultimat3/entity`'s hand-written `postgresDriver()` compiles every statement out of the
|
|
@@ -26,16 +28,20 @@ await withTransaction(async (tx) => {
|
|
|
26
28
|
| Export | |
|
|
27
29
|
|---|---|
|
|
28
30
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
31
|
+
| `keyset({ scope, keys, after, limit })` | keyset paging for a raw join: `{ select, where, orderBy, limit, page(rows) }`, [below](#paging-a-raw-join-keyset) |
|
|
29
32
|
| `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
33
|
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
34
|
+
| `dbClientInstalled()` | `As of 30.0`: whether `setDbClient` installed a client that is still in place. Never builds the lazy default. `@ultimat3/entity`'s `testDriver()` asks it on every repository call |
|
|
31
35
|
| `dbExecutor(client = db)` | the client as `@ultimat3/core`'s `PgExecutor` (`query(text, values)`) — what a jobs, idempotency, notify or MCP confirmation store takes. The client is resolved per statement: the default reaches the boot's client even when declared at module scope, and joins an open `withTransaction`; `() => pool` pins one pool whatever transaction is open |
|
|
32
36
|
| `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 |
|
|
33
37
|
| `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) |
|
|
38
|
+
| `completeTransaction()` / `refuseAbandoned()` | `As of 2026-11-02`: the mark that takes the open root's statements and COMMIT past an abandoned scope (a keyed action's), and the check for a write that goes through no `DbTx` (`@ultimat3/entity`'s `writing()`, the enqueue facade) → [Transactions that end badly](#transactions-that-end-badly) |
|
|
34
39
|
| `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 |
|
|
35
40
|
| `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 |
|
|
36
41
|
| `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
|
|
37
42
|
| `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 |
|
|
38
43
|
| `withReplicaReads()` / `replicaScope()` | `As of 2026-08-24`: the scope inside which a plain read may be served by a replica — until it writes, after which every read in it is the primary's. No scope open, nothing routes |
|
|
44
|
+
| `poolerFromEnv()` / `POOLER_ENV` / `PoolerMode` / `POOLER_WAIT_MS` / `statementBound()` / `lostStatementBound()` / `StatementBound` / `poolerSessionRequired()` | `As of 2026-11-03`: a connection pooler in the path (`pooler.ts`), and the two bounds as the session reads them. [Pool sizing by role](#pool-sizing-by-role) |
|
|
39
45
|
| `replicatedClient()` / `ReplicaStats` / `REPLICA_URL_ENV` | `As of 2026-08-24`: one `DbClient` over a primary and a standby. `baseClient()` builds one when `DATABASE_REPLICA_URL` is set and the single-pool client when it is not |
|
|
40
46
|
| `INDEX_METHODS` / `IndexMethod` / `indexMethodOf()` / `indexMethodSql()` / `declaredMethod()` / `isIndexMethod()` | `As of 2026-08-24`: an index's access method — `btree` or `gin`, closed. Absent is `btree`, the live side is read open (whatever `pg_am` said), and the DDL literal is re-derived from the set rather than spliced from the input |
|
|
41
47
|
| `isPlainRead()` | `As of 2026-08-24`: whether a statement may leave the primary. An allow-list — everything it cannot vouch for is the primary's |
|
|
@@ -45,15 +51,18 @@ await withTransaction(async (tx) => {
|
|
|
45
51
|
| `snapshotJson()` | `As of 2026-08`: the sidecar's **bytes** — the JSON Biome would have printed, trailing newline included. The one writer of a `<id>.snapshot.json`, because `JSON.stringify(…, null, 2)` is not formatter-clean and an app's `lint` step rejected the file `x db gen` had just written |
|
|
46
52
|
| `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
|
|
47
53
|
| `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
|
|
54
|
+
| `sameTenantKeyName()` | the name of a same-tenant composite key, `<table>_<column>_tenant_fkey`, within 63 bytes. `ColumnDescriptionLike.sameTenant` is what makes a key composite; `@ultimat3/entity`'s memory driver reads this so its refusal names the constraint Postgres would |
|
|
48
55
|
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all. `As of 2026-08-25` a **retype** drops the partial indexes and CHECK constraints written against that column first and restores them in `down`: Postgres compiles both predicates against the old type and cannot recompile either, so `alter column … type text using …::text` was `42883 operator does not exist: text = post_status` and the migration aborted mid-run. A plain btree over the column is left alone — measured, Postgres rebuilds that one itself. `As of 2026-08-26` `replicaIdentityFull` names the tables a live query subscribes to and emits one `alter table … replica identity full` each, last in `up`, recorded on the snapshot so the next generation emits none — a parameter and never an entity field, because the live-query set is a tier-3 fact (`replica-identity.ts`) |
|
|
49
56
|
| `declaredIndexes()` / `invariantChecks()` / `constraintNameFor()` | `As of 2026-08-25`: the DDL an entity **invariant** becomes — a `check` as a named `CONSTRAINT`, a `unique` as a partial-capable unique INDEX, an `assert` as nothing. `EntityDescriptionLike` had no `invariants` field for three majors, so a regenerated migration silently held **none** of them, including the composite UNIQUE `upsertAll`'s `on conflict` is inferred against |
|
|
50
57
|
| `declaredChecks()` / `checkClauses()` / `checkPlan()` / `columnChecks()` / `columnCheckName()` / `columnNamesConstraint()` | `As of 2026-08-25`: **every** CHECK a table declares — a column's own (`enumerated()`'s value set, `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound) and an invariant's — on ONE list, so `createTable`, `diffTable` and `snapshotOf` agree about what exists. A column's check reached `create table` **inline and anonymous** and nothing else: the snapshot recorded none and the diff had no arm, so a value added to `enumerated()` generated no migration and a regenerated ENUM column came back as bare `text`. The name is `<table>_<column>_check` because that is the name **Postgres itself mints** for the old anonymous form — measured — so the repair lands on the constraint an already-generated database is holding; `checkPlan` emits `drop constraint if exists` before the `add` for exactly that column, because a bare add is `42710` there and a no-op everywhere else |
|
|
51
58
|
| `defaultExpression()` / `ColumnDefaultLike` | `As of 2026-08-25`: a column's `default` as SQL. A DECLARED default (`{ kind: 'value', value }`) wins; `gen_random_uuid()` and `now()` stay as the inference for a description that carries only `hasDefault` |
|
|
52
59
|
| `unrenderedOf()` / `unrenderedComment()` / `UnrenderedDeclaration` | `As of 2026-08-25`: what the generator could **not** write, on `GeneratedMigration.unrendered` and as a `-- UNRENDERED` block at the top of a non-empty `up`. A generator that emits less than the declaration in silence is the defect the whole file exists against, and `x verify`'s `drift` step reads a source hash — it never reads the SQL, so the loss was green. **`unrenderedOf(entities, current)` takes the recorded schema**, required and nullable: a rule declared as an `assert` reaches no SQL by design and is no loss on its own, but one whose CHECK a previous migration RECORDED is dropped by this run and reported by nothing — five in `examples/dummy`, and `@ultimat3/cli`'s `repairFix` then offered `x db gen "drop <name>"` as the repair for the loss that command performs |
|
|
53
60
|
| `isNonTransactional()` / `NO_TRANSACTION_MARKER` / `isConcurrentIndexStatement()` / `concurrentIndexTarget()` | `As of 2026-10-31`: the `-- transaction: false` marker a migration carries to run outside a transaction, and the concurrent index statements that need it → [Indexes built concurrently](#indexes-built-concurrently-and-every-index-drop) |
|
|
61
|
+
| `rollingUnsafeStatements()` / `hasRollingUnsafeMarker()` / `ROLLING_UNSAFE_MARKER` / `retiredColumnsIn()` / `retireAndContractPending()` / `migrationRollingUnsafe()` | `As of 2026-11-02`: the rolling-deploy rail — would the previous release trip over this `up`? A closed list (`drop column` with no earlier `-- retires`, `rename column`, `rename table`), read by the `drift` step past `rollingSince`, and the pair `migrate()` refuses pending together in production |
|
|
62
|
+
| `recordRetired()` | `As of 2026-11-02`: `snapshotOf` one step on, keeping a retired column and what hangs off it on the record. `x db gen` and the `drift` step both read through it, so they cannot disagree about one |
|
|
54
63
|
| `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructiveMigration()` / `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 |
|
|
55
64
|
| `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 |
|
|
56
|
-
| `introspectSchema()` | 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 |
|
|
65
|
+
| `introspectSchema()` | 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. **A partition is not app schema either** (`relispartition`, `As of 2026-11-06`): its parent is judged — keys, indexes, checks, triggers — and carries `partitioned: true`; a key Postgres clones per partition (`conparentid <> 0`) is not read |
|
|
57
66
|
| `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; `introspectSchema()` stays the entity-vocabulary reading a snapshot is diffed in |
|
|
58
67
|
| `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) |
|
|
59
68
|
| `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 |
|
|
@@ -65,7 +74,9 @@ await withTransaction(async (tx) => {
|
|
|
65
74
|
| `pgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
|
|
66
75
|
| `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
|
|
67
76
|
| `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
|
|
77
|
+
| `dbDiagnostics()` / `DIAGNOSTICS_LIMIT` / `DIAGNOSTICS_TIMEOUT_MS` (from `@ultimat3/db/diagnostics`) | `As of 2026-11`: what the database is doing now, as seven sections (`blockers`, `activity`, `slowQueries`, `unusedIndexes`, `invalidIndexes`, `notValidConstraints`, `deadTuples`), each `{ available: true, rows }` or `{ available: false, code, reason, fix }` → [Diagnostics](#diagnostics). `x db diagnostics` prints it |
|
|
68
78
|
| `setStatementObserver()` / `statementObserver()` | `As of 2026-08`: one event **and one `db.<verb>` span** per settled statement, both drivers; uninstalled is one branch |
|
|
79
|
+
| `expectedSeqScan()` / `expectedSeqScanReason()` | `As of 2026-11`: the one way a hand-written statement says it reads its table whole — `x exercise`'s plan sweep lists it exempt with the reason instead of `X_QUERY_UNINDEXED`. Same shape as `expectedQueryLoop()`: required, non-blank, innermost wins. A small table says it once on the entity: `scan: '<why>'` |
|
|
69
80
|
| `expectedQueryLoop()` / `expectedQueryLoopReason()` | `As of 2026-08`: the one way to declare a loop of queries deliberate — the reason rides on every statement it issues as `StatementEvent.expected` |
|
|
70
81
|
| `withStatementAttribution()` / `statementAttribution()` | `As of 2026-08`: the `{ entity, op }` pair on `StatementEvent.attribution`, scoped exactly like `expectedQueryLoop()` — `@ultimat3/entity`'s `postgresRepo` is the one producer |
|
|
71
82
|
| `STATEMENT_ATTRIBUTE` | `As of 2026-08`: `db.statement`, the OTel attribute each span carries its text under — declared here, read by `x dev`'s timeline |
|
|
@@ -87,6 +98,32 @@ trusted to remember the difference between a value and a fragment. So:
|
|
|
87
98
|
- `raw(trusted)` is the one audited escape hatch, `identifier(name)` the safe way to interpolate
|
|
88
99
|
a table or column, `literal(text)` for utility statements that reject bound parameters.
|
|
89
100
|
|
|
101
|
+
## Failure bundles: `x_failures`
|
|
102
|
+
|
|
103
|
+
`postgresFailureStore()` is the store `failureCapture` (`app.config.ts`) installs: every statement
|
|
104
|
+
runs `outsideTransaction`, because the request that failed rolled its own transaction back.
|
|
105
|
+
`find(id)` takes a bundle, request or trace id; `latest(fingerprint)` the newest of a group;
|
|
106
|
+
`list({ code })` the summaries `x repro --list` prints. Retention has no default window:
|
|
107
|
+
`failurePurgeTarget(store, { keepMs })` is a `purge()` target. The table is applied by the boot.
|
|
108
|
+
|
|
109
|
+
## Paging a raw join: `keyset()`
|
|
110
|
+
|
|
111
|
+
`As of 2026-11-06`. The handle pages with `.after()`; a join it cannot say pages with this. Four
|
|
112
|
+
fragments to splice and `page(rows)`, which answers core's `Page`.
|
|
113
|
+
|
|
114
|
+
| Rule | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| A key | `{ column: SqlFragment, kind: 'timestamptz' \| 'uuid' \| 'text' \| 'integer' \| 'bigint', direction }`, last key the tiebreak |
|
|
117
|
+
| Precision | selected as `col::text` under `_keyset_<n>`, bound back as `$n::<kind>`: a microsecond survives, a `Date` would floor it and drop rows |
|
|
118
|
+
| The seek | uniform direction: one row comparison, Index Only Scan on a matching index. Mixed: the or-chain |
|
|
119
|
+
| `order by` | the bare columns. `date_trunc('milliseconds', …)` there is a Parallel Seq Scan and a sort (100 ms against 0.2 ms, 300,000 rows); the scaffolded `repo-raw-sql` guard reports it |
|
|
120
|
+
| A null key | `X_KEYSET_NULL_KEY`, on any row of a page, whatever its size |
|
|
121
|
+
| The cursor | core's signed codec; scoped to `scope` plus every key's column, kind and direction. Another read's is `X_CURSOR_INVALID` |
|
|
122
|
+
| `limit` | a whole number ≥ 1 (`finiteCount`), read as `limit + 1` |
|
|
123
|
+
|
|
124
|
+
Proven on both engines: `keyset.live.test.ts` (Postgres) and `keyset-embedded.test.ts` (PGlite)
|
|
125
|
+
walk four rows inside one millisecond across page boundaries, and assert the plan holds no sort.
|
|
126
|
+
|
|
90
127
|
## Transactions that end badly
|
|
91
128
|
|
|
92
129
|
`As of 2026-10-02`. Postgres aborts the WHOLE transaction on any statement error and answers the
|
|
@@ -105,6 +142,9 @@ that, fire `onCommit`, and store nothing.
|
|
|
105
142
|
| `COMMIT` rejects with a SQLSTATE (a deferred constraint, `40001`) | rolled back: the server's error surfaces, undos run |
|
|
106
143
|
| `COMMIT` rejects with NO SQLSTATE | `X_DB_COMMIT_UNKNOWN`: neither `onCommit` nor `onRollback` runs, and it is never retried |
|
|
107
144
|
| 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 |
|
|
145
|
+
| a statement on a handle whose scope ended, committed or rolled back: an un-awaited promise, a `Promise.all` loser | `X_DB_TRANSACTION_CLOSED` `As of 2026-11-02`, where it fell through to the pool and autocommitted. `await` it, or `outsideTransaction(fn)` |
|
|
146
|
+
| `ctx.signal` aborted with a coded reason (`X_TIMEOUT`, a job's cancellation), at a statement, a nested scope, a `BEGIN` or a `COMMIT` | `As of 2026-11-02`: that reason is rethrown, the same object, and the scope rolls back. An uncoded abort (a client hang-up) changes nothing. `ROLLBACK` and `ROLLBACK TO` are never refused. A statement on the pool, outside any transaction, is not refused here: `@ultimat3/entity`'s writes ask at their own funnel |
|
|
147
|
+
| the same abort after `completeTransaction()` marked the root | statements and `COMMIT` go through: `withIdempotency` marks a reservation it created, because the settle rides that COMMIT |
|
|
108
148
|
|
|
109
149
|
Only a failure carrying a SQLSTATE marks the scope aborted: a refusal raised before the send left
|
|
110
150
|
the transaction untouched.
|
|
@@ -142,6 +182,21 @@ statement's own error. Every caller handles that failure; nothing here swallows
|
|
|
142
182
|
`ensureReadOnlyRole()` — otherwise a table created later is not selectable by the role, and
|
|
143
183
|
layer 1 covers only what existed at grant time.
|
|
144
184
|
|
|
185
|
+
## Diagnostics
|
|
186
|
+
|
|
187
|
+
`As of 2026-11`. `dbDiagnostics({ client?, limit? })` answers facts, never a verdict: how bloated is
|
|
188
|
+
too bloated differs per app. `checkDb()` stays the readiness probe `/readyz` reads.
|
|
189
|
+
|
|
190
|
+
| Rule | |
|
|
191
|
+
|---|---|
|
|
192
|
+
| Read-only, bounded | every section is one catalog statement through `readOnlyQuery()`: `BEGIN READ ONLY`, `DIAGNOSTICS_TIMEOUT_MS` (2 s), a cursor capped at `limit` (default 20). Sequential, so one connection |
|
|
193
|
+
| One failure stays one | a section that throws is `{ available: false, code, reason, fix }`; the other six answer. The single throw is a probe (`select 1`) that cannot connect |
|
|
194
|
+
| No row values | no statement reads a user table. Session SQL (`activity`, `blockers`, `slowQueries`) has every literal replaced by `?` before it leaves (`db-diagnostics-redact.ts`, over the package's one lexer); a `scheme://user:pass@` in a reason is masked |
|
|
195
|
+
| `slowQueries` | reads `pg_stat_statements`; absent (always on PGlite) is `available: false` with the operator's `create extension` + `shared_preload_libraries` fix. The framework never creates an extension |
|
|
196
|
+
| `unusedIndexes` | `idx_scan = 0`, unique and primary excluded; each row carries `statsAgeSeconds` (since `pg_stat_reset`, `null` = never) and `replica` — counters are per server |
|
|
197
|
+
| `activity` | `queryVisible: false` (and `query: null`) for a session another role owns when this one lacks `pg_read_all_stats` |
|
|
198
|
+
| Who sees what | whoever runs it sees the SQL text (redacted) of **every session on the app's database role**, and of every role when that role holds `pg_read_all_stats` or is a superuser: other users' requests, background jobs, `psql` sessions. Literals are `?`, statement shape and identifiers are not. Run it with the credentials of someone allowed to see that |
|
|
199
|
+
|
|
145
200
|
## Index access methods
|
|
146
201
|
|
|
147
202
|
`btree` is the default and is never written out; `gin` is what makes `@>` / `<@` / `&&` / `?` on a
|
|
@@ -189,6 +244,30 @@ entity('runs', {
|
|
|
189
244
|
To drop an index on a hot table: delete it from the entity's `indexes`, `x db gen "drop <name>"`,
|
|
190
245
|
commit what it writes, ship. The drop is its own non-transactional migration.
|
|
191
246
|
|
|
247
|
+
## Reads with their own settings
|
|
248
|
+
|
|
249
|
+
| API | |
|
|
250
|
+
|---|---|
|
|
251
|
+
| `setLocal(tx, settings)` / `readLocal(tx, names)` | the framework's ONE `set_config` caller: `select set_config($1, $2, true)`, parameter-bound and transaction-local, so a value ends with its transaction and a pooled connection hands nothing on (`read-transaction.test.ts` greps the tree for a second one) |
|
|
252
|
+
| `withReadTransaction(fn, { settings, client })` | `withTransaction(fn, { readOnly: true, client })`, then `setLocal`. Inside an open transaction it joins it, applies the settings, runs `fn` and puts the prior values back |
|
|
253
|
+
| `withStatementTimeout(ms, fn, { client })` | one read's own `statement_timeout`, transaction-local. Refused above the bound already in force, and non-positive (`X_DB_STATEMENT_BOUND_INVALID`); firing is `X_DB_STATEMENT_TIMEOUT`. A transaction is the primary's, so a bounded read never reaches a replica |
|
|
254
|
+
| `setTransactionSettings(provider)` | the settings every ROOT transaction sets right after `BEGIN` — row security's tenant. One provider; unset costs one comparison |
|
|
255
|
+
|
|
256
|
+
## Row security (`As of 2026-11`, mechanism)
|
|
257
|
+
|
|
258
|
+
| API | |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `enableRowSecuritySql(table, column, type)` / `dropRowSecuritySql(table)` | `enable` + `force` + one `ultimate_tenant` policy: `using` and `with check` both `column = nullif(current_setting('x.tenant', true), '')::type or current_setting('x.cross', true) = 'on'`. Unset matches nothing |
|
|
261
|
+
| `rowSecurityPlan(plan, input)` / `rowSecurityAfter(input)` | what `x db gen` emits per tenant table when the flag flips, both directions, grants to the app role |
|
|
262
|
+
| `rowSecureClient(client)` | a statement sent outside a transaction while the provider has a tenant is wrapped: `BEGIN [READ ONLY]`, `set_config`, the statement, `COMMIT` |
|
|
263
|
+
| `assertRowSecurityBinds(client)` / `rowSecurityRole(client)` | `X_DB_ROW_SECURITY_BYPASSED` for a superuser, a `bypassrls` role, or the owner of an unforced table — Postgres exempts all three |
|
|
264
|
+
| `ensureRowSecurityRoles(client)` / `assumeRole(client, role)` | `x_app` (`nosuperuser nobypassrls`) and `x_migrator` (`bypassrls`, owns tables), with default privileges from one to the other. `rowSecurityRolesSql()` is the same list as text |
|
|
265
|
+
| `serveAsAppRole(client)` | PGlite's superuser session: the roles, DML on every existing table and sequence to `x_app`, then `set role x_app` |
|
|
266
|
+
| `generateMigration({ rowSecurity: { enabled, tenants } })` | `x db gen`: the policy per tenant table, typed off the column, recorded `rowSecurity: true` on the snapshot; off drops it |
|
|
267
|
+
| `rowSecurityDifferences(entities, snapshot, option)` / `rowSecurityDrift(d)` | the flag against the migrations, `X_DB_ROW_SECURITY_DRIFT`. `checkDrift` raises the same code from `policyNames` (catalog: an enabled AND forced table's policies) |
|
|
268
|
+
|
|
269
|
+
This layer catches forgetting, not hostile SQL in the app's own process: a raw `set_config('x.cross', 'on', true)` lifts it.
|
|
270
|
+
|
|
192
271
|
## Read replicas
|
|
193
272
|
|
|
194
273
|
Opt in twice, and the second one is why it is safe.
|
|
@@ -219,6 +298,7 @@ await withReplicaReads(async () => {
|
|
|
219
298
|
| read-your-writes | one write anywhere in the scope, at any depth, across any `await`, and every later read in it is the primary's |
|
|
220
299
|
| a transaction | always the primary — `reserve()` is delegated there, so BEGIN, the body and COMMIT are one connection on one server. A `readOnly: true` transaction leaves the scope clean |
|
|
221
300
|
| eligibility | `select` / `table` / `values` / a read-only `with`, minus locking reads, `select … into` and the functions a standby answers instead of refusing (`pg_advisory_lock`, `set_config`, `nextval`). Everything else is the primary's |
|
|
301
|
+
| across requests (`As of 2026-11`) | `withReplicaReads(fn, { minPosition })`: a read goes to the standby only once its `pg_last_wal_replay_lsn()` has passed `minPosition` — sampled once per `positionCacheMs` (50 ms) per client, checked BEFORE the breaker's `admit()` so a read sent to the primary for being behind never spends the half-open probe. Unknown or `null` is the primary. `parsePosition` / `formatPosition` / `primaryPosition` (`replica-position.ts`); `ReplicatedClient.primaryPosition()` reads the primary's WAL position. The boot carries it as `__Host-x-pos` + `x-ultimate-position` |
|
|
222
302
|
| a replica that fails | the statement is re-run on the primary — exactly-once, because only plain reads are sent there and a `25006` refusal never executed. Three failures in a row park it for ten seconds |
|
|
223
303
|
| observability | `client.stats` (`replica`, `primary`, `fallbacks`, `parked`), and a `db.replica_fallback` warning per fallback |
|
|
224
304
|
|
|
@@ -226,9 +306,7 @@ await withReplicaReads(async () => {
|
|
|
226
306
|
a classifier that cannot be complete; pointed at a writable node a misroute becomes a write on the
|
|
227
307
|
wrong server, silently.
|
|
228
308
|
|
|
229
|
-
**
|
|
230
|
-
request in it is the app's call — or one line in the HTTP pipeline — so until that lands no
|
|
231
|
-
production traffic is routed.
|
|
309
|
+
**The boot opens the scope per request** (`@ultimat3/cli`'s `runtime-replica-position.ts`) when `DATABASE_REPLICA_URL` is set.
|
|
232
310
|
|
|
233
311
|
## The drift contract
|
|
234
312
|
|
|
@@ -302,6 +380,15 @@ the exit code — and a deploy that rolled on past a schema nobody can reconstru
|
|
|
302
380
|
drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
|
|
303
381
|
detector (`checkSourceDrift`), which needs no database and never calls this.
|
|
304
382
|
|
|
383
|
+
## Views
|
|
384
|
+
|
|
385
|
+
`EntityDescriptionLike.view` (from `entity({ view: { sql, materialized? } })`) is emitted by
|
|
386
|
+
`generate-view.ts`, never by a table arm: `create [materialized] view` after every table, its
|
|
387
|
+
indexes with it when materialized, recorded on the snapshot as `views: [{ name, materialized?,
|
|
388
|
+
create }]`. Anything that moved — a query, an index, a retype, drop column or drop table under it —
|
|
389
|
+
drops every recorded view first and recreates every declared one last. The catalog reading never
|
|
390
|
+
fills `views`, so drift never reports one.
|
|
391
|
+
|
|
305
392
|
## Append-only tables
|
|
306
393
|
|
|
307
394
|
`EntityDescriptionLike.appendOnly` (from `entity({ appendOnly: true })`) is a trigger, written by
|
|
@@ -354,8 +441,13 @@ object kind, numbered in the order a database is built in, one file per named ob
|
|
|
354
441
|
- A name becomes a file name with everything outside `[A-Za-z0-9_-]` percent-encoded.
|
|
355
442
|
- A file over 500 lines (`SCHEMA_DUMP_MAX_LINES`, the `filesize` ceiling) is split `<name>.1.sql`, `<name>.2.sql`;
|
|
356
443
|
`loadSchemaDump()` joins the parts in numeric order.
|
|
357
|
-
-
|
|
358
|
-
|
|
444
|
+
- A partitioned table is rendered with `partition by`, each partition `partition of … for values` in
|
|
445
|
+
its root's file, parent first (`partition-order.ts`); a partition's attached indexes, cloned keys
|
|
446
|
+
and cloned triggers are not, the parent's recreate them. `As of 2026-11-06`.
|
|
447
|
+
- Row security is `10_policies/<table>.sql`: `enable`, `force`, then each policy with every clause
|
|
448
|
+
(`catalog-policies.ts`, `schema-dump-policies.ts`). `As of 2026-11-06`.
|
|
449
|
+
- What cannot be spelled — inheritance children, foreign tables, composite and range types,
|
|
450
|
+
aggregates, a policy on a relation the dump does not create, rules, extended statistics,
|
|
359
451
|
a column's non-default `storage`, a materialized view created `with no data`, and a trigger on
|
|
360
452
|
a relation the dump does not create — is named in `unrendered.sql` as comments.
|
|
361
453
|
- A generated column is spelled `stored` or `virtual` (Postgres 18), as `attgenerated` says.
|
|
@@ -437,13 +529,47 @@ one role that has to outlive it. Before this, `set` replaced the whole value and
|
|
|
437
529
|
with a non-zero timeout, so a `search_path` survived on `migrate` and vanished on `web` — the role
|
|
438
530
|
that runs the migrations and the role that serves the traffic reading different schemas.
|
|
439
531
|
|
|
532
|
+
**`DATABASE_POOLER=session|transaction` names a pooler in the path** (`As of 2026-11-02`):
|
|
533
|
+
`postgresClient({ env })` reads it, with `ROLE` and, absent a `url`, `DATABASE_URL`, from that one
|
|
534
|
+
environment (`process.env` when absent); `client.pooler` is the answer everything after asks.
|
|
535
|
+
Behind a pooler the unanswered-statement deadline grows by `poolerWaitMs` (default
|
|
536
|
+
`POOLER_WAIT_MS`, 120 s, PgBouncer's `query_wait_timeout`): the server's bound starts only once
|
|
537
|
+
the pooler assigns a backend, so a saturated pooler is not a dead path. Set, neither bound is sent as `options`, the operator's own
|
|
538
|
+
stay and an empty one is not emitted; the bounds become `alter role`
|
|
539
|
+
([`docs/ops/04-datastores.md`](../../docs/ops/04-datastores.md#pooling-and-the-trap-under-it)).
|
|
540
|
+
`statementBound(client)` reads both as the session does, `{ applied, expected }` per setting, in ms.
|
|
541
|
+
|
|
542
|
+
**`idleInTransactionMs`** (`As of 2026-11-02`) is sent as `idle_in_transaction_session_timeout`
|
|
543
|
+
beside the statement bound: `web`, `sync` 30 s, `worker`, `scheduler` 120 s, `migrate`, `replicator`
|
|
544
|
+
0. Past it the server closes the transaction, `X_DB_STATEMENT_FAILED` with `sqlState: '25P03'`.
|
|
545
|
+
|
|
440
546
|
**`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
|
|
441
547
|
can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
|
|
442
548
|
value that is not a positive integer refuses at boot rather than falling back.
|
|
443
549
|
|
|
444
|
-
**`acquireTimeoutMs` bounds `reserve()
|
|
445
|
-
|
|
446
|
-
|
|
550
|
+
**`acquireTimeoutMs` bounds a plain statement and `reserve()` alike** (`As of 2026-11-02`, for the
|
|
551
|
+
statement), because queueing turns exhaustion into a hang: `/readyz`'s `select 1` joins the same
|
|
552
|
+
queue, the kubelet kills the pod, and the replacement inherits the same saturated database. 0 waits,
|
|
553
|
+
which is what a run-once role wants. `max` is one count in front of `Bun.SQL`, FIFO: a statement holds
|
|
554
|
+
a slot until it settles, a pin until it is released, and a statement on a pin takes none.
|
|
555
|
+
|
|
556
|
+
**A statement nobody answers replaces the pool** (`As of 2026-11-02`). `statement_timeout` is the
|
|
557
|
+
server's, so it cannot fire when the server or the path is gone, and the kernel's retransmit window is
|
|
558
|
+
about 15 minutes. A statement unanswered past `statementTimeoutMs` + 5 s is `X_DB_UNAVAILABLE`,
|
|
559
|
+
its cause naming the bound and the statement; an autocommit write may have landed. The old pool closes
|
|
560
|
+
in the background (`db.pool.retired`, at error), the next statement opens a new one, a statement still
|
|
561
|
+
queued moves to it unsent, and a `LISTEN` is dialled again, `onListening` firing as on any re-dial.
|
|
562
|
+
`statementTimeoutMs: 0` has no deadline. **A statement on a pin — a transaction's — is bound the same
|
|
563
|
+
way** (`As of 2026-11-06`), against the `statement_timeout` in force on that connection: the role's,
|
|
564
|
+
until a `set_config('statement_timeout', …)` or `SET [LOCAL] statement_timeout` it answered moves it
|
|
565
|
+
(`0`: no deadline), and the role's again after `COMMIT`/`ROLLBACK`. After a miss the pin sends nothing
|
|
566
|
+
more: its `ROLLBACK` is refused `X_DB_UNAVAILABLE` at once rather than waiting out a second bound, and
|
|
567
|
+
a missed `COMMIT` is `X_DB_COMMIT_UNKNOWN`. `X_DB_UNAVAILABLE` is unclassified for retry, so a job worker re-runs the
|
|
568
|
+
attempt: a write that landed runs again, at least once, as every job. `reserve()`'s two waits share one
|
|
569
|
+
`acquireTimeoutMs`. The cost is one unref'd 1 s
|
|
570
|
+
timer per pool while statements are in flight, and a set insert and delete per statement: measured
|
|
571
|
+
2026-11-02 on Postgres, 20,000 sends a side, `max: 4`, p50 216-268 µs against 227-258 µs before, inside
|
|
572
|
+
the noise.
|
|
447
573
|
|
|
448
574
|
**`lockTimeoutMs` bounds a *wait*, never the work.** `alter table … add column` takes `ACCESS
|
|
449
575
|
EXCLUSIVE`; a long `SELECT` holding `ACCESS SHARE` makes it queue, and Postgres' lock queue is FIFO,
|
|
@@ -481,13 +607,16 @@ await subscription.unlisten();
|
|
|
481
607
|
|
|
482
608
|
## Migrations
|
|
483
609
|
|
|
610
|
+
`As of 2026-11-02`: **a foreign key on a live table** is written to the deferred step (`not valid`, then `validate constraint`, an old key held until the contract phase), **a CHECK** `not valid` in the transaction with its validate deferred, **a column drop** takes two releases (`retired`, `X_MIGRATION_ROLLING_UNSAFE`), and **`migrate()` / `rollback()` send it again** (a transactional step whole, a `-- transaction: false` step per statement) when `lock_timeout` fires (`lockRetries`, default 5). The user-facing page: `wiki/Migrations-And-Backfills.md`.
|
|
611
|
+
|
|
484
612
|
`migrate()` takes the advisory lock by **polling** `pg_try_advisory_lock(4919202607)` every 500ms
|
|
485
613
|
until `lockWaitMs` (default 60s) and then throwing `X_MIGRATE_CONCURRENT` (`As of 2026-08`).
|
|
486
614
|
`pg_advisory_lock` has no timeout, and a predecessor OOM-killed on a partition keeps its backend —
|
|
487
615
|
and the lock — for hours, so a deploy hook sat inside one statement printing nothing while
|
|
488
616
|
`helm upgrade --wait` blocked and `backoffLimit` never fired, because a job that never finishes
|
|
489
617
|
never fails. It then ensures `x_migrations`
|
|
490
|
-
(`id, name, checksum, applied_at, app_version, duration_ms
|
|
618
|
+
(`id, name, checksum, applied_at, app_version, duration_ms`, and `destructive` — added
|
|
619
|
+
`if not exists`, written at apply from `isDestructiveMigration(up)`), audits, then applies each pending
|
|
491
620
|
migration inside its own transaction — or, for one marked `-- transaction: false`, one
|
|
492
621
|
autocommitted statement at a time ([Indexes built concurrently](#indexes-built-concurrently-and-every-index-drop)).
|
|
493
622
|
It refuses **before applying anything** when:
|
|
@@ -500,7 +629,12 @@ It refuses **before applying anything** when:
|
|
|
500
629
|
sorts after the newest migration this build ships, and this build has nothing left to apply, the
|
|
501
630
|
ledger is an older image rolled back onto a newer build's schema. Nothing is applied, the rows are
|
|
502
631
|
reported in `ahead` and logged as `ultimate migrate ledger ahead of build`; any other unknown row is
|
|
503
|
-
still `X_MIGRATION_CONFLICT`.
|
|
632
|
+
still `X_MIGRATION_CONFLICT`. A row ahead whose `destructive` bit is `true` refuses that rollback
|
|
633
|
+
(`X_MIGRATION_ROLLBACK_DESTRUCTIVE`, `As of 2026-11`) unless `ROLLBACK_ALLOW_DESTRUCTIVE` names it
|
|
634
|
+
(comma-separated ids); a `null` bit — a row applied before the ledger kept one — still warns.
|
|
635
|
+
`readLedger()` reads the bit through the row's JSON, so a ledger an older release created answers
|
|
636
|
+
`null` rather than `42703`. The refusal is the ROLLED-BACK build's code: it protects from the
|
|
637
|
+
release after the one that ships it.
|
|
504
638
|
|
|
505
639
|
Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion, ahead: [id] }`.
|
|
506
640
|
|
|
@@ -594,17 +728,21 @@ job the moment Postgres fails over.
|
|
|
594
728
|
| `X_DB_COMMIT_UNKNOWN` | `COMMIT` was sent and the connection failed before the answer: durable or rolled back, unknowable from here |
|
|
595
729
|
| `X_DB_SIBLING_SCOPE_TIMEOUT` | a nested `withTransaction` waited past `siblingWaitMs` for a sibling scope that never finished |
|
|
596
730
|
| `X_DB_STATEMENT_TIMEOUT` | `57014` — the statement ran past `statement_timeout` |
|
|
597
|
-
| `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
|
|
598
|
-
| `
|
|
731
|
+
| `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got; `migrate()` raises it only once `lockRetries` are spent |
|
|
732
|
+
| `X_MIGRATION_ROLLING_UNSAFE` | a drop or rename the previous release would still read, from `x db gen`, the `drift` rail past `rollingSince`, or `migrate()` with a retire and its drop both pending in production |
|
|
733
|
+
| `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or a statement or `reserve()` past `acquireTimeoutMs` |
|
|
599
734
|
| `X_DB_DRIFT` | live schema differs from migrations |
|
|
600
|
-
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch — never for rows that are all newer than the build (a rollback) |
|
|
735
|
+
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch — never for rows that are all newer than the build (a rollback). A mismatch's fix restores the file: `git log -- packages/db/migrations/<id>.sql`, then `git checkout <rev> -- …` the revision whose checksum the ledger names, then `x db gen "<change>"` |
|
|
601
736
|
| `X_MIGRATE_CONCURRENT` | another migrator still held the lock when the wait ran out |
|
|
602
737
|
| `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
|
|
603
738
|
| `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
|
|
604
739
|
| `X_BRANCH_EXISTS` | branch database already exists (or is the connected one) |
|
|
605
740
|
| `X_SCHEMA_DUMP_DRIFT` | the committed schema dump is not what the migrations produce, or does not load back |
|
|
606
741
|
| `X_NOT_IMPLEMENTED` | branching an in-memory PGlite — a copy needs a directory |
|
|
607
|
-
| `X_ENV_MISSING` | core's — `DATABASE_POOL_MAX` is set to something that is not a positive integer |
|
|
742
|
+
| `X_ENV_MISSING` | core's — `DATABASE_POOL_MAX` is set to something that is not a positive integer, or `DATABASE_POOLER` to something that is neither `session` nor `transaction` |
|
|
743
|
+
| `X_DB_POOLER_OPTIONS_REFUSED` | `08P01` with `unsupported startup parameter`: a pooler refused the libpq `options`. `DATABASE_POOLER`, then `alter role` |
|
|
744
|
+
| `X_DB_POOLER_SESSION_REQUIRED` | `migrate` or `replicator` under `DATABASE_POOLER=transaction`, before a socket |
|
|
745
|
+
| `X_DB_STATEMENT_BOUND_LOST` | a report, never thrown by a statement: `lostStatementBound(statementBound(client), role, pooler)` |
|
|
608
746
|
|
|
609
747
|
```bash
|
|
610
748
|
x db migrate --json
|
|
@@ -628,3 +766,13 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
628
766
|
| Class | Code | Declared in |
|
|
629
767
|
|---|---|---|
|
|
630
768
|
| `DbError` | any `DbErrorCode` — `DB_ERROR_CODES`, including the SQLSTATE codes a driver error maps to (`DB_SQLSTATE_CODES`) | `src/errors.ts` |
|
|
769
|
+
|
|
770
|
+
## Supporting exports
|
|
771
|
+
|
|
772
|
+
`As of 2026-11-02`.
|
|
773
|
+
|
|
774
|
+
| Name | What |
|
|
775
|
+
|---|---|
|
|
776
|
+
| `unretiredColumnsIn(up)` | the columns this `up` takes back, as `columnKey`: a retire before it earns no later drop |
|
|
777
|
+
| `RollingUnsafeKind` | `'drop-column'`, `'rename-column'` or `'rename-table'`: the closed list |
|
|
778
|
+
| `RollingUnsafeStatement` | one finding of `rollingUnsafeStatements`, carrying its `kind` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"postgres",
|
|
10
|
+
"migrations",
|
|
11
|
+
"database",
|
|
12
|
+
"sql"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"repository": {
|
|
@@ -15,6 +25,7 @@
|
|
|
15
25
|
},
|
|
16
26
|
"exports": {
|
|
17
27
|
".": "./src/index.ts",
|
|
28
|
+
"./diagnostics": "./src/diagnostics-entry.ts",
|
|
18
29
|
"./schema-dump": "./src/schema-dump-entry.ts"
|
|
19
30
|
},
|
|
20
31
|
"files": [
|
|
@@ -33,7 +44,7 @@
|
|
|
33
44
|
"test": "bun test"
|
|
34
45
|
},
|
|
35
46
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
47
|
+
"@ultimat3/core": "30.1.0"
|
|
37
48
|
},
|
|
38
49
|
"peerDependencies": {
|
|
39
50
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/app-relation.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: name the relations in a schema that are NOT app schema, so introspection
|
|
2
|
-
// never puts them in a snapshot and drift can never report one.
|
|
3
|
-
// question: an object an extension owns,
|
|
2
|
+
// never puts them in a snapshot and drift can never report one. Three disqualifications, one
|
|
3
|
+
// question: an object an extension owns, an object that is not a table at all, and a partition.
|
|
4
4
|
|
|
5
5
|
import type { DbClient } from './client';
|
|
6
6
|
import { sql } from './sql';
|
|
@@ -27,6 +27,10 @@ interface NonAppRelationRow {
|
|
|
27
27
|
* `x db gen` diff emits `create view` and no snapshot records one, so counting them as app schema
|
|
28
28
|
* can only ever produce a finding an author has no way to clear.
|
|
29
29
|
*
|
|
30
|
+
* **A partition is Postgres' (`relispartition`)**, a sub-partition included: the PARENT is what a
|
|
31
|
+
* migration declares, and each month's child is created by the app's own task. Counted as app
|
|
32
|
+
* schema, every partition was `unexpected-table` with `drop table` as its fix — a month of rows.
|
|
33
|
+
*
|
|
30
34
|
* Excluding by NAME is safe because `pg_class` names are unique within a namespace: a name on this
|
|
31
35
|
* list cannot also be an app table in the same schema.
|
|
32
36
|
*/
|
|
@@ -42,6 +46,7 @@ export async function nonAppRelations(
|
|
|
42
46
|
and c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
43
47
|
and (
|
|
44
48
|
c.relkind in ('v', 'm', 'f')
|
|
49
|
+
or c.relispartition
|
|
45
50
|
or exists (
|
|
46
51
|
select 1
|
|
47
52
|
from pg_depend d
|
package/src/catalog-fold.ts
CHANGED
|
@@ -112,5 +112,10 @@ export function tableOf(
|
|
|
112
112
|
}),
|
|
113
113
|
),
|
|
114
114
|
replicaIdentity: replicaIdentityOf(row),
|
|
115
|
+
// Absent, never `null`, on an ordinary table: its description is what it was before these.
|
|
116
|
+
...(row.partition_key === null ? {} : { partitionBy: row.partition_key }),
|
|
117
|
+
...(row.partition_of === null || row.partition_bound === null
|
|
118
|
+
? {}
|
|
119
|
+
: { partitionOf: { table: row.partition_of, bound: row.partition_bound } }),
|
|
115
120
|
};
|
|
116
121
|
}
|
package/src/catalog-objects.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ViewRow {
|
|
|
34
34
|
readonly kind: string;
|
|
35
35
|
readonly options: string | null;
|
|
36
36
|
readonly definition: string;
|
|
37
|
+
/** The output columns, in order — what a view entity's `columns` must name (`X_ENTITY_VIEW_DRIFT`). */
|
|
38
|
+
readonly columns: readonly string[];
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export interface FunctionRow {
|
|
@@ -102,7 +104,12 @@ export const viewRows = (client: DbClient, schema: string): Promise<readonly Vie
|
|
|
102
104
|
c.relname as name,
|
|
103
105
|
c.relkind as kind,
|
|
104
106
|
array_to_string(c.reloptions, ', ') as options,
|
|
105
|
-
pg_get_viewdef(c.oid) as definition
|
|
107
|
+
pg_get_viewdef(c.oid) as definition,
|
|
108
|
+
array(
|
|
109
|
+
select a.attname::text from pg_attribute a
|
|
110
|
+
where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
|
111
|
+
order by a.attnum
|
|
112
|
+
) as columns
|
|
106
113
|
from pg_class c
|
|
107
114
|
join pg_namespace n on n.oid = c.relnamespace
|
|
108
115
|
where n.nspname = ${schema}
|
|
@@ -126,6 +133,8 @@ export const functionRows = (client: DbClient, schema: string): Promise<readonly
|
|
|
126
133
|
|
|
127
134
|
/**
|
|
128
135
|
* `not tgisinternal`: a foreign key's own enforcement triggers are the constraint's, not the app's.
|
|
136
|
+
* `tgparentid = 0`: a partition's clone of its parent's trigger (Postgres 13+, `tgisinternal`
|
|
137
|
+
* false — measured on 18) is recreated by the parent's own `create trigger`.
|
|
129
138
|
* A trigger on an extension-owned table is skipped with its table: the dump creates no such table,
|
|
130
139
|
* so its `09_triggers/` file could never load.
|
|
131
140
|
*/
|
|
@@ -141,6 +150,7 @@ export const triggerRows = (client: DbClient, schema: string): Promise<readonly
|
|
|
141
150
|
join pg_namespace n on n.oid = c.relnamespace
|
|
142
151
|
where n.nspname = ${schema}
|
|
143
152
|
and not t.tgisinternal
|
|
153
|
+
and t.tgparentid = 0
|
|
144
154
|
and ${notExtensionOwned('pg_class', 'c.oid')}
|
|
145
155
|
`);
|
|
146
156
|
|
|
@@ -149,11 +159,11 @@ export const triggerRows = (client: DbClient, schema: string): Promise<readonly
|
|
|
149
159
|
* here is a kind the dump admits it does not carry, and a kind rendered later leaves this list in
|
|
150
160
|
* the same diff.
|
|
151
161
|
*
|
|
152
|
-
* The last
|
|
153
|
-
* dump and this list: `create statistics`,
|
|
154
|
-
*
|
|
155
|
-
* view
|
|
156
|
-
*
|
|
162
|
+
* The last three are facts ABOUT an object the dump does render, and each was absent from both the
|
|
163
|
+
* dump and this list: `create statistics`, a column whose `set storage` departs from its type's
|
|
164
|
+
* own, and a materialized view created `with no data` — which the dump's `create materialized
|
|
165
|
+
* view` would populate. A load-equals-replay check cannot see any of them, because both sides are
|
|
166
|
+
* this same reading. Row security and its policies left this list for `catalog-policies.ts`.
|
|
157
167
|
*
|
|
158
168
|
* A trigger on a relation the dump does not create is the one kind NOT read here: which relations
|
|
159
169
|
* are rendered is the fold's answer, so `introspectCatalog` names those itself.
|
|
@@ -166,20 +176,18 @@ export const unrenderedRows = (
|
|
|
166
176
|
select kind, name, table_name from (
|
|
167
177
|
select
|
|
168
178
|
case
|
|
169
|
-
when c.relkind = 'p' then 'partitioned table'
|
|
170
179
|
when c.relkind = 'f' then 'foreign table'
|
|
171
180
|
when c.relkind = 'c' then 'composite type'
|
|
172
|
-
else '
|
|
181
|
+
else 'inheritance child'
|
|
173
182
|
end as kind,
|
|
174
183
|
c.relname as name,
|
|
175
184
|
null::text as table_name,
|
|
176
185
|
c.relnamespace as namespace
|
|
177
186
|
from pg_class c
|
|
178
187
|
where (
|
|
179
|
-
c.relkind in ('
|
|
180
|
-
or (c.relkind = 'r' and
|
|
181
|
-
|
|
182
|
-
))
|
|
188
|
+
c.relkind in ('f', 'c')
|
|
189
|
+
or (c.relkind = 'r' and not c.relispartition
|
|
190
|
+
and exists (select 1 from pg_inherits h where h.inhrelid = c.oid))
|
|
183
191
|
)
|
|
184
192
|
and ${notExtensionOwned('pg_class', 'c.oid')}
|
|
185
193
|
union all
|
|
@@ -191,14 +199,6 @@ export const unrenderedRows = (
|
|
|
191
199
|
from pg_proc p
|
|
192
200
|
where p.prokind = 'a' and ${notExtensionOwned('pg_proc', 'p.oid')}
|
|
193
201
|
union all
|
|
194
|
-
select 'row security policy', pol.polname, c.relname, c.relnamespace
|
|
195
|
-
from pg_policy pol
|
|
196
|
-
join pg_class c on c.oid = pol.polrelid
|
|
197
|
-
union all
|
|
198
|
-
select 'row security', c.relname, c.relname, c.relnamespace
|
|
199
|
-
from pg_class c
|
|
200
|
-
where c.relrowsecurity
|
|
201
|
-
union all
|
|
202
202
|
select 'rule', r.rulename, c.relname, c.relnamespace
|
|
203
203
|
from pg_rewrite r
|
|
204
204
|
join pg_class c on c.oid = r.ev_class
|
|
@@ -208,10 +208,6 @@ export const unrenderedRows = (
|
|
|
208
208
|
from pg_statistic_ext s
|
|
209
209
|
join pg_class c on c.oid = s.stxrelid
|
|
210
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
211
|
select 'column storage', a.attname, c.relname, c.relnamespace
|
|
216
212
|
from pg_attribute a
|
|
217
213
|
join pg_class c on c.oid = a.attrelid
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Single responsibility: row security as the catalog holds it — which tables enable or force it,
|
|
2
|
+
// and every policy on them, clause by clause — read and folded for the schema dump. A dump without
|
|
3
|
+
// them loads a test database that refuses nothing, so a policy would be untested and prod-only.
|
|
4
|
+
|
|
5
|
+
import type { CatalogPolicy, CatalogRowSecurity } from './catalog';
|
|
6
|
+
import { by } from './catalog-fold';
|
|
7
|
+
import { notExtensionOwned } from './catalog-relations';
|
|
8
|
+
import type { DbClient } from './client';
|
|
9
|
+
import { sql } from './sql';
|
|
10
|
+
|
|
11
|
+
export interface RowSecurityRow {
|
|
12
|
+
readonly table_name: string;
|
|
13
|
+
readonly enabled: boolean;
|
|
14
|
+
readonly forced: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PolicyRow {
|
|
18
|
+
readonly table_name: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly permissive: boolean;
|
|
21
|
+
readonly command: string;
|
|
22
|
+
readonly roles: readonly string[];
|
|
23
|
+
readonly using: string | null;
|
|
24
|
+
readonly with_check: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const rowSecurityRows = (
|
|
28
|
+
client: DbClient,
|
|
29
|
+
schema: string,
|
|
30
|
+
): Promise<readonly RowSecurityRow[]> =>
|
|
31
|
+
client.query<RowSecurityRow>(sql`
|
|
32
|
+
select c.relname as table_name, c.relrowsecurity as enabled, c.relforcerowsecurity as forced
|
|
33
|
+
from pg_class c
|
|
34
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
35
|
+
where n.nspname = ${schema} and (c.relrowsecurity or c.relforcerowsecurity)
|
|
36
|
+
and ${notExtensionOwned('pg_class', 'c.oid')}
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
/** `polroles` holds `0` for `public`, which no `pg_roles` row answers; each name is resolved here. */
|
|
40
|
+
export const policyRows = (client: DbClient, schema: string): Promise<readonly PolicyRow[]> =>
|
|
41
|
+
client.query<PolicyRow>(sql`
|
|
42
|
+
select
|
|
43
|
+
c.relname as table_name,
|
|
44
|
+
p.polname as name,
|
|
45
|
+
p.polpermissive as permissive,
|
|
46
|
+
case p.polcmd when 'r' then 'select' when 'a' then 'insert' when 'w' then 'update'
|
|
47
|
+
when 'd' then 'delete' else 'all' end as command,
|
|
48
|
+
array(
|
|
49
|
+
select case when r.oid = 0 then 'public' else (select rolname from pg_roles where oid = r.oid) end
|
|
50
|
+
from unnest(p.polroles) as r(oid)
|
|
51
|
+
) as roles,
|
|
52
|
+
pg_get_expr(p.polqual, p.polrelid) as using,
|
|
53
|
+
pg_get_expr(p.polwithcheck, p.polrelid) as with_check
|
|
54
|
+
from pg_policy p
|
|
55
|
+
join pg_class c on c.oid = p.polrelid
|
|
56
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
57
|
+
where n.nspname = ${schema} and ${notExtensionOwned('pg_class', 'c.oid')}
|
|
58
|
+
`);
|
|
59
|
+
|
|
60
|
+
export const rowSecurityOf = (rows: readonly RowSecurityRow[]): readonly CatalogRowSecurity[] =>
|
|
61
|
+
rows
|
|
62
|
+
.map((row) => ({ table: row.table_name, enabled: row.enabled, forced: row.forced }))
|
|
63
|
+
.sort(by((entry) => entry.table));
|
|
64
|
+
|
|
65
|
+
export const policiesOf = (rows: readonly PolicyRow[]): readonly CatalogPolicy[] =>
|
|
66
|
+
rows
|
|
67
|
+
.map((row) => ({
|
|
68
|
+
table: row.table_name,
|
|
69
|
+
name: row.name,
|
|
70
|
+
permissive: row.permissive,
|
|
71
|
+
command: row.command,
|
|
72
|
+
roles: [...row.roles].sort(by((role) => role)),
|
|
73
|
+
using: row.using,
|
|
74
|
+
withCheck: row.with_check,
|
|
75
|
+
}))
|
|
76
|
+
.sort(
|
|
77
|
+
by(
|
|
78
|
+
(policy) => policy.table,
|
|
79
|
+
(policy) => policy.name,
|
|
80
|
+
),
|
|
81
|
+
);
|