@ultimat3/db 24.0.0 → 25.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 +28 -27
- package/README.md +49 -20
- package/package.json +3 -3
- package/src/client.ts +2 -2
- package/src/column-alter.ts +9 -3
- package/src/db-executor.ts +24 -0
- package/src/default-client.ts +4 -4
- package/src/dependent-view.ts +6 -6
- package/src/destructive.ts +1 -1
- package/src/drift-append-only.ts +53 -0
- package/src/drift-findings.ts +4 -2
- package/src/drift.ts +14 -6
- package/src/entity-shape.ts +5 -0
- package/src/errors.ts +6 -1
- package/src/fake.ts +1 -1
- package/src/generate-append-only.ts +146 -0
- package/src/generate.ts +16 -7
- package/src/index.ts +8 -6
- package/src/introspect-catalog.ts +1 -1
- package/src/introspect.ts +47 -4
- package/src/migrate-rollback.ts +44 -0
- package/src/migrate.ts +44 -154
- package/src/migration-ledger.ts +167 -0
- package/src/pglite-branch.ts +1 -1
- package/src/pglite.ts +3 -3
- package/src/pool-profile.ts +1 -1
- package/src/primary-key.ts +34 -4
- package/src/retype-dependents.ts +3 -3
- package/src/snapshot-parse.ts +9 -3
- package/src/sql-scan.ts +37 -12
- package/src/drift-fixtures.ts +0 -23
- package/src/fake-pglite.ts +0 -32
- package/src/fake-reservable.ts +0 -50
package/CLAUDE.md
CHANGED
|
@@ -100,14 +100,14 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
100
100
|
- **`expected-loop.ts` is the ONLY suppression**: `expectedQueryLoop(reason, fn)`, innermost reason,
|
|
101
101
|
blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement.
|
|
102
102
|
The framework's own loops declare themselves (`migrate()`, `rollback()`, admin's `search.ts`).
|
|
103
|
-
- `@ultimat3/jobs` never imports this package; its statements pass the observer
|
|
104
|
-
`
|
|
103
|
+
- `@ultimat3/jobs` never imports this package; its statements pass the observer through
|
|
104
|
+
`dbExecutor` (`db-executor.ts`, the ONE `PgExecutor` builder; the CLI binds it), unattributed.
|
|
105
105
|
|
|
106
106
|
## Migrations
|
|
107
107
|
|
|
108
108
|
- **The migration lock is polled** (`pg_try_advisory_lock` every `MIGRATION_LOCK_POLL_MS` until
|
|
109
109
|
`MIGRATION_LOCK_WAIT_MS`, then `X_MIGRATE_CONCURRENT`), declared with `expectedQueryLoop`;
|
|
110
|
-
`
|
|
110
|
+
`recordingClient` stubs the lock as `locked: true`.
|
|
111
111
|
- **`lock_timeout` is the migration's** (`SET LOCAL` inside each migration's transaction, from the
|
|
112
112
|
`migrate` profile's 3 s).
|
|
113
113
|
- **The advisory lock is held by one pinned session, and `migrate()`/`rollback()` run every statement
|
|
@@ -141,7 +141,7 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
141
141
|
declared is CLOSED, live is OPEN (`indexMethodOf` passes the catalog through; `declaredMethod`
|
|
142
142
|
refuses); absent is `btree` through one function; `snapshotOf` records `using` only when declared;
|
|
143
143
|
`indexMethodSql` re-derives the literal (`X_SQL_UNSAFE` default); a unique or ordered GIN is
|
|
144
|
-
`X_INVARIANT`. `
|
|
144
|
+
`X_INVARIANT`. `introspectSchema()` reads `pg_am` (`introspect-embedded.test.ts`).
|
|
145
145
|
- **`index-plan.ts` walks both directions** (declared first, removed last). `dropRecordedIndex` emits
|
|
146
146
|
`alter table … drop constraint if exists` then `drop index` for a shape a constraint could back
|
|
147
147
|
(`mayBeConstraintBacked`); four names are skipped (primary, moved aside, rebuilt, over a dropped
|
|
@@ -170,29 +170,30 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
170
170
|
in `preAlters` at the top of `up`, re-adding is `foreignKeyPlan`'s, both `breaksOn` ends are needed
|
|
171
171
|
(`generate-retype-key.live.test.ts`). `sql-type.ts` reads `SQL_TYPES` with `Object.hasOwn`.
|
|
172
172
|
- **A generated column** (`generated-column.ts`): the clause right after the type; generated-and-
|
|
173
|
-
defaulted refused; an expression change is `set expression as (…)`; a retype
|
|
174
|
-
NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
defaulted refused; an expression change is `set expression as (…)`; a retype has no `using`; a NOT
|
|
174
|
+
NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds
|
|
175
|
+
(`rebuilt`) and moves dependents aside, its own type change does not. `introspectSchema` never reads
|
|
176
|
+
`generation_expression`.
|
|
177
177
|
`generate-generated-{column,rebuild}.live.test.ts`.
|
|
178
|
-
- **`REPLICA IDENTITY FULL` is
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
- **`REPLICA IDENTITY FULL` is a PARAMETER** (`GenerateOptions.replicaIdentityFull`, from the CLI's
|
|
179
|
+
`db-generate.ts`; `replica-identity.ts`): recorded `true` or absent, as a union; last in `up`; an
|
|
180
|
+
undeclared name skipped; `down` reverts it unless this migration created the table.
|
|
181
|
+
- **`appendOnly: true` is a trigger** (`generate-append-only.ts`): one `create or replace` function
|
|
182
|
+
(SQLSTATE `23001`, message leads `X_ENTITY_APPEND_ONLY`) and a fixed-name `ultimate_append_only`
|
|
183
|
+
`before update or delete … for each row`; recorded `true` or absent; no `truncate`. Drift:
|
|
184
|
+
enabled `triggerNames` (`drift-append-only.ts`, `X_APPEND_ONLY_TRIGGER_MISSING`).
|
|
182
185
|
- **A foreign key is `alter table … add constraint`**, collected into a bucket merged after every table
|
|
183
186
|
statement (`foreign-key-plan.ts`); **dropping a table has its own bucket emitted BEFORE the table
|
|
184
187
|
statements** (`preDrops`), ordered children-first by `drop-order.ts`, which breaks a two-table cycle
|
|
185
188
|
by dropping one key first. `foreignKeyPlan` walks both directions, drops the name the previous
|
|
186
189
|
snapshot recorded, and rebuilds a key whose `onDelete` moved. **`on delete` reaches the SQL**
|
|
187
190
|
(`onDeleteRule`, `foreign-key.ts`; an unknown rule is `X_INVARIANT`).
|
|
188
|
-
- **`entity-shape.ts` holds the
|
|
189
|
-
- **`snapshot-json.ts` writes
|
|
190
|
-
|
|
191
|
-
- **`declaredSchema()` answers the NEWEST migration's snapshot or `undefined
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
migration's files FIRST and then run `x db gen`. Both commands are screened (`shellInertIdentifier`
|
|
195
|
-
/ `renderFixShellArg`), degrading the whole line to prose.
|
|
191
|
+
- **`entity-shape.ts` holds the `*Like` interfaces** (every later field optional).
|
|
192
|
+
- **`snapshot-json.ts` writes a fixed point of Biome** (an array collapses when it fits at `<= 100`
|
|
193
|
+
with its trailing comma); `snapshot-json.test.ts` runs `biome format`.
|
|
194
|
+
- **`declaredSchema()` answers the NEWEST migration's snapshot or `undefined`** (`checkDrift`:
|
|
195
|
+
`unknown-schema`; `x db gen`: `X_MIGRATION_SNAPSHOT_MISSING`). Both fixes: restore the sidecar
|
|
196
|
+
(`git checkout --`), else delete the migration's files FIRST, then `x db gen`; both screened.
|
|
196
197
|
|
|
197
198
|
## Drift and introspection
|
|
198
199
|
|
|
@@ -204,16 +205,16 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
204
205
|
holds `x db gen`'s arm, its `drop not null`s, its two refusals). The type is not compared.
|
|
205
206
|
- **A missing CHECK is drift, compared by NAME**: `TableDescription.checks` (declared: name and
|
|
206
207
|
expression) vs `TableDescription.checkNames` (catalog: `conname` for `contype = 'c'`, always written
|
|
207
|
-
by `
|
|
208
|
+
by `introspectSchema()`, `[]` included). Only the declared side is judged; never a `changed-check`.
|
|
208
209
|
`drift-check.live.test.ts`.
|
|
209
210
|
- `compareTable` judges declared indexes (`missing-index`, `changed-index` over method, column list,
|
|
210
211
|
uniqueness, predicate presence and direction; `asc` normalises to `null`); never the predicate text.
|
|
211
212
|
- `compareForeignKeys` matches on where a key points (`foreignKeyTarget`, the one copy) and compares
|
|
212
213
|
`onDelete` through `onDeleteRule` (`changed-foreign-key`; its fix, `changed-column`'s and
|
|
213
214
|
`missing-check`'s are one `psql -c` too — `repair()`, schema-scoped off `public`).
|
|
214
|
-
- `
|
|
215
|
+
- `introspectSchema()` reads index columns in key order (`indkey`) and a foreign key's two column lists
|
|
215
216
|
together (`unnest(a, b) with ordinality`), pinned by `introspect-embedded.test.ts`.
|
|
216
|
-
- **`appTables()`** excludes
|
|
217
|
+
- **`appTables()`** excludes all of `x_` for drift; `introspectSchema()` alone excludes
|
|
217
218
|
`x_migrations` by default. **`app-relation.ts`**: `nonAppRelations(client, schema)` — extension
|
|
218
219
|
ownership from `pg_depend` (`deptype = 'e'`) plus views, materialised views and foreign tables —
|
|
219
220
|
merged into `excluded` unconditionally.
|
|
@@ -228,9 +229,9 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
228
229
|
|
|
229
230
|
- **Its own entry: `@ultimat3/db/schema-dump`** (`schema-dump-entry.ts`). The barrel is in every
|
|
230
231
|
role's boot graph and must evaluate none of this family — `schema-dump-entry.test.ts`.
|
|
231
|
-
- **Two readings of one catalog, never mixed.** `
|
|
232
|
-
vocabulary a snapshot is diffed in. `introspectCatalog()` (`catalog.ts`; queries
|
|
233
|
-
`catalog-objects.ts`;
|
|
232
|
+
- **Two readings of one catalog, never mixed.** `introspectSchema()` → `SchemaDescription`, the entity
|
|
233
|
+
vocabulary a snapshot is diffed in. `introspectCatalog()` (`catalog.ts`; queries: `catalog-relations.ts`,
|
|
234
|
+
`catalog-objects.ts`; pure fold: `catalog-fold.ts`) → `CatalogDescription`, Postgres' own `pg_get_*def` text, compared only to
|
|
234
235
|
itself. A catalog spelling on a `SchemaDescription` field is the `checks`/`checkNames` mistake.
|
|
235
236
|
- **Sorted in JS** (`byCodeUnit`), never `order by` and never `localeCompare`: collations differ.
|
|
236
237
|
- **`renderSchemaDump()` is pure**; `schema-dump-table.ts` spells one table. `quoted()` escapes any
|
|
@@ -284,7 +285,7 @@ Gotchas:
|
|
|
284
285
|
|
|
285
286
|
- `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
|
|
286
287
|
- `noUncheckedIndexedAccess` — array reads are `T | undefined`; `chunks[i] ?? ''` everywhere.
|
|
287
|
-
- Tests use `
|
|
288
|
+
- Tests use `recordingClient()` + `setDbClient()`; no test needs a live database.
|
|
288
289
|
- A test that must prove a pin came back uses `reservableOver()` (`fake-reservable.ts`), never a copy.
|
|
289
290
|
- `ALTER DEFAULT PRIVILEGES` is scoped to an object's creator, so layer 1 covers future tables only for
|
|
290
291
|
the roles in `creators` (default: the connected user).
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ await withTransaction(async (tx) => {
|
|
|
28
28
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
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
|
+
| `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 |
|
|
31
32
|
| `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
33
|
| `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
34
|
| `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 |
|
|
@@ -38,7 +39,7 @@ await withTransaction(async (tx) => {
|
|
|
38
39
|
| `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 |
|
|
39
40
|
| `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 |
|
|
40
41
|
| `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 |
|
|
41
|
-
| `checkDrift()` / `diffSchema()` / `
|
|
42
|
+
| `checkDrift()` / `diffSchema()` / `assertNoSchemaDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (access method `As of 2026-08-24`, columns, uniqueness, direction, and whether a predicate is there at all — never its text), declared CHECK constraints by NAME (`missing-check`, `As of 2026-08-25` — never a predicate, which the catalog answers rewritten) and declared foreign keys, matched on where the key points and not on its constraint name, with the `on delete` rule compared through one normalisation `As of 2026-08-19` |
|
|
42
43
|
| `declaredSchema()` / `expectedSchema()` | `As of 2026-08`: the schema the migrations write down, or `undefined` when the newest one carries no snapshot — never an older snapshot standing in for it |
|
|
43
44
|
| `parseSnapshot()` | `As of 2026-08`: a `<id>.snapshot.json` sidecar validated to the last nested field, or `undefined`. `{"tables":[null]}` is valid JSON and is not a schema |
|
|
44
45
|
| `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 |
|
|
@@ -49,10 +50,10 @@ await withTransaction(async (tx) => {
|
|
|
49
50
|
| `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 |
|
|
50
51
|
| `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` |
|
|
51
52
|
| `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 |
|
|
52
|
-
| `destructiveStatements()` / `hasDestructiveMarker()` / `
|
|
53
|
+
| `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 |
|
|
53
54
|
| `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 |
|
|
54
|
-
| `
|
|
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; `
|
|
55
|
+
| `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 |
|
|
56
|
+
| `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 |
|
|
56
57
|
| `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
58
|
| `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
59
|
| `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 |
|
|
@@ -60,7 +61,7 @@ await withTransaction(async (tx) => {
|
|
|
60
61
|
| `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
62
|
| `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 |
|
|
62
63
|
| `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 |
|
|
63
|
-
| `
|
|
64
|
+
| `pgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
|
|
64
65
|
| `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
|
|
65
66
|
| `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
|
|
66
67
|
| `setStatementObserver()` / `statementObserver()` | `As of 2026-08`: one event **and one `db.<verb>` span** per settled statement, both drivers; uninstalled is one branch |
|
|
@@ -68,7 +69,7 @@ await withTransaction(async (tx) => {
|
|
|
68
69
|
| `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 |
|
|
69
70
|
| `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 |
|
|
70
71
|
| `statementFingerprint()` / `statementKind()` / `statementVerb()` | `As of 2026-08`: what shape a statement is — `entity.op` when attributed else its own collapsed text, read or write from the leading verb. One rule, so two detectors group identically |
|
|
71
|
-
| `
|
|
72
|
+
| `recordingClient()` | in-memory `DbClient` that records SQL, for tests |
|
|
72
73
|
|
|
73
74
|
## `sql` is parameters-only
|
|
74
75
|
|
|
@@ -112,7 +113,8 @@ the transaction untouched.
|
|
|
112
113
|
`As of 2026-07`: a bug in one defence must not become a write, so `db.query` on the MCP dev
|
|
113
114
|
server stacks independent layers rather than trusting a single gate. This package owns the two
|
|
114
115
|
layers that are Postgres facts rather than MCP facts — the tool-boundary layers (pre-parse scan,
|
|
115
|
-
policy) live above it, and `@ultimat3/mcp`
|
|
116
|
+
policy) live above it, and `@ultimat3/mcp` imports nothing of this package but one lexer rule,
|
|
117
|
+
`endOfBlockComment` (a nested `/* */` ends where Postgres ends it, `null` when it never closes).
|
|
116
118
|
|
|
117
119
|
```ts
|
|
118
120
|
import { ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
|
|
@@ -197,7 +199,7 @@ production traffic is routed.
|
|
|
197
199
|
|
|
198
200
|
## The drift contract
|
|
199
201
|
|
|
200
|
-
`checkDrift()` compares `
|
|
202
|
+
`checkDrift()` compares `introspectSchema()` against the snapshot the newest applied migration carries —
|
|
201
203
|
`expectedSchema(migrations, ledger)`. `declaredSchema(migrations)` is the same read with the ledger
|
|
202
204
|
left out: the schema the files *declare*, applied or not, which is what `x db gen` diffs the app's
|
|
203
205
|
entities against so generation needs no database at all. One implementation, two callers — a
|
|
@@ -215,7 +217,7 @@ A migration the ledger has not recorded is **not** drift: `expectedSchema` reads
|
|
|
215
217
|
subset, so a database that simply has not migrated yet is pending, not divergent. Neither is a
|
|
216
218
|
table in the `x_` namespace — `x_migrations`, the queue's tables, the outbox and every
|
|
217
219
|
`@ultimat3/auth` table are created by `create table if not exists` at boot and appear in no
|
|
218
|
-
snapshot, so `appTables()` drops them before the diff. `
|
|
220
|
+
snapshot, so `appTables()` drops them before the diff. `introspectSchema()` keeps its own narrower
|
|
219
221
|
exclusion (the ledger alone), reserving `x_users` for a schema view that wants it.
|
|
220
222
|
|
|
221
223
|
**A CHECK the catalog no longer holds is drift, `As of 2026-08-25` — by NAME.**
|
|
@@ -233,7 +235,7 @@ expression parser competing with the server's.
|
|
|
233
235
|
**Nor is a relation an extension owns, `As of 2026-08-24`.** `create extension pg_stat_statements`
|
|
234
236
|
in `public` is the CNPG, RDS, Supabase and Neon default, and its view read as `unexpected-table`
|
|
235
237
|
with `x db gen "add pg_stat_statements"` as the fix — so every deploy failed terminally and the fix
|
|
236
|
-
would have written an extension's internal view into the app's migration set. `
|
|
238
|
+
would have written an extension's internal view into the app's migration set. `introspectSchema()` now
|
|
237
239
|
excludes every relation Postgres records as extension-owned (`pg_depend`, `deptype = 'e'`), which is
|
|
238
240
|
ownership rather than a name: a `pg_*` prefix rule covers that view and misses `postgis`'
|
|
239
241
|
`spatial_ref_sys`. Views, materialised views and foreign tables go with them — no snapshot records
|
|
@@ -257,15 +259,36 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
257
259
|
| index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
|
|
258
260
|
| 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
261
|
| 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 |
|
|
262
|
+
| append-only trigger gone (`missing-append-only-trigger`, `As of 2026-10-06`) — raised as `X_APPEND_ONLY_TRIGGER_MISSING`, the one kind with its own code | `table "T" is declared appendOnly, and its trigger ultimate_append_only is missing or disabled` — a disabled (`D`) or replica-only (`R`) trigger counts as missing, because an ordinary session fires neither | `psql "$DATABASE_URL" -c '<create or replace function …; drop trigger if exists "ultimate_append_only" on "T"; create trigger "ultimate_append_only" …>'` against the drifted database, then `x db migrate`. Drop-if-exists first: a disabled trigger still exists, and a bare `create trigger` would fail on it (`42710`) |
|
|
260
263
|
| 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 |
|
|
261
264
|
|
|
262
|
-
`checkDrift()` returns every difference; `
|
|
265
|
+
`checkDrift()` returns every difference; `assertNoSchemaDrift()` throws the first. `x db migrate` renders
|
|
263
266
|
them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
|
|
264
|
-
(`
|
|
267
|
+
(`assertNoSchemaDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel —
|
|
265
268
|
the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
|
|
266
269
|
drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
|
|
267
270
|
detector (`checkSourceDrift`), which needs no database and never calls this.
|
|
268
271
|
|
|
272
|
+
## Append-only tables
|
|
273
|
+
|
|
274
|
+
`EntityDescriptionLike.appendOnly` (from `entity({ appendOnly: true })`) is a trigger, written by
|
|
275
|
+
`x db gen` (`generate-append-only.ts`) and recorded on the snapshot as `appendOnly: true`:
|
|
276
|
+
|
|
277
|
+
```sql
|
|
278
|
+
create or replace function "ultimate_refuse_append_only"() returns trigger language plpgsql as $append_only$ begin raise exception 'X_ENTITY_APPEND_ONLY: % on %.% is refused, the table is append-only', tg_op, tg_table_schema, tg_table_name using errcode = '23001', hint = '…'; end; $append_only$;
|
|
279
|
+
create trigger "ultimate_append_only" before update or delete on "ledger" for each row execute function "ultimate_refuse_append_only"();
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
| | |
|
|
283
|
+
|---|---|
|
|
284
|
+
| Names | fixed: the trigger is `ultimate_append_only` on every table (a trigger name is per table, so no 63-byte truncation), the function one shared `ultimate_refuse_append_only()` |
|
|
285
|
+
| Refuses | UPDATE, DELETE, and the update arm of `insert … on conflict do update`. Not `truncate` — no row write, and `destructive.ts` already gates it |
|
|
286
|
+
| The error | message leads with `X_ENTITY_APPEND_ONLY`, SQLSTATE `23001` (`restrict_violation`, class 23: an integrity rule refused it, so never retried) |
|
|
287
|
+
| On / off | adding `appendOnly` emits the function (once per migration) and the trigger (drop-if-exists first on an existing table), `down` drops the trigger; removing it drops the trigger, `down` restores both. A dropped table takes its trigger with it; the function is never dropped |
|
|
288
|
+
| NOT NULL with no backfill | a NEW NOT NULL column needs a default (or `.nullable()`), and an EXISTING column cannot be turned NOT NULL: `x db gen` refuses both (`X_MIGRATION_APPEND_ONLY_BACKFILL`) rather than emit the usual `-- backfill …, then: set not null` note — that backfill is an UPDATE the trigger refuses, and a default fills no NULL already stored (`generate-append-only-column.test.ts`) |
|
|
289
|
+
| Engines | Postgres and PGlite alike (`generate-append-only.live.test.ts`, `generate-append-only-embedded.test.ts`) |
|
|
290
|
+
| Drift | `introspectSchema()` writes `triggerNames` (enabled, non-internal) on every table — the catalog half; `appendOnly` is the snapshot half, never read from the catalog |
|
|
291
|
+
|
|
269
292
|
## The schema dump
|
|
270
293
|
|
|
271
294
|
**Imported from `@ultimat3/db/schema-dump`, never from the barrel** (23.0.0): `introspectCatalog`,
|
|
@@ -317,17 +340,17 @@ Which engine, where the files live, when they are written and what holds them is
|
|
|
317
340
|
|
|
318
341
|
## The embedded database
|
|
319
342
|
|
|
320
|
-
No `DATABASE_URL` means no Docker: `
|
|
343
|
+
No `DATABASE_URL` means no Docker: `pgliteClient()` runs Postgres as WASM inside this
|
|
321
344
|
process. The module is resolved on the first statement, never at import, so an image that only
|
|
322
345
|
ever talks to a managed Postgres never loads it.
|
|
323
346
|
|
|
324
347
|
```ts
|
|
325
|
-
const dev =
|
|
348
|
+
const dev = pgliteClient({ dataDir: pgliteDataDir(services.db.url) }); // or memory://
|
|
326
349
|
await dev.ping(); // pay the ~3s boot before serving
|
|
327
350
|
setDbClient(dev);
|
|
328
351
|
|
|
329
352
|
const branch = await branchPglite('feature_x', { from: '.x/pgdata' });
|
|
330
|
-
setDbClient(
|
|
353
|
+
setDbClient(pgliteClient({ dataDir: branch.dataDir }));
|
|
331
354
|
```
|
|
332
355
|
|
|
333
356
|
PGlite has no `CREATE DATABASE ... TEMPLATE`, so `branchPglite()` copies the data directory —
|
|
@@ -337,7 +360,7 @@ lands in a filesystem path, so an unvalidated one is traversal rather than a typ
|
|
|
337
360
|
|
|
338
361
|
### One session, so callers take turns
|
|
339
362
|
|
|
340
|
-
Embedded Postgres is a single session, not a pool. `
|
|
363
|
+
Embedded Postgres is a single session, not a pool. `pgliteClient()` is therefore
|
|
341
364
|
`ReservableClient`: `withTransaction()` and `readOnlyQuery()` pin it, and every other statement
|
|
342
365
|
waits for its turn. Without that pin two concurrent units of work each run `BEGIN` on the same
|
|
343
366
|
connection — the second `COMMIT` commits the first's rows and the first `ROLLBACK` finds no
|
|
@@ -402,9 +425,9 @@ A pooled statement cannot hold a subscription: the next one runs on another conn
|
|
|
402
425
|
clients hold ONE session beside the pool for it.
|
|
403
426
|
|
|
404
427
|
```ts
|
|
405
|
-
import {
|
|
428
|
+
import { postgresClient } from '@ultimat3/db';
|
|
406
429
|
|
|
407
|
-
const client =
|
|
430
|
+
const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
|
|
408
431
|
const subscription = await client.listen(
|
|
409
432
|
'x_jobs_wake',
|
|
410
433
|
(payload) => console.log('notified', payload),
|
|
@@ -438,7 +461,13 @@ migration inside its own transaction. It refuses **before applying anything** wh
|
|
|
438
461
|
the running one — another version owns the database; or
|
|
439
462
|
- an applied migration's `up` SQL no longer matches its recorded checksum.
|
|
440
463
|
|
|
441
|
-
|
|
464
|
+
**Except a rollback** (`As of 2026-10-05`, `migrate-rollback.ts`): when every unknown ledger row
|
|
465
|
+
sorts after the newest migration this build ships, and this build has nothing left to apply, the
|
|
466
|
+
ledger is an older image rolled back onto a newer build's schema. Nothing is applied, the rows are
|
|
467
|
+
reported in `ahead` and logged as `ultimate migrate ledger ahead of build`; any other unknown row is
|
|
468
|
+
still `X_MIGRATION_CONFLICT`.
|
|
469
|
+
|
|
470
|
+
Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion, ahead: [id] }`.
|
|
442
471
|
|
|
443
472
|
## A loop of queries that is deliberate says so
|
|
444
473
|
|
|
@@ -533,7 +562,7 @@ job the moment Postgres fails over.
|
|
|
533
562
|
| `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
|
|
534
563
|
| `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
|
|
535
564
|
| `X_DB_DRIFT` | live schema differs from migrations |
|
|
536
|
-
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch |
|
|
565
|
+
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch — never for rows that are all newer than the build (a rollback) |
|
|
537
566
|
| `X_MIGRATE_CONCURRENT` | another migrator still held the lock when the wait ran out |
|
|
538
567
|
| `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
|
|
539
568
|
| `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
-
"bun": ">=1.4.
|
|
29
|
+
"bun": ">=1.4.2"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
36
|
+
"@ultimat3/core": "25.1.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/client.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface PostgresClient extends ReservableClient, ListeningClient {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Lazily connects: the pool opens on the first statement, never at import. */
|
|
72
|
-
export function
|
|
72
|
+
export function postgresClient(options: PostgresClientOptions = {}): PostgresClient {
|
|
73
73
|
const role = options.role ?? resolveRole();
|
|
74
74
|
const profile: PoolProfile = assertPoolProfile({
|
|
75
75
|
...poolProfileFor(role),
|
|
@@ -247,7 +247,7 @@ export function setDbClient(client: DbClient | undefined): void {
|
|
|
247
247
|
*
|
|
248
248
|
* The role default is layered under `DATABASE_POOL_MAX`, because this is the one place the process
|
|
249
249
|
* builds its own client and therefore the only place an operator's value can reach one:
|
|
250
|
-
* `
|
|
250
|
+
* `postgresClient` has always taken a `profile` override and nothing in a running app passed
|
|
251
251
|
* it, so `POOL_PROFILES` was the last word in a deployed image. `default-client.ts` owns what gets
|
|
252
252
|
* built — one pool, or a primary and a replica when `DATABASE_REPLICA_URL` names one.
|
|
253
253
|
*/
|
package/src/column-alter.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { defaultExpression, hasUnrenderedDefault } from './column-default';
|
|
8
8
|
import { columnDefaultUnsafe } from './ddl-errors';
|
|
9
|
-
import type { ColumnDescriptionLike } from './entity-shape';
|
|
9
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
10
10
|
import type { Plan } from './foreign-key-plan';
|
|
11
|
+
import { appendOnlyBackfillRefused } from './generate-append-only';
|
|
11
12
|
import type { ColumnDescription } from './introspect';
|
|
12
13
|
import { identifier } from './sql';
|
|
13
14
|
import { statementsOf } from './statement-split';
|
|
@@ -29,18 +30,20 @@ function screened(column: string, expression: string): string {
|
|
|
29
30
|
* `set default` / `drop default` and `drop not null`, each with its reverse in `down` — the shape
|
|
30
31
|
* `redefineIndex` (`index-ddl.ts`) gives a moved index. Becoming NOT NULL is deliberately NOT a
|
|
31
32
|
* bare `set not null`: rows already holding `NULL` make it fail inside `ROLE=migrate`, so it gets
|
|
32
|
-
* the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table
|
|
33
|
+
* the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table —
|
|
34
|
+
* refused on an append-only table, whose backfill the trigger would refuse.
|
|
33
35
|
*
|
|
34
36
|
* A default this generator cannot render (`hasUnrenderedDefault`) moves nothing: dropping the one
|
|
35
37
|
* the database holds would lose a rule the entity still states, and `unrenderedOf` already reports
|
|
36
38
|
* it at the top of `up`.
|
|
37
39
|
*/
|
|
38
40
|
export function alterColumnInPlace(
|
|
39
|
-
|
|
41
|
+
entity: EntityDescriptionLike,
|
|
40
42
|
column: ColumnDescriptionLike,
|
|
41
43
|
recorded: ColumnDescription,
|
|
42
44
|
plan: Plan,
|
|
43
45
|
): void {
|
|
46
|
+
const { table } = entity;
|
|
44
47
|
const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
|
|
45
48
|
const wanted = hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
|
|
46
49
|
const held = recorded.default;
|
|
@@ -59,6 +62,9 @@ export function alterColumnInPlace(
|
|
|
59
62
|
plan.down.push(`${alter} set not null;`);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
65
|
+
// That backfill is an UPDATE, and an append-only table's trigger refuses every one. Refused even
|
|
66
|
+
// with a default now declared: `set default` fills no row that already holds NULL.
|
|
67
|
+
if (entity.appendOnly === true) throw appendOnlyBackfillRefused(entity, column, 'made-not-null');
|
|
62
68
|
// `drop not null` in `down` is a no-op on a column that never became NOT NULL, so the reverse is
|
|
63
69
|
// right whether or not the backfill and its `set not null` were ever run.
|
|
64
70
|
plan.up.push(`-- backfill ${identifier(column.column).text}, then: ${alter} set not null;`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: a `DbClient` as `@ultimat3/core`'s structural `PgExecutor` — the
|
|
2
|
+
// `query(text, values)` seam the jobs queue, the outbox, the idempotency, notify and MCP
|
|
3
|
+
// confirmation stores take. The ONE builder: the boot and an app both build theirs here.
|
|
4
|
+
|
|
5
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
|
+
import { type DbClient, db } from './client';
|
|
7
|
+
import type { SqlFragment } from './sql';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The client is resolved per STATEMENT, never at the call. Default `db`: the installed client, so
|
|
11
|
+
* an executor declared at module scope — before boot installs one — still reaches the boot's, and
|
|
12
|
+
* inside `withTransaction` the transaction's own connection, so a store's row commits or rolls
|
|
13
|
+
* back with the business rows. Pass `() => client` for a store that must stay on one pool whatever
|
|
14
|
+
* transaction is open (the boot's queue and idempotency reservations).
|
|
15
|
+
*
|
|
16
|
+
* The fragment is assembled by hand rather than through `sql`` `: a store hands over `$1..$n`
|
|
17
|
+
* text it wrote itself plus already-bound values, so there is nothing to interpolate or guard.
|
|
18
|
+
*/
|
|
19
|
+
export function dbExecutor(client: () => DbClient = db): PgExecutor {
|
|
20
|
+
return {
|
|
21
|
+
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
22
|
+
client().query<R>({ text, values } satisfies SqlFragment),
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/default-client.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// framework decides a process's database topology from the environment, and `client.ts` is at the
|
|
4
4
|
// line ceiling.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { type DbClient, postgresClient } from './client';
|
|
7
7
|
import { poolMaxFromEnv } from './pool-profile';
|
|
8
8
|
import { replicatedClient } from './replica-client';
|
|
9
9
|
|
|
@@ -18,7 +18,7 @@ import { replicatedClient } from './replica-client';
|
|
|
18
18
|
export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Composed rather than folded into `
|
|
21
|
+
* Composed rather than folded into `postgresClient`, on purpose: `migrate`, `x db branch` and
|
|
22
22
|
* every test build a client that must be exactly one pool, and a second pool reachable through the
|
|
23
23
|
* same factory would be a second thing `reserve()`, `close()` and `ping()` each have to mean two
|
|
24
24
|
* ways.
|
|
@@ -29,8 +29,8 @@ export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
|
|
|
29
29
|
*/
|
|
30
30
|
export function defaultClient(): DbClient {
|
|
31
31
|
const profile = poolMaxFromEnv();
|
|
32
|
-
const primary =
|
|
32
|
+
const primary = postgresClient({ profile });
|
|
33
33
|
const replicaUrl = process.env[REPLICA_URL_ENV];
|
|
34
34
|
if (replicaUrl === undefined || replicaUrl.trim() === '') return primary;
|
|
35
|
-
return replicatedClient(primary,
|
|
35
|
+
return replicatedClient(primary, postgresClient({ url: replicaUrl, profile }));
|
|
36
36
|
}
|
package/src/dependent-view.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// before the statement is sent — and name the view, the column and the statement that recreates it.
|
|
3
3
|
//
|
|
4
4
|
// **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
|
|
5
|
-
// runs with no database open; `SchemaDescription` has no field for a view; `
|
|
5
|
+
// runs with no database open; `SchemaDescription` has no field for a view; `introspectSchema()` reads
|
|
6
6
|
// none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
|
|
7
7
|
// declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
|
|
8
8
|
// with no caller to fill it is the declared-and-never-wired defect this release exists to
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import type { DbClient } from './client';
|
|
22
22
|
import { migrationViewDepends } from './migration-errors';
|
|
23
23
|
import { identifier, join, sql } from './sql';
|
|
24
|
-
import {
|
|
24
|
+
import { foldIdentifier, IDENTIFIER_CHAR, noiseAt } from './sql-scan';
|
|
25
25
|
import { statementsOf } from './statement-split';
|
|
26
26
|
|
|
27
27
|
/** One `alter table <table> alter column <column> type …`, as the catalog spells both names. */
|
|
@@ -44,7 +44,7 @@ interface SqlWord {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The names in one statement, in order, folded the way Postgres folds them: an unquoted identifier
|
|
47
|
-
* to lower case, a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
47
|
+
* to lower case in ASCII only (`foldIdentifier`), a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
48
48
|
* contribute nothing, through this package's one lexer — `-- alter column` is prose and
|
|
49
49
|
* `'alter column'` is data.
|
|
50
50
|
*/
|
|
@@ -60,13 +60,13 @@ function wordsOf(statement: string): readonly SqlWord[] {
|
|
|
60
60
|
at = noise.end;
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
|
-
if (!
|
|
63
|
+
if (!IDENTIFIER_CHAR.test(statement[at] ?? '')) {
|
|
64
64
|
at += 1;
|
|
65
65
|
continue;
|
|
66
66
|
}
|
|
67
67
|
let end = at;
|
|
68
|
-
while (end < statement.length &&
|
|
69
|
-
words.push({ text: statement.slice(at, end)
|
|
68
|
+
while (end < statement.length && IDENTIFIER_CHAR.test(statement[end] ?? '')) end += 1;
|
|
69
|
+
words.push({ text: foldIdentifier(statement.slice(at, end)), quoted: false });
|
|
70
70
|
at = end;
|
|
71
71
|
}
|
|
72
72
|
return words;
|
package/src/destructive.ts
CHANGED
|
@@ -112,4 +112,4 @@ export function destructiveStatements(up: string): readonly DestructiveStatement
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Whether `up` destroys data at all — what `x db gen` writes the marker from. */
|
|
115
|
-
export const
|
|
115
|
+
export const isDestructiveMigration = (up: string): boolean => destructiveStatements(up).length > 0;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Single responsibility: whether an `appendOnly` table still carries the trigger that refuses UPDATE
|
|
2
|
+
// and DELETE, and what to say when it does not. The repository refuses above the driver whatever
|
|
3
|
+
// the database holds, so a dropped trigger is invisible to every test — only the catalog can say.
|
|
4
|
+
|
|
5
|
+
import { byHand, type DriftDifference, pathTo, repair } from './drift-findings';
|
|
6
|
+
import {
|
|
7
|
+
APPEND_ONLY_FUNCTION_SQL,
|
|
8
|
+
APPEND_ONLY_TRIGGER,
|
|
9
|
+
installAppendOnlyTriggerSql,
|
|
10
|
+
} from './generate-append-only';
|
|
11
|
+
import type { TableDescription } from './introspect';
|
|
12
|
+
import { shellInertIdentifier } from './sql';
|
|
13
|
+
|
|
14
|
+
/** The code `driftError` raises for this kind, exported so the tests assert the one literal. */
|
|
15
|
+
export const APPEND_ONLY_DRIFT_CODE = 'X_APPEND_ONLY_TRIGGER_MISSING';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The constructor. The fix is the two statements `x db gen` wrote, run against THIS database:
|
|
19
|
+
* the migration that added the trigger is already in the ledger, so `x db migrate` applies nothing
|
|
20
|
+
* (`repair`'s argument). Drop-if-exists before create, because a DISABLED trigger counts as missing
|
|
21
|
+
* here yet still exists, and a bare `create trigger` would fail on it. The function is redefined too — `create or replace` — because a hand that
|
|
22
|
+
* dropped the trigger may have dropped the function with it.
|
|
23
|
+
*/
|
|
24
|
+
export function missingAppendOnlyTrigger(schema: string, table: string): DriftDifference {
|
|
25
|
+
const path = pathTo(schema);
|
|
26
|
+
const spellable = shellInertIdentifier(table) !== null;
|
|
27
|
+
return {
|
|
28
|
+
kind: 'missing-append-only-trigger',
|
|
29
|
+
table,
|
|
30
|
+
column: null,
|
|
31
|
+
cause:
|
|
32
|
+
`table "${table}" is declared appendOnly, and its trigger ${APPEND_ONLY_TRIGGER} is missing ` +
|
|
33
|
+
'or disabled — raw SQL can UPDATE and DELETE its rows; only the repository still refuses',
|
|
34
|
+
fix:
|
|
35
|
+
path === null || !spellable
|
|
36
|
+
? byHand('re-create the append-only function and trigger this difference names')
|
|
37
|
+
: repair(path, [APPEND_ONLY_FUNCTION_SQL, ...installAppendOnlyTriggerSql(table)].join(' ')),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Judged only where both halves were asked: the snapshot declares the table append-only AND the
|
|
43
|
+
* catalog was read (`triggerNames` present). Either absent says nothing — a sidecar that predates
|
|
44
|
+
* the field, or a description nobody introspected — exactly as `compareChecks` reads its pair.
|
|
45
|
+
*/
|
|
46
|
+
export function compareAppendOnly(
|
|
47
|
+
live: TableDescription,
|
|
48
|
+
expected: TableDescription,
|
|
49
|
+
): DriftDifference[] {
|
|
50
|
+
if (expected.appendOnly !== true || live.triggerNames === undefined) return [];
|
|
51
|
+
if (live.triggerNames.includes(APPEND_ONLY_TRIGGER)) return [];
|
|
52
|
+
return [missingAppendOnlyTrigger(live.schema, live.name)];
|
|
53
|
+
}
|
package/src/drift-findings.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type DriftKind =
|
|
|
23
23
|
| 'missing-check'
|
|
24
24
|
| 'missing-foreign-key'
|
|
25
25
|
| 'changed-foreign-key'
|
|
26
|
+
// Constructed in `drift-append-only.ts`: an `appendOnly` table whose refusing trigger is gone.
|
|
27
|
+
| 'missing-append-only-trigger'
|
|
26
28
|
// Constructed in `object-drift.ts`: a trigger, function, view, type or sequence in the live
|
|
27
29
|
// database that replaying the migrations does not create.
|
|
28
30
|
| 'unexpected-object';
|
|
@@ -59,7 +61,7 @@ const UNSPELLABLE = `${CARRIES}, so no statement here can spell it`;
|
|
|
59
61
|
* this database and never "in a new migration": drift means this database left the migrations,
|
|
60
62
|
* and a migration would re-apply the repair to every database that is already right.
|
|
61
63
|
*/
|
|
62
|
-
const repair = (path: string, statements: string, note = RE_CHECK): string =>
|
|
64
|
+
export const repair = (path: string, statements: string, note = RE_CHECK): string =>
|
|
63
65
|
`${psqlCommand(`${path}${statements}`)} # ${note}`;
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -81,7 +83,7 @@ const DEFAULT_SCHEMA = 'public';
|
|
|
81
83
|
* `tenant_a` fails, or lands on a same-named table in `public`. `null` for a schema no statement
|
|
82
84
|
* can spell.
|
|
83
85
|
*/
|
|
84
|
-
const pathTo = (schema: string): string | null => {
|
|
86
|
+
export const pathTo = (schema: string): string | null => {
|
|
85
87
|
if (schema === DEFAULT_SCHEMA) return '';
|
|
86
88
|
const name = shellInertIdentifier(schema);
|
|
87
89
|
return name === null ? null : `set search_path = ${name}; `;
|