@ultimat3/db 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -149,8 +149,12 @@ pool's idle timeout (`migrate`'s is 10s) closes it, releasing the lock mid-migra
149
149
  hid the first half by accident — its pool is `max: 1`, so every statement found the same connection.
150
150
  No other role and no test has that. The pin is therefore also why the lock scope hands its session
151
151
  *down*: on `max: 1` a statement sent to the pool while the pin is held waits for a connection that
152
- cannot come back until the migration blocking on it finishes. `lock: false` (`x db branch`, a
153
- 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.
154
158
 
155
159
  Pinned by `migrate.live.test.ts` against a real Postgres: two concurrent `migrate()` calls (one
156
160
  applies, the other skips — never both, never a unique-violation crash) and a migration that fails
@@ -335,12 +339,27 @@ a bad argument is not a fact about the ledger), thrown by `rollbackStepsInvalid`
335
339
  lock is taken and before the ledger is read. Same discipline as `poolMaxInvalid`: a number this
336
340
  build cannot honour is refused, never reinterpreted.
337
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
+
338
357
  **`reapBranches` skips a `createdAt` it cannot parse; it never reads one as infinitely old.**
339
358
  `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
340
359
  `COMMENT ON DATABASE` that was truncated or hand-edited used to be a database DROPPED on the next
341
360
  nightly sweep whatever `maxAgeMs` said. `Date.parse` + `Number.isFinite`, the discipline
342
- `@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Distinct from the open
343
- source-blindness of the reaper, issue #133.)
361
+ `@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Whose branches it may touch at
362
+ all is the paragraph above.)
344
363
 
345
364
  **One send is one statement, so `migrate()` and `rollback()` split the script.** `tx.execute(raw(
346
365
  migration.up))` on a text holding two commands is where the two drivers disagreed, and the
@@ -471,11 +490,16 @@ never emitted one and would answer with an empty migration.
471
490
  catches a composite index rebuilt with its columns the other way round while the column diff said
472
491
  `ok: true`. A live index no snapshot names is deliberately **not** reported: Postgres creates one for
473
492
  every primary key and every unique constraint, so counting those is eight findings against a correct
474
- database, the same argument `appTables()` makes. The predicate and the direction are not compared
475
- either the catalog returns its own rewriting of an expression (`(deleted_at IS NULL)`) and the
476
- snapshot holds the author's spelling, so a text comparison reports two identical indexes as drift.
477
- `x db gen` compares them instead (`redefineIndex`), where both sides are generated. Named in
478
- `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.
479
503
 
480
504
  `compareForeignKeys` judges **declared** keys the same way, and matches on **where the key points**
481
505
  — its columns, its target table, its target columns — never on the constraint name. That identity is
@@ -485,8 +509,14 @@ reported on a correct database. `snapshotOf` names a key `<table>_<column>_fkey`
485
509
  would have called an inline `references` clause — and `addForeignKey` now writes that name out, so
486
510
  the snapshot records a name the migration beside it chose rather than one it guessed; a hand-written
487
511
  migration may still have said `constraint fk_posts_org`, and a key pointing the same way under
488
- another name is the same key. `onDelete` is not compared: the catalog spells it `a`/`c`/`r` and no
489
- 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
490
520
  this, `snapshotOf` recorded `foreignKeys: []` beside an `up` emitting `references "orgs" ("id")` — a
491
521
  snapshot denying a constraint its own migration creates — so `alter table … drop constraint` on the
492
522
  database answered `ok: true`.
@@ -505,7 +535,32 @@ referencing each other cannot be expressed inline in any order, and separate con
505
535
  order at all. The same call site answers the other half — a `references()` added to a column that
506
536
  already exists now emits its `add constraint`, where before `up` came out **empty**, `x db gen`
507
537
  wrote no file, and `x verify`'s drift step stayed red forever with `x db gen "…"` as a fix that did
508
- 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.
509
564
 
510
565
  **`snapshot-json.ts` writes the sidecar's bytes, and they must be a fixed point of Biome.** A
511
566
  scaffolded app's `lint` step is `biome check .` over `"includes": ["**"]`, and `.sql`/`.hash` are
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,6 +136,8 @@ 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
142
  `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
141
143
  them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "3.0.0",
3
+ "version": "4.1.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": "3.0.0"
34
+ "@ultimat3/core": "4.1.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,12 +157,24 @@ 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
179
  const createdAtMs = Date.parse(branch.createdAt);
138
180
  // `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
package/src/client.ts CHANGED
@@ -7,7 +7,7 @@ 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 { mergeLibpqOptions } from './libpq-options';
10
+ import { declaresLibpqOption, mergeLibpqOptions } from './libpq-options';
11
11
  import { statementObserver } from './observe';
12
12
  import { type SqlFragment, sql } from './sql';
13
13
  import { withStatementSpan } from './statement-span';
@@ -180,13 +180,25 @@ function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): st
180
180
  // migrations and the role that serves the traffic read different schemas. 0 is a value, not a
181
181
  // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
182
182
  // `alter database ... set statement_timeout` kills the one role that must outlive it.
183
- url.searchParams.set(
184
- 'options',
185
- mergeLibpqOptions(url.searchParams.get('options'), {
186
- statement_timeout: String(profile.statementTimeoutMs),
187
- }),
188
- );
189
- url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
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');
190
202
  return url.toString();
191
203
  }
192
204
 
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
  /**
@@ -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
+ }
@@ -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,
@@ -78,13 +83,7 @@ export {
78
83
  export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
79
84
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
80
85
  export { createRecordingClient } from './fake';
81
- export type {
82
- ColumnDescriptionLike,
83
- EntityDescriptionLike,
84
- GeneratedMigration,
85
- GenerateOptions,
86
- IndexDescriptionLike,
87
- } from './generate';
86
+ export type { GeneratedMigration, GenerateOptions } from './generate';
88
87
  export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
89
88
  export type {
90
89
  ColumnDescription,
@@ -54,6 +54,16 @@ function without(tokens: readonly string[], name: string): readonly string[] {
54
54
  return kept;
55
55
  }
56
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
+
57
67
  /**
58
68
  * The operator's `options` with the framework's settings merged in.
59
69
  *
package/src/migrate.ts CHANGED
@@ -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;
@@ -382,7 +385,10 @@ export interface RollbackOptions {
382
385
  readonly client?: DbClient | undefined;
383
386
  /** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
384
387
  readonly steps?: number | undefined;
385
- /** 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
+ */
386
392
  readonly lock?: boolean | undefined;
387
393
  /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
388
394
  readonly lockWaitMs?: number | undefined;
@@ -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: