@ultimat3/db 2.0.0 → 4.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
@@ -11,6 +11,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
11
11
  | SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
12
12
  | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
13
13
  | SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
14
+ | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
14
15
  | Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
15
16
  | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
16
17
  | Exports | explicit in `src/index.ts`; no `export *` |
@@ -148,8 +149,12 @@ pool's idle timeout (`migrate`'s is 10s) closes it, releasing the lock mid-migra
148
149
  hid the first half by accident — its pool is `max: 1`, so every statement found the same connection.
149
150
  No other role and no test has that. The pin is therefore also why the lock scope hands its session
150
151
  *down*: on `max: 1` a statement sent to the pool while the pin is held waits for a connection that
151
- cannot come back until the migration blocking on it finishes. `lock: false` (`x db branch`, a
152
- private database) reserves nothing and takes no lock, exactly as before.
152
+ cannot come back until the migration blocking on it finishes. `lock: false` reserves nothing and takes no lock, exactly as
153
+ before — for a database only this process can reach. **No shipped path passes it**: both option
154
+ comments named `x db branch`, which does not, and the only callers in the repo are
155
+ `migrate-pin.test.ts`'s. The option stays because it is public API shipped in 3.0.0 and the "no pin
156
+ was taken" assertions cannot be written without it; the false attribution is pinned out by
157
+ `migrate-pin.test.ts`, which also refuses a passer appearing inside this package.
153
158
 
154
159
  Pinned by `migrate.live.test.ts` against a real Postgres: two concurrent `migrate()` calls (one
155
160
  applies, the other skips — never both, never a unique-violation crash) and a migration that fails
@@ -318,6 +323,44 @@ block is the join of that fix with the engine it ships through — an entity des
318
323
  `generateMigration`, applied by `migrate()` itself against a real server, columns confirmed against
319
324
  `pg_indexes`, rather than either half alone.
320
325
 
326
+ **The ledger audit asks one question — does this build ship every migration the ledger records?**
327
+ `auditLedger`'s `foreign` filter is `!known.has(row.id)` and nothing else, `As of 2026-08`. It used
328
+ to also require `row.app_version !== appVersion`, which switched the audit OFF wherever the two
329
+ agree: `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
330
+ earlier `dev` build and since deleted was invisible, and `expectedSchema` (`drift.ts`) then dropped
331
+ its table from the comparison — `x db drift` answering `ok: true` against a database that still has
332
+ the table. The version is a detail of the ANSWER and lives in the cause, never in the predicate.
333
+
334
+ **`rollback({ steps })` refuses anything that is not a positive safe integer, before the lock.**
335
+ `steps` reaches `slice(0, steps)`, where a negative count counts from the END: `steps: -1` selected
336
+ every applied migration but the newest and reversed four of five. `X_INVARIANT` (core's generic
337
+ code, borrowed in `DB_BORROWED_ERROR_CODES` the way `@ultimat3/money`'s `roundRatio` borrows it —
338
+ a bad argument is not a fact about the ledger), thrown by `rollbackStepsInvalid` before the advisory
339
+ lock is taken and before the ledger is read. Same discipline as `poolMaxInvalid`: a number this
340
+ build cannot honour is refused, never reinterpreted.
341
+
342
+ **`reapBranches` sweeps branches of THIS database, never the server's, `As of 2026-08-19`** (issue
343
+ #133, closed). `listBranches` walks `pg_database` for the whole server and admits every database
344
+ carrying the marker, so two Ultimate apps on one Postgres plus one nightly reap was the other app's
345
+ branches dropped. The discriminator was in hand and thrown away: `createBranch` already resolves
346
+ `options.base ?? currentDatabase(client)` and wrote only the timestamp. The marker is now
347
+ `ultimate:branch:<base>:<iso>` and `BranchInfo.base` carries it, so the reaper skips a branch whose
348
+ base is not the database it is connected to. **Split on the ISO tail, never on the first `:`** — a
349
+ database name may contain one and an instant certainly does. A pre-4.x one-segment comment matches
350
+ no base, keeps its readable date for `x db branch ls`, and is **skipped, never dropped**: a branch
351
+ of nothing is not a branch of this database, which is what makes the change self-healing with no
352
+ migration. Postgres records no template lineage in the catalog and `datdba` is shared when both
353
+ apps use one role, so writing the base down at creation is the only answer there is.
354
+ `@ultimat3/cli`'s `ls`/`drop` scope by the `<source>_branch_` name prefix instead — its own guard,
355
+ and unaffected.
356
+
357
+ **`reapBranches` skips a `createdAt` it cannot parse; it never reads one as infinitely old.**
358
+ `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
359
+ `COMMENT ON DATABASE` that was truncated or hand-edited used to be a database DROPPED on the next
360
+ nightly sweep whatever `maxAgeMs` said. `Date.parse` + `Number.isFinite`, the discipline
361
+ `@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Whose branches it may touch at
362
+ all is the paragraph above.)
363
+
321
364
  **One send is one statement, so `migrate()` and `rollback()` split the script.** `tx.execute(raw(
322
365
  migration.up))` on a text holding two commands is where the two drivers disagreed, and the
323
366
  disagreement is the whole reason this is a bug rather than a preference: `pglite.ts` calls
@@ -447,11 +490,16 @@ never emitted one and would answer with an empty migration.
447
490
  catches a composite index rebuilt with its columns the other way round while the column diff said
448
491
  `ok: true`. A live index no snapshot names is deliberately **not** reported: Postgres creates one for
449
492
  every primary key and every unique constraint, so counting those is eight findings against a correct
450
- database, the same argument `appTables()` makes. The predicate and the direction are not compared
451
- either the catalog returns its own rewriting of an expression (`(deleted_at IS NULL)`) and the
452
- snapshot holds the author's spelling, so a text comparison reports two identical indexes as drift.
453
- `x db gen` compares them instead (`redefineIndex`), where both sides are generated. Named in
454
- `wiki/Known-Gaps.md`.
493
+ database, the same argument `appTables()` makes. **Three of the four parts are compared, and the fourth never
494
+ will be**, `As of 2026-08-19`: the predicate's *text* stays uncompared, because the catalog returns
495
+ its own rewriting of an expression (`(deleted_at IS NULL)`) where the snapshot holds the author's
496
+ spelling, and normalising that is an expression parser competing with the server's `x db gen`
497
+ compares the text instead (`redefineIndex`), where both sides are generated. Its *presence* is a
498
+ boolean, not text, and the direction is a closed enum on both sides, so both are compared now: a
499
+ partial index recreated as a total one silently widens the constraint, and a `desc` index rebuilt
500
+ ascending serves a feed's newest page off the wrong end. `asc` normalises to `null` first —
501
+ `createIndex` writes `"col" asc`, which Postgres stores as not-descending, so the raw values differ
502
+ on every ascending index in a correct database.
455
503
 
456
504
  `compareForeignKeys` judges **declared** keys the same way, and matches on **where the key points**
457
505
  — its columns, its target table, its target columns — never on the constraint name. That identity is
@@ -461,8 +509,14 @@ reported on a correct database. `snapshotOf` names a key `<table>_<column>_fkey`
461
509
  would have called an inline `references` clause — and `addForeignKey` now writes that name out, so
462
510
  the snapshot records a name the migration beside it chose rather than one it guessed; a hand-written
463
511
  migration may still have said `constraint fk_posts_org`, and a key pointing the same way under
464
- another name is the same key. `onDelete` is not compared: the catalog spells it `a`/`c`/`r` and no
465
- generated clause has ever declared one, so a snapshot has nothing truthful to hold there. Before
512
+ another name is the same key. `onDelete` **is** compared, `As of 2026-08-19`, through `onDeleteRule`
513
+ (`foreign-key.ts`) the one normalisation both sides pass through, because the catalog spells the
514
+ rule `a`/`c`/`r`/`n`/`d` and a description spells it out, and `a` (`no action`) is what a key that
515
+ declared nothing has. A difference is `changed-foreign-key`, never a `missing` one: the rule is not
516
+ part of a key's identity (`foreignKeyTarget` ignores it, pinned by `foreign-key.test.ts`), the
517
+ constraint is there, and what changed is what happens to the child rows. Its `fix` is the drop/add
518
+ pair built from `dropForeignKey`/`addForeignKey`, not `x db migrate` — a rule cannot be altered in
519
+ place and no `x db gen` diff emits one for a schema already applied. Before
466
520
  this, `snapshotOf` recorded `foreignKeys: []` beside an `up` emitting `references "orgs" ("id")` — a
467
521
  snapshot denying a constraint its own migration creates — so `alter table … drop constraint` on the
468
522
  database answered `ok: true`.
@@ -481,7 +535,32 @@ referencing each other cannot be expressed inline in any order, and separate con
481
535
  order at all. The same call site answers the other half — a `references()` added to a column that
482
536
  already exists now emits its `add constraint`, where before `up` came out **empty**, `x db gen`
483
537
  wrote no file, and `x verify`'s drift step stayed red forever with `x db gen "…"` as a fix that did
484
- nothing. Removing a `references()` still emits nothing, exactly as a removed index does.
538
+ nothing.
539
+
540
+ **`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
541
+ emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
542
+ stayed on the database **and** the record denied one the catalog holds, which `compareForeignKeys`
543
+ can never see because it judges the declared side. That is not parity with a removed index: a
544
+ removed index leaves the snapshot correct by omission, and this snapshot lied. The drop names the
545
+ constraint **the previous snapshot recorded**, never the one this generator would have chosen — a
546
+ hand-written `fk_legacy` is `42704` under the generated spelling — and a key whose columns this
547
+ migration is dropping is skipped, because `drop column` takes the constraint with it. A key whose
548
+ `onDelete` moved is a drop **and** an add, the rebuild `redefineIndex` performs for the parts of an
549
+ index Postgres cannot alter in place.
550
+
551
+ **`on delete` reaches the SQL, `As of 2026-08-19`.** `entity()` has carried
552
+ `references(() => orgs.id, { onDelete: 'cascade' })` since 1.0, it type-checked, and the clause it
553
+ produced was `references "orgs" ("id");` — a declared cascade the database refused the delete
554
+ under instead. It was lost twice over: `describeColumn` renders `references` as the flat string
555
+ `"orgs.id"`, which has no room for it, and `ReferenceDescription` had no field for it either. Both
556
+ carry it now, `addForeignKey` writes it out, and a rule Postgres does not have is `X_INVARIANT`
557
+ rather than spliced DDL — the discipline `createIndex` already applies to an index naming no column.
558
+
559
+ **`entity-shape.ts` holds the three `*Like` interfaces**, split out of `generate.ts` for the line
560
+ ceiling and along the seam the tier already draws: they are the structural mirror of
561
+ `@ultimat3/entity`'s description, which is how a snapshot crosses tier 2 → tier 1 with no import.
562
+ `ColumnDescriptionLike.onDelete` is optional for exactly that reason — a description written before
563
+ the field existed still satisfies the shape.
485
564
 
