@ultimat3/db 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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 only because
104
- `packages/cli/src/dev-queue.ts` wraps a real client for its `PgExecutor`, unattributed.
103
+ - `@ultimat3/jobs` never imports this package; its statements pass the observer only as
104
+ `packages/cli/src/runtime-queue.ts` wraps a real client for its `PgExecutor`, 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
- `createRecordingClient` stubs the lock as `locked: true`.
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`. `introspect()` reads `pg_am` (`introspect-embedded.test.ts`).
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 carries no `using`; a NOT
174
- NULL add is one statement; generated → plain is `drop expression`; plain → generated rebuilds the
175
- column (`regenerate` answers `rebuilt`) and moves its dependents aside; a generated column's own type
176
- change deliberately does not. `introspect` never reads `generation_expression` back.
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 emitted by a PARAMETER** (`GenerateOptions.replicaIdentityFull`, from
179
- `@ultimat3/cli`'s `db-generate.ts`), in `replica-identity.ts`: recorded `true` or absent; the
180
- snapshot records the union; dead last in `up`; never destructive; an undeclared name is skipped;
181
- `down` is `replica identity default` except on a table this migration creates.
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 three `*Like` interfaces** (optional `onDelete` / `generated`).
189
- - **`snapshot-json.ts` writes bytes that are a fixed point of Biome** (arrays collapse when they fit at
190
- `<= 100` counting the trailing comma); `snapshot-json.test.ts` runs the repo's own `biome format`.
191
- - **`declaredSchema()` answers the NEWEST migration's snapshot or `undefined`**; `checkDrift` turns
192
- that into `unknown-schema`, and `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING`. Both lead
193
- with the same two remedies in order: restore the sidecar (`git checkout --`), or delete the
194
- migration's files FIRST and then run `x db gen`. Both commands are screened (`shellInertIdentifier`
195
- / `renderFixShellArg`), degrading the whole line to prose.
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 `introspect()`, `[]` included). Only the declared side is judged; no `changed-check`, ever.
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
- - `introspect()` reads index columns in key order (`indkey`) and a foreign key's two column lists
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 the whole `x_` namespace for drift; `introspect()` alone 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.** `introspect()` → `SchemaDescription`, the entity
232
- vocabulary a snapshot is diffed in. `introspectCatalog()` (`catalog.ts`; queries in `catalog-relations.ts` and
233
- `catalog-objects.ts`; the pure fold in `catalog-fold.ts`) → `CatalogDescription`, Postgres' own `pg_get_*def` text, compared only to
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 `createRecordingClient()` + `setDbClient()`; no test may need a live database.
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
@@ -38,7 +38,7 @@ await withTransaction(async (tx) => {
38
38
  | `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
39
  | `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
40
  | `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()` / `assertNoDrift()` | 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` |
41
+ | `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
42
  | `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
