@ultimat3/db 3.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 +67 -12
- package/README.md +5 -3
- package/package.json +2 -2
- package/src/branch.ts +48 -6
- package/src/client.ts +20 -8
- package/src/drift.ts +86 -14
- package/src/entity-shape.ts +49 -0
- package/src/foreign-key.ts +41 -1
- package/src/generate.ts +49 -49
- package/src/index.ts +6 -7
- package/src/libpq-options.ts +10 -0
- package/src/migrate.ts +8 -2
- package/src/pglite-branch.ts +3 -0
- package/src/transaction.ts +10 -0
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`
|
|
153
|
-
|
|
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. (
|
|
343
|
-
|
|
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.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
`
|
|
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
|
|
489
|
-
|
|
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.
|
|
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
|
+
"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,12 +157,24 @@ 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);
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
*
|
|
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
|
/**
|
|
@@ -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/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,
|
|
@@ -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,
|
package/src/libpq-options.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
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:
|