486
565
  **`snapshot-json.ts` writes the sidecar's bytes, and they must be a fixed point of Biome.** A
487
566
  scaffolded app's `lint` step is `biome check .` over `"includes": ["**"]`, and `.sql`/`.hash` are
@@ -562,6 +641,20 @@ itself all reach the caller, and every caller must handle that. Layers 3–4 (pr
562
641
  policy) live in `@ultimat3/mcp`, which must still never import this package — the CLI wires the
563
642
  two together.
564
643
 
644
+ **`libpq-options.ts` merges the framework's `options` into the operator's, and `connectionUrl` may
645
+ not `set` that key again.** `DATABASE_URL` is the operator's file: `url.searchParams.set('options',
646
+ …)` REPLACED whatever they had written, and only on the roles whose `statementTimeoutMs` is
647
+ non-zero — so `?options=-c search_path=app` survived on `migrate` and `replicator` and was dropped
648
+ on `web`, `sync`, `worker` and `scheduler`, i.e. the role that runs the migrations and the role that
649
+ serves the traffic looked at different schemas with nothing reporting it. Precedence is **the
650
+ framework wins on the names it sets, the operator keeps every other flag**, and it is enforced by
651
+ removing those names from the operator's tokens before appending, never by position: "the last `-c`
652
+ wins" is backend argument-order behaviour nobody here measured. The bound is emitted for all six
653
+ roles including the two whose value is `0` — `0` is `migrate` saying it may take as long as it
654
+ takes, and left unsaid an `alter database … set statement_timeout` on the server kills the one role
655
+ that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
656
+ survives the round trip whole.
657
+
565
658
  ```bash
566
659
  bun test # from packages/db
567
660
  bun run typecheck
package/README.md CHANGED
@@ -32,17 +32,17 @@ await withTransaction(async (tx) => {
32
32
  | `sqlState()` / `sqlStateCode()` / `isRetryableState()` / `SQLSTATE` | `As of 2026-08`: the SQLSTATE a driver error carries, and the closed table from it to a code. `Bun.SQL` puts it on `errno`; PGlite puts it on `code`; **one** reader answers for both |
33
33
  | `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
34
34
  | `statementsOf()` | `As of 2026-08`: a SQL script → the statements a driver sends one at a time. One send is one statement, so `migrate()` splits with this — a `;` inside a literal, an identifier, a dollar-quoted body or a comment is data |
35
- | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes, and declared foreign keys, matched on where the key points and not on its constraint name |
35
+ | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (columns, uniqueness, direction, and whether a predicate is there at all — never its text) 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` |
36
36
  | `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 |
37
37
  | `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 |
38
38
  | `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 |
39
39
  | `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
40
40
  | `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
41
- | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control |
41
+ | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all |
42
42
  | `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 |
43
43
  | `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 |
44
44
  | `introspect()` | live schema → `SchemaDescription` |
45
- | `createBranch()` / `dropBranch()` / `reapBranches()` | copy-on-write branch databases |
45
+ | `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 |
46
46
  | `createPgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
47
47
  | `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
48
48
  | `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
@@ -136,11 +136,15 @@ X_DB_DRIFT: schema differs from migrations
136
136
  | migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
137
137
  | live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` |
138
138
  | migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
139
+ | index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
140
+ | foreign key, rule moved | `foreign key on "T" (C) to "R" is on delete cascade, not what migrations declare` | the `drop constraint` + `add constraint` pair, in a new migration |
139
141
 
140
- `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` reports
141
- them and exits non-zero; a `ROLE=migrate` container logs them and still exits 0, because its
142
- contract is "apply every migration, then exit" and a schema difference after a clean apply is a
143
- diagnostic, not a failed migration.
142
+ `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
143
+ them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
144
+ (`assertNoDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel
145
+ the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
146
+ drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
147
+ detector (`checkSourceDrift`), which needs no database and never calls this.
144
148
 
145
149
  ## The embedded database
146
150
 
@@ -198,6 +202,16 @@ does reach the backend: `client.live.test.ts` asserts `current_setting('statemen
198
202
  is the only reading a DSN test cannot fake. `Bun.SQL` is reached lazily, so importing this package
199
203
  never opens a socket.
200
204
 
205
+ **That setting is MERGED into the operator's own `options`, never assigned over them** (`As of
206
+ 2026-08`). `?options=-c search_path=app` in `DATABASE_URL` survives on every role, and the role's
207
+ `statement_timeout` is appended to it; if the URL sets `statement_timeout` itself, the **role
208
+ wins** — it is a bound the pool is sized around — and every other flag is kept. It is emitted for
209
+ all six roles, `migrate`'s and `replicator`'s `0` included: `0` is "this role may take as long as
210
+ it takes", and left unsaid a server-side `alter database … set statement_timeout` would kill the
211
+ one role that has to outlive it. Before this, `set` replaced the whole value and only on the roles
212
+ with a non-zero timeout, so a `search_path` survived on `migrate` and vanished on `web` — the role
213
+ that runs the migrations and the role that serves the traffic reading different schemas.
214
+
201
215
  **`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
202
216
  can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
203
217
  value that is not a positive integer refuses at boot rather than falling back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0"
34
+ "@ultimat3/core": "4.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
package/src/branch.ts CHANGED
@@ -10,15 +10,43 @@ import { identifier, literal, sql } from './sql';
10
10
 
11
11
  const BRANCH_NAME = /^[a-z0-9_-]+$/;
12
12
 
13
- /** Written as a database comment at creation time — `pg_database` has no created_at. */
13
+ /**
14
+ * Written as a database comment at creation time — `pg_database` has no created_at, and no
15
+ * template lineage either. The payload is `<base>:<iso>`: the database this clone came from, then
16
+ * the instant it was made, always UTC through `toISOString()`.
17
+ *
18
+ * The base is half the marker because nothing else can answer "whose branch is this". Postgres
19
+ * records no lineage in the catalog and `datdba` is shared when both apps connect as one role, so
20
+ * a reaper reading the marker alone sweeps every Ultimate app on the server (issue #133).
21
+ */
14
22
  const BRANCH_MARKER = 'ultimate:branch:';
15
23
 
24
+ /**
25
+ * `<base>:<iso>`, split on the ISO tail rather than on the first `:` — a database name may contain
26
+ * one and an ISO instant certainly does. A comment that does not match is a pre-4.x one-segment
27
+ * marker: its date is still readable, and its base is `null`, which is what keeps `reapBranches`
28
+ * off it. Unknown, never guessed at.
29
+ */
30
+ const MARKER_PAYLOAD = /^(.*):(\d{4,}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/;
31
+
16
32
  export interface BranchInfo {
17
33
  readonly name: string;
34
+ /** The database this branch was cloned from. `null` on a branch made before 4.0 recorded it. */
35
+ readonly base: string | null;
18
36
  readonly createdAt: string | null;
19
37
  readonly sizeBytes: number;
20
38
  }
21
39
 
40
+ function parseMarker(comment: string): Pick<BranchInfo, 'base' | 'createdAt'> {
41
+ const payload = comment.slice(BRANCH_MARKER.length);
42
+ const parts = MARKER_PAYLOAD.exec(payload);
43
+ const [, base, createdAt] = parts ?? [];
44
+ if (base === undefined || createdAt === undefined) {
45
+ return { base: null, createdAt: payload === '' ? null : payload };
46
+ }
47
+ return { base: base === '' ? null : base, createdAt };
48
+ }
49
+
22
50
  export function assertBranchName(branch: string): string {
23
51
  if (!BRANCH_NAME.test(branch)) throw branchNameInvalid(branch);
24
52
  return branch;
@@ -54,9 +82,11 @@ export async function createBranch(
54
82
  await client.execute(sql`create database ${identifier(branch)} template ${identifier(base)}`);
55
83
  const createdAt = (options.now ?? systemClock.now()).toISOString();
56
84
  await client.execute(
57
- sql`comment on database ${identifier(branch)} is ${literal(`${BRANCH_MARKER}${createdAt}`)}`,
85
+ sql`comment on database ${identifier(branch)} is ${literal(
86
+ `${BRANCH_MARKER}${base}:${createdAt}`,
87
+ )}`,
58
88
  );
59
- return { name: branch, createdAt, sizeBytes: 0 };
89
+ return { name: branch, base, createdAt, sizeBytes: 0 };
60
90
  }
61
91
 
62
92
  export async function currentDatabase(client: DbClient = baseClient()): Promise<string> {
@@ -85,7 +115,7 @@ export async function listBranches(options: BranchOptions = {}): Promise<readonl
85
115
  .filter((row) => row.comment?.startsWith(BRANCH_MARKER) === true)
86
116
  .map((row) => ({
87
117
  name: row.name,
88
- createdAt: row.comment?.slice(BRANCH_MARKER.length) ?? null,
118
+ ...parseMarker(row.comment ?? ''),
89
119
  sizeBytes: Number(row.size_bytes),
90
120
  }));
91
121
  }
@@ -127,14 +157,36 @@ export interface ReapOptions extends DropBranchOptions {
127
157
  readonly maxAgeMs: number;
128
158
  }
129
159
 
130
- /** Preview environments leak branches; this is what the nightly `reapBranches` task calls. */
160
+ /**
161
+ * Preview environments leak branches; this is what the nightly `reapBranches` task calls.
162
+ *
163
+ * **Branches of THIS database only** (issue #133). `listBranches` walks `pg_database` for the whole
164
+ * server, so two Ultimate apps sharing one Postgres plus one nightly sweep was the other app's
165
+ * branches dropped — a `DROP DATABASE` nothing recovers from and nobody asked for. A branch whose
166
+ * base is not this database is skipped, and so is a pre-4.x marker that records no base at all:
167
+ * a branch of nothing is not a branch of this database, which makes the change self-healing with
168
+ * no migration — the next `createBranch` writes the base down.
169
+ */
131
170
  export async function reapBranches(options: ReapOptions): Promise<readonly string[]> {
132
171
  const cutoff = (options.now ?? systemClock.now()).getTime() - options.maxAgeMs;
133
- const branches = await listBranches(options);
172
+ const client = options.client ?? baseClient();
173
+ const here = await currentDatabase(client);
174
+ const branches = await listBranches({ ...options, client });
134
175
  const dropped: string[] = [];
135
176
  for (const branch of branches) {
177
+ if (branch.base !== here) continue;
136
178
  if (branch.createdAt === null) continue;
137
- if (new Date(branch.createdAt).getTime() > cutoff) continue;
179
+ const createdAtMs = Date.parse(branch.createdAt);
180
+ // `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
181
+ // truncated or hand-edited comment used to be a database DROPPED on the next sweep, whatever
182
+ // `maxAgeMs` said. An age nothing can read is not an old age.
183
+ if (!Number.isFinite(createdAtMs)) continue;
184
+ // Finite is not enough: `'2026-08-18T10:00'` parses as LOCAL time, so a truncated comment
185
+ // names an instant hours from the one it reads as, and the sweep acts on a date nobody wrote.
186
+ // `createBranch` writes `toISOString()` and nothing else does, so a value that does not round
187
+ // trip through it is not ours — there is no legitimate non-canonical comment to strand.
188
+ if (new Date(createdAtMs).toISOString() !== branch.createdAt) continue;
189
+ if (createdAtMs > cutoff) continue;
138
190
  await dropBranch(branch.name, options);
139
191
  dropped.push(branch.name);
140
192
  }
package/src/client.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  // pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module
4
4
  // never opens a socket (the CLI imports it to print help).
5
5
 
6
- import { type Role, resolveRole } from '@ultimat3/core';
6
+ import { type Role, renderThrowable, resolveRole } from '@ultimat3/core';
7
7
  import { statementAttribution } from './attribution';
8
8
  import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors';
9
9
  import { expectedQueryLoopReason } from './expected-loop';
10
+ import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options';
10
11
  import { statementObserver } from './observe';
11
12
  import { type SqlFragment, sql } from './sql';
12
13
  import { withStatementSpan } from './statement-span';
@@ -172,11 +173,32 @@ function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): st
172
173
  } catch (error) {
173
174
  throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
174
175
  }
175
- // libpq `options` is the portable way to pin a statement timeout for every pooled connection.
176
- if (profile.statementTimeoutMs > 0) {
177
- url.searchParams.set('options', `-c statement_timeout=${profile.statementTimeoutMs}`);
178
- }
179
- url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
176
+ // libpq `options` is the portable way to pin a statement timeout for every pooled connection
177
+ // MERGED into the operator's own, never assigned over it, and emitted for every role including
178
+ // the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
179
+ // `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
180
+ // migrations and the role that serves the traffic read different schemas. 0 is a value, not a
181
+ // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
182
+ // `alter database ... set statement_timeout` kills the one role that must outlive it.
183
+ // `application_name` is a LABEL, not a bound: 'ultimate' is a DEFAULT, and a default may not
184
+ // overwrite what the operator wrote — `?application_name=billing-api` is the filter their
185
+ // `pg_stat_activity` query, their pooler rule and their audit rule all match on, and losing it
186
+ // is silent. Both spellings count, or the URL parameter and a `-c application_name=` in
187
+ // `options` disagree and which one the backend honours is argument order nobody here measured.
188
+ const named = options.applicationName;
189
+ const settings: Record<string, string> = {
190
+ statement_timeout: String(profile.statementTimeoutMs),
191
+ };
192
+ const inOptions = declaresLibpqOption(url.searchParams.get('options'), 'application_name');
193
+ // An explicit `applicationName` is a deliberate call by the role that opened the pool, so it
194
+ // wins. Only then is the setting named to the merge, and only when the operator wrote the other
195
+ // spelling: `mergeLibpqOptions` drops their assignment before appending, so the two cannot
196
+ // disagree — and a URL with no assignment in it keeps the exact `options` it always had.
197
+ if (named !== undefined && inOptions) settings['application_name'] = named;
198
+ const declared = url.searchParams.has('application_name') || inOptions;
199
+ url.searchParams.set('options', mergeLibpqOptions(url.searchParams.get('options'), settings));
200
+ if (named !== undefined) url.searchParams.set('application_name', named);
201
+ else if (!declared) url.searchParams.set('application_name', 'ultimate');
180
202
  return url.toString();
181
203
  }
182
204
 
@@ -447,7 +469,9 @@ export async function checkDb(client: DbClient = baseClient()): Promise<DbHealth
447
469
  return {
448
470
  ok: false,
449
471
  latencyMs: Math.round(performance.now() - started),
450
- error: error instanceof Error ? error.message : String(error),
472
+ // `renderThrowable`, never `error.message`: the probe wants a report, and a render that
473
+ // throws is an exception out of `/readyz` — the one caller that cannot catch it.
474
+ error: renderThrowable(error),
451
475
  };
452
476
  }
453
477
  }