43
  | `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
44
  | `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 +49,10 @@ await withTransaction(async (tx) => {
49
49
  | `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
50
  | `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
51
  | `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()` / `isDestructive()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |
52
+ | `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
53
  | `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |
54
- | `introspect()` | live schema → `SchemaDescription`. **App tables only**, `As of 2026-08-24`: a relation an extension owns (`pg_depend`, `deptype = 'e'`) and anything that is not an ordinary or partitioned table are excluded before the fold, and an explicit `exclude` cannot bring them back |
55
- | `introspectCatalog()` / `CatalogDescription` / `emptyCatalog()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: the WHOLE schema in the catalog's own spelling — extensions, enum and domain types, sequences, tables, indexes, foreign keys, views, functions, triggers — sorted in code-unit order, plus `unrendered`: what exists and the dump cannot spell. Comparable only to another reading of itself; `introspect()` stays the entity-vocabulary reading a snapshot is diffed in |
54
+ | `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 |
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; `introspectSchema()` stays the entity-vocabulary reading a snapshot is diffed in |
56
56
  | `renderSchemaDump()` / `SchemaDumpFile` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: a catalog → the schema dump's files. Pure and byte-deterministic. [The schema dump](#the-schema-dump) |
57
57
  | `loadSchemaDump()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: build a schema from those files, in one transaction, retrying a file that names something not created yet |
58
58
  | `compareSchemaDump()` / `reloadDifferences()` / `schemaDumpDrift()` / `schemaDumpDifferenceOf()` | **`@ultimat3/db/schema-dump`.** `As of 2026-10`: `X_SCHEMA_DUMP_DRIFT` — committed files against rendered ones in both directions, and load-equals-replay as a comparison |
@@ -60,7 +60,7 @@ await withTransaction(async (tx) => {
60
60
  | `PgliteOptions.extensions` / `linkPgliteExtensions()` | `As of 2026-10`: Postgres extensions to link at boot, by name — a list, or a function for a caller whose list is read from disk. `linkPgliteExtensions(names)` answers `{ linked, missing }` without booting anything: `missing` is what the installed PGlite ships no bundle for, which is how `@ultimat3/cli` decides a replay needs a real Postgres. A missing name is skipped at boot and refused by `create extension` itself |
61
61
  | `PgliteOptions.snapshotDir` | `As of 2026-10`: a directory for the post-`initdb` snapshot, so an in-memory boot is a restore (~0.4 s against ~2.7 s). Keyed on the PGlite version alone — `initdb` never sees a linked extension, so one snapshot serves every set; checksummed, never trusted, written by temp-name-then-rename. Ignored for a data directory on disk |
62
62
  | `createBranch()` / `dropBranch()` / `reapBranches()` | copy-on-write branch databases. `As of 2026-08-19` the marker comment records the **base** as well as the instant (`ultimate:branch:<base>:<iso>`, on `BranchInfo.base`), and `reapBranches()` sweeps only branches of the database it is connected to — one Postgres hosting two Ultimate apps used to mean one app's nightly reap dropped the other's branches. A pre-3.x marker records no base and is skipped, never dropped |
63
- | `createPgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
63
+ | `pgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
64
64
  | `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
65
65
  | `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
66
66
  | `setStatementObserver()` / `statementObserver()` | `As of 2026-08`: one event **and one `db.<verb>` span** per settled statement, both drivers; uninstalled is one branch |
@@ -68,7 +68,7 @@ await withTransaction(async (tx) => {
68
68
  | `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
69
  | `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
70
  | `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
- | `createRecordingClient()` | in-memory `DbClient` that records SQL, for tests |
71
+ | `recordingClient()` | in-memory `DbClient` that records SQL, for tests |
72
72
 
73
73
  ## `sql` is parameters-only
74
74
 
@@ -197,7 +197,7 @@ production traffic is routed.
197
197
 
198
198
  ## The drift contract
199
199
 
200
- `checkDrift()` compares `introspect()` against the snapshot the newest applied migration carries —
200
+ `checkDrift()` compares `introspectSchema()` against the snapshot the newest applied migration carries —
201
201
  `expectedSchema(migrations, ledger)`. `declaredSchema(migrations)` is the same read with the ledger
202
202
  left out: the schema the files *declare*, applied or not, which is what `x db gen` diffs the app's
203
203
  entities against so generation needs no database at all. One implementation, two callers — a
@@ -215,7 +215,7 @@ A migration the ledger has not recorded is **not** drift: `expectedSchema` reads
215
215
  subset, so a database that simply has not migrated yet is pending, not divergent. Neither is a
216
216
  table in the `x_` namespace — `x_migrations`, the queue's tables, the outbox and every
217
217
  `@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. `introspect()` keeps its own narrower
218
+ snapshot, so `appTables()` drops them before the diff. `introspectSchema()` keeps its own narrower
219
219
  exclusion (the ledger alone), reserving `x_users` for a schema view that wants it.
220
220
 
221
221
  **A CHECK the catalog no longer holds is drift, `As of 2026-08-25` — by NAME.**
@@ -233,7 +233,7 @@ expression parser competing with the server's.
233
233
  **Nor is a relation an extension owns, `As of 2026-08-24`.** `create extension pg_stat_statements`
234
234
  in `public` is the CNPG, RDS, Supabase and Neon default, and its view read as `unexpected-table`
235
235
  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. `introspect()` now
236
+ would have written an extension's internal view into the app's migration set. `introspectSchema()` now
237
237
  excludes every relation Postgres records as extension-owned (`pg_depend`, `deptype = 'e'`), which is
238
238
  ownership rather than a name: a `pg_*` prefix rule covers that view and misses `postgis`'
239
239
  `spatial_ref_sys`. Views, materialised views and foreign tables go with them — no snapshot records
@@ -257,15 +257,36 @@ X_DB_DRIFT: schema differs from migrations
257
257
  | index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
258
258
  | foreign key, rule moved | `foreign key "K" on "T" (C) to "R" is on delete cascade, not what migrations declare` — `K` is the constraint the database holds | `psql "$DATABASE_URL" -c '<the drop constraint + add constraint pair>'` — one command, against the drifted database — then `x db migrate` |
259
259
  | column nullability differs (`changed-column`) | `table "T" allows NULL in column "C" that migrations declare not null` / `forbids NULL in column "C" that migrations declare nullable` | `psql "$DATABASE_URL" -c 'alter table "T" alter column "C" set not null;'` (or `drop not null`) — one command, then `x db migrate`. A table read from a schema other than `public` gets `set search_path = "<schema>";` ahead of the statement, in the same `psql -c` word, so it and every table it references resolve there. A name `shellInertIdentifier()` refuses degrades every one of these three to `psql "$DATABASE_URL" # <the steps>`: still a command that runs, with no name in it |
260
+ | 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
261
  | 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
262
 
262
- `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
263
+ `checkDrift()` returns every difference; `assertNoSchemaDrift()` throws the first. `x db migrate` renders
263
264
  them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
264
- (`assertNoDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel —
265
+ (`assertNoSchemaDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel —
265
266
  the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
266
267
  drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
267
268
  detector (`checkSourceDrift`), which needs no database and never calls this.
268
269
 
270
+ ## Append-only tables
271
+
272
+ `EntityDescriptionLike.appendOnly` (from `entity({ appendOnly: true })`) is a trigger, written by
273
+ `x db gen` (`generate-append-only.ts`) and recorded on the snapshot as `appendOnly: true`:
274
+
275
+ ```sql
276
+ 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$;
277
+ create trigger "ultimate_append_only" before update or delete on "ledger" for each row execute function "ultimate_refuse_append_only"();
278
+ ```
279
+
280
+ | | |
281
+ |---|---|
282
+ | 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()` |
283
+ | Refuses | UPDATE, DELETE, and the update arm of `insert … on conflict do update`. Not `truncate` — no row write, and `destructive.ts` already gates it |
284
+ | The error | message leads with `X_ENTITY_APPEND_ONLY`, SQLSTATE `23001` (`restrict_violation`, class 23: an integrity rule refused it, so never retried) |
285
+ | 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 |
286
+ | 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`) |
287
+ | Engines | Postgres and PGlite alike (`generate-append-only.live.test.ts`, `generate-append-only-embedded.test.ts`) |
288
+ | Drift | `introspectSchema()` writes `triggerNames` (enabled, non-internal) on every table — the catalog half; `appendOnly` is the snapshot half, never read from the catalog |
289
+
269
290
  ## The schema dump
270
291
 
271
292
  **Imported from `@ultimat3/db/schema-dump`, never from the barrel** (23.0.0): `introspectCatalog`,
@@ -317,17 +338,17 @@ Which engine, where the files live, when they are written and what holds them is
317
338
 
318
339
  ## The embedded database
319
340
 
320
- No `DATABASE_URL` means no Docker: `createPgliteClient()` runs Postgres as WASM inside this
341
+ No `DATABASE_URL` means no Docker: `pgliteClient()` runs Postgres as WASM inside this
321
342
  process. The module is resolved on the first statement, never at import, so an image that only
322
343
  ever talks to a managed Postgres never loads it.
323
344
 
324
345
  ```ts
325
- const dev = createPgliteClient({ dataDir: pgliteDataDir(services.db.url) }); // or memory://
346
+ const dev = pgliteClient({ dataDir: pgliteDataDir(services.db.url) }); // or memory://
326
347
  await dev.ping(); // pay the ~3s boot before serving
327
348
  setDbClient(dev);
328
349
 
329
350
  const branch = await branchPglite('feature_x', { from: '.x/pgdata' });
330
- setDbClient(createPgliteClient({ dataDir: branch.dataDir }));
351
+ setDbClient(pgliteClient({ dataDir: branch.dataDir }));
331
352
  ```
332
353
 
333
354
  PGlite has no `CREATE DATABASE ... TEMPLATE`, so `branchPglite()` copies the data directory —
@@ -337,7 +358,7 @@ lands in a filesystem path, so an unvalidated one is traversal rather than a typ
337
358
 
338
359
  ### One session, so callers take turns
339
360
 
340
- Embedded Postgres is a single session, not a pool. `createPgliteClient()` is therefore
361
+ Embedded Postgres is a single session, not a pool. `pgliteClient()` is therefore
341
362
  `ReservableClient`: `withTransaction()` and `readOnlyQuery()` pin it, and every other statement
342
363
  waits for its turn. Without that pin two concurrent units of work each run `BEGIN` on the same
343
364
  connection — the second `COMMIT` commits the first's rows and the first `ROLLBACK` finds no
@@ -402,9 +423,9 @@ A pooled statement cannot hold a subscription: the next one runs on another conn
402
423
  clients hold ONE session beside the pool for it.
403
424
 
404
425
  ```ts
405
- import { createPostgresClient } from '@ultimat3/db';
426
+ import { postgresClient } from '@ultimat3/db';
406
427
 
407
- const client = createPostgresClient({ url: 'postgres://localhost:5432/app_test' });
428
+ const client = postgresClient({ url: 'postgres://localhost:5432/app_test' });
408
429
  const subscription = await client.listen(
409
430
  'x_jobs_wake',
410
431
  (payload) => console.log('notified', payload),
@@ -438,7 +459,13 @@ migration inside its own transaction. It refuses **before applying anything** wh
438
459
  the running one — another version owns the database; or
439
460
  - an applied migration's `up` SQL no longer matches its recorded checksum.
440
461
 
441
- Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion }`.
462
+ **Except a rollback** (`As of 2026-10-05`, `migrate-rollback.ts`): when every unknown ledger row
463
+ sorts after the newest migration this build ships, and this build has nothing left to apply, the
464
+ ledger is an older image rolled back onto a newer build's schema. Nothing is applied, the rows are
465
+ reported in `ahead` and logged as `ultimate migrate ledger ahead of build`; any other unknown row is
466
+ still `X_MIGRATION_CONFLICT`.
467
+
468
+ Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion, ahead: [id] }`.
442
469
 
443
470
  ## A loop of queries that is deliberate says so
444
471
 
@@ -533,7 +560,7 @@ job the moment Postgres fails over.
533
560
  | `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
534
561
  | `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
535
562
  | `X_DB_DRIFT` | live schema differs from migrations |
536
- | `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch |
563
+ | `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch — never for rows that are all newer than the build (a rollback) |
537
564
  | `X_MIGRATE_CONCURRENT` | another migrator still held the lock when the wait ran out |
538
565
  | `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
539
566
  | `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": "24.0.0",
3
+ "version": "25.0.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.0"
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": "24.0.0"
36
+ "@ultimat3/core": "25.0.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 createPostgresClient(options: PostgresClientOptions = {}): PostgresClient {
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
- * `createPostgresClient` has always taken a `profile` override and nothing in a running app passed
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
  */
@@ -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
- table: string,
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;`);
@@ -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 { createPostgresClient, type DbClient } from './client';
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 `createPostgresClient`, on purpose: `migrate`, `x db branch` and
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 = createPostgresClient({ profile });
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, createPostgresClient({ url: replicaUrl, profile }));
35
+ return replicatedClient(primary, postgresClient({ url: replicaUrl, profile }));
36
36
  }
@@ -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; `introspect()` reads
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
@@ -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 isDestructive = (up: string): boolean => destructiveStatements(up).length > 0;
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
+ }
@@ -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}; `;
package/src/drift.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // by the framework contract; `x verify` fails on it and `--json` carries every difference.
5
5
 
6
6
  import { baseClient, type DbClient } from './client';
7
+ import { APPEND_ONLY_DRIFT_CODE, compareAppendOnly } from './drift-append-only';
7
8
  import type { DriftDifference } from './drift-findings';
8
9
  import {
9
10
  changedColumn,
@@ -22,7 +23,12 @@ import {
22
23
  import { DbError } from './errors';
23
24
  import { foreignKeyTarget, onDeleteRule } from './foreign-key';
24
25
  import { indexMethodOf } from './index-method';
25
- import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
26
+ import {
27
+ findTable,
28
+ introspectSchema,
29
+ type SchemaDescription,
30
+ type TableDescription,
31
+ } from './introspect';
26
32
  import { type LedgerRow, type Migration, readLedger } from './migrate';
27
33
  import { sameColumns } from './primary-key';
28
34
 
@@ -162,7 +168,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
162
168
  * before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames`
163
169
  * absent is a description that never asked the catalog — a stub, a fake client's rows, a
164
170
  * `TableDescription` built by hand — and reading that as "the database holds none" is one finding
165
- * per declared constraint against a database nobody looked at. `introspect()` always answers with
171
+ * per declared constraint against a database nobody looked at. `introspectSchema()` always answers with
166
172
  * the field, `[]` included, so a real read is never mistaken for an unread one.
167
173
  *
168
174
  * Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state:
@@ -222,6 +228,7 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
222
228
  differences.push(...compareIndexes(live, expected));
223
229
  differences.push(...compareChecks(live, expected));
224
230
  differences.push(...compareForeignKeys(live, expected));
231
+ differences.push(...compareAppendOnly(live, expected));
225
232
  return differences;
226
233
  }
227
234
 
@@ -246,7 +253,8 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
246
253
 
247
254
  export function driftError(difference: DriftDifference): DbError {
248
255
  return new DbError({
249
- code: 'X_DB_DRIFT',
256
+ // One kind carries its own code — the guarantee it names is the entity's, not a column's.
257
+ code: difference.kind === 'missing-append-only-trigger' ? APPEND_ONLY_DRIFT_CODE : 'X_DB_DRIFT',
250
258
  cause: difference.cause,
251
259
  fix: difference.fix,
252
260
  meta: { kind: difference.kind, table: difference.table, column: difference.column },
@@ -260,7 +268,7 @@ export function driftError(difference: DriftDifference): DbError {
260
268
  * (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
261
269
  * which never reaches this function. There is no `x db drift` command.
262
270
  */
263
- export function assertNoDrift(report: DriftReport): void {
271
+ export function assertNoSchemaDrift(report: DriftReport): void {
264
272
  const first = report.differences[0];
265
273
  if (first !== undefined) throw driftError(first);
266
274
  }
@@ -311,7 +319,7 @@ export function expectedSchema(
311
319
  * schema that is in fact correct. The `x_` prefix is the convention every framework table already
312
320
  * follows, so a table a future package adds needs no second list here.
313
321
  *
314
- * `introspect()` keeps its own narrower default (`x_migrations` alone) on purpose: the admin
322
+ * `introspectSchema()` keeps its own narrower default (`x_migrations` alone) on purpose: the admin
315
323
  * dashboard's schema view and the MCP `schema.describe` tool legitimately show `x_users`. Only
316
324
  * drift wants the whole namespace gone, so only drift declares it.
317
325
  */
@@ -348,7 +356,7 @@ export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
348
356
  // and a wrong `ok: true` is the failure this check exists to prevent.
349
357
  if (expected === undefined)
350
358
  return { ok: false, differences: [unknownSchema(options.migrations)] };
351
- const live = await introspect({
359
+ const live = await introspectSchema({
352
360
  client,
353
361
  ...(options.schema === undefined ? {} : { schema: options.schema }),
354
362
  });
@@ -112,4 +112,9 @@ export interface EntityDescriptionLike {
112
112
  * none", which is what every hand-built description in this package's own tests is.
113
113
  */
114
114
  readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
115
+ /**
116
+ * `entity({ appendOnly: true })`: the table refuses UPDATE and DELETE (`generate-append-only.ts`).
117
+ * Optional for the reason `invariants` is, and absent reads as "rows may change".
118
+ */
119
+ readonly appendOnly?: boolean | undefined;
115
120
  }