@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 +103 -10
- package/README.md +21 -7
- package/package.json +2 -2
- package/src/branch.ts +59 -7
- package/src/client.ts +31 -7
- package/src/drift.ts +93 -15
- package/src/entity-shape.ts +49 -0
- package/src/errors.ts +25 -1
- package/src/foreign-key.ts +41 -1
- package/src/generate.ts +49 -49
- package/src/index.ts +7 -7
- package/src/libpq-options.ts +86 -0
- package/src/migrate.ts +20 -4
- package/src/pglite-branch.ts +3 -0
- package/src/transaction.ts +10 -0
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`
|
|
152
|
-
|
|
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.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
`
|
|
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
|
|
465
|
-
|
|
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.
|
|
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`
|
|
141
|
-
them and exits non-zero; a `ROLE=migrate` container
|
|
142
|
-
|
|
143
|
-
|
|
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": "
|
|
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": "
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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);
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* `
|
|
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
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.
|
|
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
|
-
/**
|
|
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 = [
|
|
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
|
package/src/foreign-key.ts
CHANGED
|
@@ -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`
|
|
153
|
-
*
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
package/src/pglite-branch.ts
CHANGED
|
@@ -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),
|
package/src/transaction.ts
CHANGED
|
@@ -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:
|