package/src/drift.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { baseClient, type DbClient } from './client';
7
7
  import { DbError } from './errors';
8
- import { foreignKeyTarget } from './foreign-key';
8
+ import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
9
9
  import {
10
10
  type ForeignKeyDescription,
11
11
  findTable,
@@ -24,7 +24,8 @@ export type DriftKind =
24
24
  | 'unknown-schema'
25
25
  | 'missing-index'
26
26
  | 'changed-index'
27
- | 'missing-foreign-key';
27
+ | 'missing-foreign-key'
28
+ | 'changed-foreign-key';
28
29
 
29
30
  export interface DriftDifference {
30
31
  readonly kind: DriftKind;
@@ -165,6 +166,37 @@ function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDiff
165
166
  };
166
167
  }
167
168
 
169
+ /**
170
+ * The key points where it was declared to point and one side's `on delete` rule is not the other's
171
+ * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
172
+ * there, and what changed is what happens to the child rows.
173
+ *
174
+ * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
175
+ * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
176
+ * naming a command would send a reader to one that generates an empty migration. Same reasoning
177
+ * as `changedColumn`.
178
+ */
179
+ function changedForeignKey(
180
+ table: string,
181
+ declared: ForeignKeyDescription,
182
+ held: ForeignKeyDescription,
183
+ ): DriftDifference {
184
+ const rule = onDeleteRule(held.onDelete);
185
+ return {
186
+ kind: 'changed-foreign-key',
187
+ table,
188
+ column: null,
189
+ cause:
190
+ `foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
191
+ `"${declared.referencedTable}" ` +
192
+ `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
193
+ 'migrations declare',
194
+ fix:
195
+ `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}` +
196
+ ' # in a new migration',
197
+ };
198
+ }
199
+
168
200
  /**
169
201
  * Indexes migrations declare, against the ones the catalog holds — by column list and by
170
202
  * uniqueness, which is what caught a composite index rebuilt with its columns the other way round
@@ -176,11 +208,17 @@ function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDiff
176
208
  * would be eight findings against a correct database, which is how a drift check earns being
177
209
  * ignored (`appTables` exists for the same reason).
178
210
  *
179
- * The predicate and the direction are deliberately **not** compared: the catalog returns its own
180
- * rewriting of an expression (`(deleted_at IS NULL)`) and a snapshot holds the author's spelling,
181
- * so a text comparison reports drift on two identical indexes. `x db gen` compares them instead,
182
- * where both sides are generated see `redefineIndex` in `generate.ts`. Named in
183
- * `wiki/Known-Gaps.md`.
211
+ * Three of the four parts of an index are compared here; the fourth never will be. The predicate's
212
+ * **text** is uncomparable and stays so the catalog answers its own rewriting of the expression
213
+ * (`(deleted_at IS NULL)`) where the snapshot holds the author's spelling (`deleted_at is null`),
214
+ * and normalising that means shipping an expression parser to compete with the server's. `x db gen`
215
+ * compares the text instead, where both sides are generated (`redefineIndex`).
216
+ *
217
+ * Its **presence** is not text but a boolean, and the direction is a closed enum on both sides, so
218
+ * both are compared: a partial index recreated as a total one silently widens the constraint, and
219
+ * a `desc` index rebuilt ascending by hand serves a feed's newest page off the wrong end. `asc` is
220
+ * normalised to `null` first — `createIndex` writes `"col" asc`, which Postgres stores as
221
+ * not-descending, so the raw values differ on every ascending index in a correct database.
184
222
  */
185
223
  function compareIndexes(live: TableDescription, expected: TableDescription): DriftDifference[] {
186
224
  const differences: DriftDifference[] = [];
@@ -201,6 +239,26 @@ function compareIndexes(live: TableDescription, expected: TableDescription): Dri
201
239
  differences.push(
202
240
  changedIndex(live.name, index.name, counterpart.unique ? 'is unique' : 'is not unique'),
203
241
  );
242
+ continue;
243
+ }
244
+ if ((counterpart.order ?? 'asc') !== (index.order ?? 'asc')) {
245
+ differences.push(
246
+ changedIndex(
247
+ live.name,
248
+ index.name,
249
+ counterpart.order === 'desc' ? 'is descending' : 'is ascending',
250
+ ),
251
+ );
252
+ continue;
253
+ }
254
+ if ((counterpart.where === null) !== (index.where === null)) {
255
+ differences.push(
256
+ changedIndex(
257
+ live.name,
258
+ index.name,
259
+ counterpart.where === null ? 'covers every row' : 'is partial',
260
+ ),
261
+ );
204
262
  }
205
263
  }
206
264
  return differences;
@@ -213,17 +271,31 @@ function compareIndexes(live: TableDescription, expected: TableDescription): Dri
213
271
  * and a constraint that points the same columns at the same table is the same constraint whatever
214
272
  * it is called; comparing the name would report drift on a database that is exactly right.
215
273
  *
216
- * `onDelete` is not compared either: the catalog spells it as a single character (`a`, `c`, `r`)
217
- * and no generated clause declares one, so a snapshot has nothing truthful to hold there. Only the
218
- * declared side is judged, for the reason `compareIndexes` gives. Named in `wiki/Known-Gaps.md`.
274
+ * `onDelete` **is** compared now, through `onDeleteRule`. It is not part of a key's identity a
275
+ * key is the same key under a different rule, which is why the difference is `changed-foreign-key`
276
+ * rather than one `missing` — but it is a fact about the database a snapshot records truthfully
277
+ * since `addForeignKey` spells the clause out. Both sides go through the same normalisation: the
278
+ * catalog answers `c` where a snapshot holds `cascade`, and Postgres records `a` (`no action`) on
279
+ * every key that declared nothing.
280
+ *
281
+ * Only the declared side is judged, for the reason `compareIndexes` gives.
219
282
  */
220
283
  function compareForeignKeys(live: TableDescription, expected: TableDescription): DriftDifference[] {
221
284
  // The same identity `x db gen` diffs on (`foreign-key.ts`): a generator and a detector that
222
285
  // disagreed about whether two keys are the same key is drift on a correct database.
223
- const present = new Set(live.foreignKeys.map(foreignKeyTarget));
224
- return expected.foreignKeys
225
- .filter((key) => !present.has(foreignKeyTarget(key)))
226
- .map((key) => missingForeignKey(live.name, key));
286
+ const present = new Map(live.foreignKeys.map((key) => [foreignKeyTarget(key), key] as const));
287
+ const differences: DriftDifference[] = [];
288
+ for (const key of expected.foreignKeys) {
289
+ const counterpart = present.get(foreignKeyTarget(key));
290
+ if (counterpart === undefined) {
291
+ differences.push(missingForeignKey(live.name, key));
292
+ continue;
293
+ }
294
+ if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
295
+ differences.push(changedForeignKey(live.name, key, counterpart));
296
+ }
297
+ }
298
+ return differences;
227
299
  }
228
300
 
229
301
  /**
@@ -294,7 +366,13 @@ export function driftError(difference: DriftDifference): DbError {
294
366
  });
295
367
  }
296
368
 
297
- /** Throws the first difference. `x verify` calls this; `x db drift --json` reads the report. */
369
+ /**
370
+ * Throws the first difference. The one caller is the release phase — `runRole` in `@ultimat3/cli`
371
+ * under `ROLE=migrate`, where the exit code is the only channel a container has. `x db migrate`
372
+ * and `x db reset` hold the same report and render every difference as a finding instead
373
+ * (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
374
+ * which never reaches this function. There is no `x db drift` command.
375
+ */
298
376
  export function assertNoDrift(report: DriftReport): void {
299
377
  const first = report.differences[0];
300
378
  if (first !== undefined) throw driftError(first);
@@ -0,0 +1,49 @@
1
+ // Single responsibility: the structural mirror of `@ultimat3/entity`'s entity description. `db` is
2
+ // tier 1 and may never import `entity` (tier 2), so a snapshot arrives as a parameter and every
3
+ // part of it — a column's `on delete` rule included — crosses the seam by shape or not at all.
4
+
5
+ /** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
6
+ export interface ColumnDescriptionLike {
7
+ readonly property: string;
8
+ readonly column: string;
9
+ readonly kind: string;
10
+ readonly notNull: boolean;
11
+ readonly primaryKey: boolean;
12
+ readonly unique: boolean;
13
+ readonly hasDefault: boolean;
14
+ readonly check: string | null;
15
+ readonly references: string | null;
16
+ /**
17
+ * The `references()` rule, `null` or absent when none was declared. Optional so a description
18
+ * written before it existed still satisfies the shape — this package cannot import `entity`, so
19
+ * the field travelling structurally is the *only* way the rule crosses the tier boundary.
20
+ */
21
+ readonly onDelete?: string | null | undefined;
22
+ }
23
+
24
+ /**
25
+ * Structurally assignment-compatible with `@ultimat3/entity`'s `IndexDescription`.
26
+ *
27
+ * The column list is carried, never recovered from `name`. Entity names an index
28
+ * `<table>_<a>_<b>_idx`, and that convention does not run backwards: two columns joined by `_`
29
+ * are one string, so a composite index read back out of its own name became the single column
30
+ * `"org_id_created_at"` — DDL Postgres answers `42703` and a migration nobody can apply.
31
+ */
32
+ export interface IndexDescriptionLike {
33
+ readonly name: string;
34
+ readonly columns: readonly string[];
35
+ readonly unique: boolean;
36
+ /** Partial index predicate as SQL, `null` when the index covers every row. */
37
+ readonly where: string | null;
38
+ /** `null` is Postgres' own default (`asc`), never written out. */
39
+ readonly order: 'asc' | 'desc' | null;
40
+ }
41
+
42
+ /** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
43
+ export interface EntityDescriptionLike {
44
+ readonly name: string;
45
+ readonly table: string;
46
+ readonly primaryKey: readonly string[];
47
+ readonly columns: readonly ColumnDescriptionLike[];
48
+ readonly indexes: readonly IndexDescriptionLike[];
49
+ }
package/src/errors.ts CHANGED
@@ -32,8 +32,17 @@ export const DB_OWNED_ERROR_CODES = [
32
32
  * `@ultimat3/core`'s. Never titled here, never registered here. `X_ENV_MISSING` is core's word for
33
33
  * "a variable this process was given is missing or invalid", and `DATABASE_POOL_MAX` is one — a
34
34
  * db-local code for it would be a second answer to a question core already answers.
35
+ *
36
+ * `X_INVARIANT` is core's own "the generic code, for checks that have no dedicated code yet"
37
+ * (`assert()` in `core/src/assert.ts`), borrowed the same way `@ultimat3/money`'s `roundRatio`
38
+ * borrows it: an argument a caller built wrong is not a fact about the ledger or the schema, so
39
+ * none of the `X_MIGRATION_*` codes above describes one.
35
40
  */
36
- export const DB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ENV_MISSING'] as const;
41
+ export const DB_BORROWED_ERROR_CODES = [
42
+ 'X_NOT_IMPLEMENTED',
43
+ 'X_ENV_MISSING',
44
+ 'X_INVARIANT',
45
+ ] as const;
37
46
 
38
47
  /** Every code db can throw: the ones it owns plus the ones it borrows. */
39
48
  export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
@@ -249,6 +258,21 @@ export const migrationConflict = (cause: string, fix: string): DbError =>
249
258
  export const migrationIrreversible = (cause: string, fix: string): DbError =>
250
259
  new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
251
260
 
261
+ /**
262
+ * A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
263
+ * negative count counts from the END: `steps: -1` selected every applied migration except the
264
+ * newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
265
+ * Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
266
+ * as a different one is the failure a validated argument exists to prevent.
267
+ */
268
+ export const rollbackStepsInvalid = (received: number): DbError =>
269
+ new DbError({
270
+ code: 'X_INVARIANT',
271
+ cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
272
+ fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
273
+ meta: { steps: received },
274
+ });
275
+
252
276
  /**
253
277
  * `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
254
278
  * every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
@@ -2,8 +2,35 @@
2
2
  // add or drop one. `generate.ts` writes them and `drift.ts` compares them, and a generator that
3
3
  // disagreed with a detector about whether two keys are the same key is drift on a correct database.
4
4
 
5
+ import { assert } from '@ultimat3/core';
5
6
  import type { ForeignKeyDescription } from './introspect';
6
7
 
8
+ /** `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name. */
9
+ const CATALOG_RULES: Readonly<Record<string, string>> = {
10
+ a: 'no action',
11
+ c: 'cascade',
12
+ r: 'restrict',
13
+ n: 'set null',
14
+ d: 'set default',
15
+ };
16
+
17
+ /**
18
+ * One `on delete` vocabulary for both sides. The catalog spells the rule as a single character and
19
+ * a description spells it out, so a comparison between them needs one of them translated — and
20
+ * translating both through the same total function keeps it idempotent, which is what lets either
21
+ * side be normalised without knowing where it came from.
22
+ *
23
+ * `no action` is `null`: Postgres records the default on **every** key, so reading it as a
24
+ * declared rule reports a difference against every constraint whose snapshot never spelled one.
25
+ */
26
+ export function onDeleteRule(raw: string | null): string | null {
27
+ if (raw === null) return null;
28
+ const named = CATALOG_RULES[raw] ?? raw.toLowerCase();
29
+ return named === 'no action' ? null : named;
30
+ }
31
+
32
+ const WRITABLE = new Set(['cascade', 'restrict', 'set null', 'set default']);
33
+
7
34
  /**
8
35
  * A key's identity: its columns, its target table, its target columns — never its name. Postgres
9
36
  * names an inline `references` clause `<table>_<column>_fkey` and a hand-written migration may have
@@ -26,12 +53,25 @@ const quoted = (names: readonly string[]): string => names.map((name) => `"${nam
26
53
  *
27
54
  * The constraint is **named** here rather than left to Postgres' own convention, so the name the
28
55
  * snapshot beside it records is a name this migration wrote and not a name it guessed.
56
+ *
57
+ * `on delete` is written out, and it had never been: `entity()` has carried the option since 1.0,
58
+ * it type-checked, and the clause it reached was `references "orgs" ("id");` — a declared cascade
59
+ * that the database refuses the delete under instead. A rule Postgres does not have is
60
+ * `X_INVARIANT` rather than DDL, the same discipline `createIndex` applies to an index naming no
61
+ * column: `entity()`'s option is a closed union, so only a hand-built description can get here.
29
62
  */
30
63
  export function addForeignKey(table: string, key: ForeignKeyDescription): string {
64
+ const rule = onDeleteRule(key.onDelete);
65
+ assert(
66
+ rule === null || WRITABLE.has(rule),
67
+ `foreign key "${key.name}" on "${table}" declares an unknown on delete rule`,
68
+ `references(() => target.id, { onDelete: 'cascade' }) # cascade | restrict | set null`,
69
+ );
31
70
  return (
32
71
  `alter table "${table}" add constraint "${key.name}" ` +
33
72
  `foreign key (${quoted(key.columns)}) ` +
34
- `references "${key.referencedTable}" (${quoted(key.referencedColumns)});`
73
+ `references "${key.referencedTable}" (${quoted(key.referencedColumns)})` +
74
+ `${rule === null ? '' : ` on delete ${rule}`};`
35
75
  );
36
76
  }
37
77
 
package/src/generate.ts CHANGED
@@ -5,8 +5,13 @@
5
5
 
6
6
  import { assert, systemClock } from '@ultimat3/core';
7
7
  import { isDestructive } from './destructive';
8
+ import type {
9
+ ColumnDescriptionLike,
10
+ EntityDescriptionLike,
11
+ IndexDescriptionLike,
12
+ } from './entity-shape';
8
13
  import { migrationIrreversible } from './errors';
9
- import { addForeignKey, dropForeignKey, foreignKeyTarget } from './foreign-key';
14
+ import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
10
15
  import {
11
16
  type ColumnDescription,
12
17
  type ForeignKeyDescription,
@@ -16,46 +21,6 @@ import {
16
21
  type TableDescription,
17
22
  } from './introspect';
18
23
 
19
- /** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
20
- export interface ColumnDescriptionLike {
21
- readonly property: string;
22
- readonly column: string;
23
- readonly kind: string;
24
- readonly notNull: boolean;
25
- readonly primaryKey: boolean;
26
- readonly unique: boolean;
27
- readonly hasDefault: boolean;
28
- readonly check: string | null;
29
- readonly references: string | null;
30
- }
31
-
32
- /**
33
- * Structurally assignment-compatible with `@ultimat3/entity`'s `IndexDescription`.
34
- *
35
- * The column list is carried, never recovered from `name`. Entity names an index
36
- * `<table>_<a>_<b>_idx`, and that convention does not run backwards: two columns joined by `_`
37
- * are one string, so a composite index read back out of its own name became the single column
38
- * `"org_id_created_at"` — DDL Postgres answers `42703` and a migration nobody can apply.
39
- */
40
- export interface IndexDescriptionLike {
41
- readonly name: string;
42
- readonly columns: readonly string[];
43
- readonly unique: boolean;
44
- /** Partial index predicate as SQL, `null` when the index covers every row. */
45
- readonly where: string | null;
46
- /** `null` is Postgres' own default (`asc`), never written out. */
47
- readonly order: 'asc' | 'desc' | null;
48
- }
49
-
50
- /** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
51
- export interface EntityDescriptionLike {
52
- readonly name: string;
53
- readonly table: string;
54
- readonly primaryKey: readonly string[];
55
- readonly columns: readonly ColumnDescriptionLike[];
56
- readonly indexes: readonly IndexDescriptionLike[];
57
- }
58
-
59
24
  const SQL_TYPES: Readonly<Record<string, string>> = {
60
25
  uuid: 'uuid',
61
26
  text: 'text',
@@ -149,8 +114,9 @@ function impliedByColumnClause(
149
114
  * beside it chose rather than one it guessed. It is still *not* what drift matches on: see
150
115
  * `compareForeignKeys` in `drift.ts`.
151
116
  *
152
- * `onDelete` stays `null`. `entity()` carries the option and no clause here has ever spelled one,
153
- * so a value written down would be a claim about the database that is not true.
117
+ * `onDelete` is the column's own, and `addForeignKey` spells it: a rule recorded here while no
118
+ * clause declared one would be a claim about the database that is not true, which is what it was
119
+ * until the clause learned to write it out.
154
120
  */
155
121
  function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
156
122
  return entity.columns
@@ -162,7 +128,7 @@ function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
162
128
  columns: [column.column],
163
129
  referencedTable: table,
164
130
  referencedColumns: [key],
165
- onDelete: null,
131
+ onDelete: column.onDelete ?? null,
166
132
  };
167
133
  })
168
134
  .sort((a, b) => (a.name < b.name ? -1 : 1));
@@ -251,17 +217,51 @@ interface Plan {
251
217
  * never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
252
218
  * last here puts them *first* on the way back: `drop table "posts"` with `comments` still
253
219
  * referencing it is `2BP01`, a migration that cannot be rolled back at all.
220
+ *
221
+ * Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
222
+ * while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
223
+ * database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
224
+ * declared side, so no drift check could ever see it. Not parity with a removed index either: that
225
+ * leaves the snapshot correct by omission. The drop names the constraint the previous snapshot
226
+ * recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
227
+ * `42704` under the generated spelling.
254
228
  */
255
229
  function foreignKeyPlan(
256
230
  entity: EntityDescriptionLike,
257
231
  live: TableDescription | undefined,
258
232
  constraints: Plan,
259
233
  ): void {
260
- const held = new Set((live?.foreignKeys ?? []).map(foreignKeyTarget));
261
- for (const key of foreignKeysOf(entity)) {
262
- if (held.has(foreignKeyTarget(key))) continue;
263
- constraints.up.push(addForeignKey(entity.table, key));
264
- constraints.down.push(dropForeignKey(entity.table, key.name));
234
+ const wanted = foreignKeysOf(entity);
235
+ const held = new Map((live?.foreignKeys ?? []).map((key) => [foreignKeyTarget(key), key]));
236
+ for (const key of wanted) {
237
+ const recorded = held.get(foreignKeyTarget(key));
238
+ if (recorded === undefined) {
239
+ constraints.up.push(addForeignKey(entity.table, key));
240
+ constraints.down.push(dropForeignKey(entity.table, key.name));
241
+ continue;
242
+ }
243
+ // The rule is not part of a key's identity, so the same key under a new one is a rebuild —
244
+ // Postgres has no `alter constraint` for it, the same reason `redefineIndex` recreates.
245
+ if (onDeleteRule(recorded.onDelete) === onDeleteRule(key.onDelete)) continue;
246
+ constraints.up.push(
247
+ dropForeignKey(entity.table, recorded.name),
248
+ addForeignKey(entity.table, key),
249
+ );
250
+ // Pushed forwards and read backwards, like `redefineIndex`: `down` is reversed at assembly.
251
+ constraints.down.push(
252
+ addForeignKey(entity.table, recorded),
253
+ dropForeignKey(entity.table, key.name),
254
+ );
255
+ }
256
+ const declared = new Set(wanted.map(foreignKeyTarget));
257
+ const columns = new Set(entity.columns.map((column) => column.column));
258
+ for (const key of live?.foreignKeys ?? []) {
259
+ if (declared.has(foreignKeyTarget(key))) continue;
260
+ // `drop column` takes the constraint with it, so a `drop constraint` after that statement is
261
+ // `42704` on a constraint that is already gone.
262
+ if (!key.columns.every((column) => columns.has(column))) continue;
263
+ constraints.up.push(dropForeignKey(entity.table, key.name));
264
+ constraints.down.push(addForeignKey(entity.table, key));
265
265
  }
266
266
  }
267
267
 
package/src/index.ts CHANGED
@@ -51,6 +51,11 @@ export {
51
51
  expectedSchema,
52
52
  FRAMEWORK_TABLE_PREFIX,
53
53
  } from './drift';
54
+ export type {
55
+ ColumnDescriptionLike,
56
+ EntityDescriptionLike,
57
+ IndexDescriptionLike,
58
+ } from './entity-shape';
54
59
  export type { DbErrorCode, DbErrorInit } from './errors';
55
60
  export {
56
61
  branchExists,
@@ -71,19 +76,14 @@ export {
71
76
  multipleStatements,
72
77
  poolAcquireTimeout,
73
78
  poolMaxInvalid,
79
+ rollbackStepsInvalid,
74
80
  serializationExhausted,
75
81
  sqlUnsafe,
76
82
  } from './errors';
77
83
  export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
78
84
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
79
85
  export { createRecordingClient } from './fake';
80
- export type {
81
- ColumnDescriptionLike,
82
- EntityDescriptionLike,
83
- GeneratedMigration,
84
- GenerateOptions,
85
- IndexDescriptionLike,
86
- } from './generate';
86
+ export type { GeneratedMigration, GenerateOptions } from './generate';
87
87
  export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
88
88
  export type {
89
89
  ColumnDescription,
@@ -0,0 +1,86 @@
1
+ // Single responsibility: merging the framework's own libpq `options` into whatever the operator
2
+ // already put in `DATABASE_URL`. A connection string is the operator's file, not the framework's,
3
+ // and `searchParams.set` on a key they may have written is a silent overwrite of their setting.
4
+
5
+ /**
6
+ * libpq hands `options` to the backend as command-line arguments, split on whitespace with a
7
+ * backslash escaping the next character. Escapes are kept intact, so re-joining the tokens
8
+ * reproduces the operator's string byte for byte.
9
+ */
10
+ export function splitLibpqOptions(options: string): readonly string[] {
11
+ const tokens: string[] = [];
12
+ let current = '';
13
+ let open = false;
14
+ for (let index = 0; index < options.length; index += 1) {
15
+ const char = options[index] ?? '';
16
+ const escaped = options[index + 1];
17
+ if (char === '\\' && escaped !== undefined) {
18
+ current += `\\${escaped}`;
19
+ open = true;
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (char.trim() === '') {
24
+ if (open) tokens.push(current);
25
+ current = '';
26
+ open = false;
27
+ continue;
28
+ }
29
+ current += char;
30
+ open = true;
31
+ }
32
+ if (open) tokens.push(current);
33
+ return tokens;
34
+ }
35
+
36
+ /**
37
+ * The three spellings a backend accepts for one GUC on the command line: `-c name=value` as two
38
+ * arguments, `-cname=value` as one, and `--name=value` (where a hyphen in the name reads as an
39
+ * underscore). A bare `name=value` token is the second half of the first spelling.
40
+ */
41
+ const ASSIGNS = (name: string): RegExp => new RegExp(`^(?:-c|--)?${name.replaceAll('_', '[_-]')}=`);
42
+
43
+ /** Drops every assignment of `name`, and the `-c` that introduced it. */
44
+ function without(tokens: readonly string[], name: string): readonly string[] {
45
+ const assigns = ASSIGNS(name);
46
+ const kept: string[] = [];
47
+ for (const token of tokens) {
48
+ if (!assigns.test(token)) {
49
+ kept.push(token);
50
+ continue;
51
+ }
52
+ if (kept.at(-1) === '-c') kept.pop();
53
+ }
54
+ return kept;
55
+ }
56
+
57
+ /**
58
+ * Whether the operator already set `name` in their own `options` — the second spelling of a
59
+ * setting that also has a URL parameter. A framework DEFAULT may not overwrite either one, and
60
+ * a default that checks only the parameter leaves the two spellings free to disagree.
61
+ */
62
+ export function declaresLibpqOption(existing: string | null, name: string): boolean {
63
+ const assigns = ASSIGNS(name);
64
+ return splitLibpqOptions(existing ?? '').some((token) => assigns.test(token));
65
+ }
66
+
67
+ /**
68
+ * The operator's `options` with the framework's settings merged in.
69
+ *
70
+ * **Precedence: the framework wins on the settings it names, the operator keeps everything else.**
71
+ * A role's `statement_timeout` is a safety bound the role is sized around — `web`'s 10s is what
72
+ * stops a slow endpoint holding all 20 pool slots — so a value in the URL may not raise it; but a
73
+ * `search_path`, an `application_name` or a `-c` an operator added is theirs and must survive.
74
+ * Enforced by removing the framework's own names before appending, never by position: relying on
75
+ * "the last `-c` wins" would make the bound depend on backend argument order nobody here measured.
76
+ */
77
+ export function mergeLibpqOptions(
78
+ existing: string | null,
79
+ settings: Readonly<Record<string, string>>,
80
+ ): string {
81
+ let tokens = splitLibpqOptions(existing ?? '');
82
+ for (const [name, value] of Object.entries(settings)) {
83
+ tokens = [...without(tokens, name), '-c', `${name}=${value}`];
84
+ }
85
+ return tokens.join(' ');
86
+ }
package/src/migrate.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  isReservable,
12
12
  poolProfileFor,
13
13
  } from './client';
14
- import { migrateConcurrent, migrationConflict } from './errors';
14
+ import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
15
15
  import { expectedQueryLoop } from './expected-loop';
16
16
  import type { SchemaDescription } from './introspect';
17
17
  import { raw, sql } from './sql';
@@ -76,7 +76,10 @@ export interface MigrateOptions {
76
76
  /** The running build. Defaults to `APP_VERSION`, then `dev`. */
77
77
  readonly appVersion?: string | undefined;
78
78
  readonly client?: DbClient | undefined;
79
- /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
79
+ /**
80
+ * Skip the advisory lock, for a database only this process can reach — a branch, a test. No
81
+ * shipped path passes it; every caller in the repo is a test (`migrate-pin.test.ts`).
82
+ */
80
83
  readonly lock?: boolean | undefined;
81
84
  /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
82
85
  readonly lockWaitMs?: number | undefined;
@@ -149,7 +152,13 @@ export function auditLedger(
149
152
  ): void {
150
153
  const known = new Map(migrations.map((migration) => [migration.id, migration]));
151
154
 
152
- const foreign = ledger.filter((row) => !known.has(row.id) && row.app_version !== appVersion);
155
+ // The predicate is "this build does not ship it" and NOTHING else. It used to also require
156
+ // `row.app_version !== appVersion`, which switched the audit off wherever the two agree —
157
+ // `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
158
+ // earlier `dev` build and since deleted was invisible here, and `expectedSchema` then dropped
159
+ // its table from the drift comparison: `ok: true` against a database that still has the table.
160
+ // The version is a detail of the ANSWER, so it moved into the cause.
161
+ const foreign = ledger.filter((row) => !known.has(row.id));
153
162
  const first = foreign[0];
154
163
  if (first !== undefined) {
155
164
  throw migrationConflict(
@@ -374,8 +383,12 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
374
383
  export interface RollbackOptions {
375
384
  readonly migrations: readonly Migration[];
376
385
  readonly client?: DbClient | undefined;
386
+ /** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
377
387
  readonly steps?: number | undefined;
378
- /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
388
+ /**
389
+ * Skip the advisory lock, for a database only this process can reach — a branch, a test. No
390
+ * shipped path passes it; every caller in the repo is a test (`migrate-pin.test.ts`).
391
+ */
379
392
  readonly lock?: boolean | undefined;
380
393
  /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
381
394
  readonly lockWaitMs?: number | undefined;
@@ -387,6 +400,9 @@ export interface RollbackOptions {
387
400
  export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
388
401
  const client = options.client ?? baseClient();
389
402
  const steps = options.steps ?? 1;
403
+ // Before the lock and before the ledger read: `slice(0, -1)` is "all but the newest", not
404
+ // "one fewer", so an unvalidated count reverses migrations nobody asked about.
405
+ if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
390
406
  const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
391
407
  const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
392
408
 
@@ -78,6 +78,9 @@ export async function branchPglite(
78
78
 
79
79
  return {
80
80
  name: branch,
81
+ // The directory this copy came from — the embedded answer to `BranchInfo.base`'s question,
82
+ // "whose branch is this". There is no `pg_database` here, so nothing else could be asked.
83
+ base: from,
81
84
  createdAt: (options.now ?? systemClock.now()).toISOString(),
82
85
  dataDir: to,
83
86
  sizeBytes: await directorySize(to),
@@ -207,6 +207,16 @@ export async function withTransaction<T>(
207
207
  fn: (tx: DbTx) => Promise<T>,
208
208
  options: TransactionOptions = {},
209
209
  ): Promise<T> {
210
+ // Before anything opens. `attempts = retry + 1` turned a negative, fractional or NaN budget into
211
+ // a loop that never ran its body: `fn` was called ZERO times and the caller was handed
212
+ // `X_DB_SERIALIZATION_FAILURE` — "lost its serialization race on all 0 attempts" — for a
213
+ // transaction that was never begun. `Number(process.env.DB_RETRY)` on an unset var is how the
214
+ // NaN arrives, and the nested branch below already refuses a budget it cannot honour.
215
+ assert(
216
+ options.retry === undefined || (Number.isInteger(options.retry) && options.retry >= 0),
217
+ `withTransaction({ retry }) needs a whole number of extra attempts, 0 or more; a budget that is not one opens nothing and runs fn zero times`,
218
+ "pass an integer — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and parse it before you pass it: Number(process.env.DB_RETRY) is NaN when the variable is unset",
219
+ );
210
220
  const outer = storage.getStore();
211
221
  if (outer !== undefined) {
212
222
  // A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for: