@ultimat3/db 8.0.0 → 10.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 +25 -3
- package/package.json +2 -2
- package/src/drop-order.ts +61 -0
- package/src/errors.ts +0 -1
- package/src/foreign-key-plan.ts +146 -0
- package/src/foreign-key.ts +17 -9
- package/src/generate.ts +17 -109
- package/src/sql.ts +7 -0
package/CLAUDE.md
CHANGED
|
@@ -547,13 +547,35 @@ app: `create table "comments" (… references "posts" …)` before `create table
|
|
|
547
547
|
`relation "posts" does not exist`. `down` had the mirror fault — `drop table "posts"` while
|
|
548
548
|
`comments` still referenced it is `2BP01`. So `foreignKeyPlan` collects every key into a bucket of
|
|
549
549
|
its own, merged into the plan **after** every table statement; `down` is reversed as a whole, so the
|
|
550
|
-
drops pushed last there come out first. No topological sort and **no cycle error
|
|
551
|
-
referencing each other cannot be expressed inline in any order, and separate constraints need
|
|
552
|
-
order at all. The same call site answers the other half — a `references()` added to a column that
|
|
550
|
+
drops pushed last there come out first. No topological sort and **no cycle error** *for adds*: two
|
|
551
|
+
tables referencing each other cannot be expressed inline in any order, and separate constraints need
|
|
552
|
+
no order at all. Dropping is not symmetrical and does need one — the paragraph below. The same call site answers the other half — a `references()` added to a column that
|
|
553
553
|
already exists now emits its `add constraint`, where before `up` came out **empty**, `x db gen`
|
|
554
554
|
wrote no file, and `x verify`'s drift step stayed red forever with `x db gen "…"` as a fix that did
|
|
555
555
|
nothing.
|
|
556
556
|
|
|
557
|
+
**Dropping a table has its own bucket, emitted BEFORE the table statements — the mirror image of
|
|
558
|
+
the one above, `As of 2026-08-23`.** `--allow-destructive` emitted a bare `drop table "authors";`
|
|
559
|
+
with every `alter table … drop constraint` appended AFTER it, so dropping a table another entity
|
|
560
|
+
`references()` was `2BP01 cannot drop table authors because other objects depend on it` — during
|
|
561
|
+
`ROLE=migrate` in the release phase, with the ledger recording nothing and a `down` of
|
|
562
|
+
`-- "<table>" cannot be restored`, i.e. nothing to reverse and a generated file to hand-edit. The
|
|
563
|
+
two-table case failed identically because drops came out **alphabetically**, which puts the parent
|
|
564
|
+
first. Two halves. `foreignKeyPlan` routes a key whose `referencedTable` is doomed into `preDrops`
|
|
565
|
+
instead of `constraints` — whether the entity still declares it or not, since a constraint cannot
|
|
566
|
+
outlive its target — and its `down` is a comment, because `add constraint` against a table no
|
|
567
|
+
`down` can restore is a rollback that cannot run. `drop-order.ts` orders the drops children-first
|
|
568
|
+
(a self-reference is not a blocker: `drop table` takes the table's own constraints with it) and
|
|
569
|
+
breaks a cycle between two doomed tables by dropping one inbound key first, which is the only
|
|
570
|
+
statement it emits. The `--allow-destructive` refusal is raised over that same ordered list, so
|
|
571
|
+
which table it names does not move with the alphabet.
|
|
572
|
+
|
|
573
|
+
**`foreignKeyPlan` lives in `foreign-key-plan.ts`, `As of 2026-08-23`** — split out of
|
|
574
|
+
`generate.ts` at the 500-line ceiling, along the seam it already drew: `generate.ts` assembles a
|
|
575
|
+
plan, `foreign-key-plan.ts` decides which bucket each key statement goes in, `foreign-key.ts`
|
|
576
|
+
writes the SQL. `Plan`, `foreignKeysOf` and `referenceParts` went with it because they are that
|
|
577
|
+
module's vocabulary; `snapshotOf` imports `foreignKeysOf` back, one direction only.
|
|
578
|
+
|
|
557
579
|
**`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
|
|
558
580
|
emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
|
|
559
581
|
stayed on the database **and** the record denied one the catalog holds, which `compareForeignKeys`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.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": "10.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: the order a set of tables can be dropped in, and the foreign keys that
|
|
2
|
+
// have to go first when no order exists. `generate.ts` writes the statements; this file decides
|
|
3
|
+
// which of them may run when, because `drop table` is refused (`2BP01`) while anything still
|
|
4
|
+
// points at the table.
|
|
5
|
+
|
|
6
|
+
import { dropForeignKey } from './foreign-key';
|
|
7
|
+
import type { TableDescription } from './introspect';
|
|
8
|
+
|
|
9
|
+
export interface DropOrder {
|
|
10
|
+
/** The tables, referencing before referenced. */
|
|
11
|
+
readonly tables: readonly TableDescription[];
|
|
12
|
+
/** `drop constraint` statements that must run BEFORE the first drop — cycles only. */
|
|
13
|
+
readonly constraints: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function referencesFrom(table: TableDescription, target: string): readonly string[] {
|
|
17
|
+
return table.foreignKeys
|
|
18
|
+
.filter((key) => key.referencedTable === target)
|
|
19
|
+
.map((key) => dropForeignKey(table.name, key.name));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Drop the children first: a table nothing still-to-be-dropped points at is always safe to drop,
|
|
24
|
+
* and removing it makes its own parents safe in turn. Alphabetical order — which is what
|
|
25
|
+
* `SchemaDescription` carries — puts `authors` before `posts`, i.e. the parent first, which is the
|
|
26
|
+
* one order Postgres refuses.
|
|
27
|
+
*
|
|
28
|
+
* A self-reference is never a blocker: `drop table` takes the table's OWN constraints with it, so
|
|
29
|
+
* a tree table needs no `drop constraint` of its own.
|
|
30
|
+
*
|
|
31
|
+
* A cycle between two doomed tables has no safe order at all, so one of them is chosen and the
|
|
32
|
+
* keys pointing at it are dropped first. That is the only case that emits a statement here — every
|
|
33
|
+
* other inbound key belongs to a table that SURVIVES, which `generate.ts` handles beside the entity
|
|
34
|
+
* that still owns the column.
|
|
35
|
+
*/
|
|
36
|
+
export function dropOrder(dropped: readonly TableDescription[]): DropOrder {
|
|
37
|
+
const remaining = [...dropped];
|
|
38
|
+
const tables: TableDescription[] = [];
|
|
39
|
+
const constraints: string[] = [];
|
|
40
|
+
while (remaining.length > 0) {
|
|
41
|
+
const free = remaining.findIndex(
|
|
42
|
+
(table) =>
|
|
43
|
+
!remaining.some(
|
|
44
|
+
(other) =>
|
|
45
|
+
other.name !== table.name &&
|
|
46
|
+
other.foreignKeys.some((key) => key.referencedTable === table.name),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const index = free === -1 ? 0 : free;
|
|
50
|
+
const next = remaining[index] as TableDescription;
|
|
51
|
+
if (free === -1) {
|
|
52
|
+
for (const other of remaining) {
|
|
53
|
+
if (other.name === next.name) continue;
|
|
54
|
+
constraints.push(...referencesFrom(other, next.name));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
tables.push(next);
|
|
58
|
+
remaining.splice(index, 1);
|
|
59
|
+
}
|
|
60
|
+
return { tables, constraints };
|
|
61
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single responsibility: which foreign keys a migration must add or drop, and into which of the
|
|
2
|
+
// two buckets each statement goes — `constraints` runs AFTER the table statements, `preDrops`
|
|
3
|
+
// BEFORE them. `generate.ts` assembles the plan; `foreign-key.ts` writes the SQL.
|
|
4
|
+
|
|
5
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
|
+
import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
7
|
+
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
+
|
|
9
|
+
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
10
|
+
export interface Plan {
|
|
11
|
+
readonly up: string[];
|
|
12
|
+
readonly down: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The two ends of a `references()`, which entity renders as `"<table>.<column>"`. Read once: the
|
|
17
|
+
* clause that writes the constraint and the snapshot that records it must not disagree about what
|
|
18
|
+
* it points at, or drift reports a key the database holds exactly as declared.
|
|
19
|
+
*/
|
|
20
|
+
export function referenceParts(references: string): readonly [string, string] {
|
|
21
|
+
const [table = references, column = 'id'] = references.split('.');
|
|
22
|
+
return [table, column];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The keys this entity declares, recorded so drift can see one dropped by hand. Recording
|
|
27
|
+
* `foreignKeys: []` while the same run emitted a constraint was a snapshot claiming a constraint
|
|
28
|
+
* does not exist that the migration beside it creates, and `compareTable` had nothing to compare —
|
|
29
|
+
* a key dropped on the database was invisible to every check the framework runs.
|
|
30
|
+
*
|
|
31
|
+
* The name is `<table>_<column>_fkey` — what Postgres would have called an inline `references`
|
|
32
|
+
* clause — and `addForeignKey` now writes it out, so the snapshot records a name the migration
|
|
33
|
+
* beside it chose rather than one it guessed. It is still *not* what drift matches on: see
|
|
34
|
+
* `compareForeignKeys` in `drift.ts`.
|
|
35
|
+
*
|
|
36
|
+
* `onDelete` is the column's own, and `addForeignKey` spells it: a rule recorded here while no
|
|
37
|
+
* clause declared one would be a claim about the database that is not true, which is what it was
|
|
38
|
+
* until the clause learned to write it out.
|
|
39
|
+
*/
|
|
40
|
+
export function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
|
|
41
|
+
return entity.columns
|
|
42
|
+
.filter((column) => column.references !== null)
|
|
43
|
+
.map((column): ForeignKeyDescription => {
|
|
44
|
+
const [table, key] = referenceParts(column.references ?? '');
|
|
45
|
+
return {
|
|
46
|
+
name: `${entity.table}_${column.column}_fkey`,
|
|
47
|
+
columns: [column.column],
|
|
48
|
+
referencedTable: table,
|
|
49
|
+
referencedColumns: [key],
|
|
50
|
+
onDelete: column.onDelete ?? null,
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every foreign key the entity declares that the previous snapshot does not already record, as its
|
|
58
|
+
* own `add constraint`. One call site for both cases the generator has — a table being created and
|
|
59
|
+
* a `references()` added to a column that already exists — because they are one question: which of
|
|
60
|
+
* this entity's keys does the database not hold yet.
|
|
61
|
+
*
|
|
62
|
+
* The statements land in a bucket of their own, appended after every table statement, so a key
|
|
63
|
+
* never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
|
|
64
|
+
* last here puts them *first* on the way back: `drop table "posts"` with `comments` still
|
|
65
|
+
* referencing it is `2BP01`, a migration that cannot be rolled back at all.
|
|
66
|
+
*
|
|
67
|
+
* The mirror image of that asymmetry is `plans.preDrops`, which is emitted BEFORE the table
|
|
68
|
+
* statements: a key pointing at a table this migration DROPS has to go first, or the drop is
|
|
69
|
+
* `2BP01` on the way forward. Its `down` is a comment for the same reason the table's own is —
|
|
70
|
+
* `add constraint` against a table no `down` can restore is a rollback that cannot run.
|
|
71
|
+
*
|
|
72
|
+
* Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
|
|
73
|
+
* while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
|
|
74
|
+
* database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
|
|
75
|
+
* declared side, so no drift check could ever see it. Not parity with a removed index either: that
|
|
76
|
+
* leaves the snapshot correct by omission. The drop names the constraint the previous snapshot
|
|
77
|
+
* recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
|
|
78
|
+
* `42704` under the generated spelling.
|
|
79
|
+
*/
|
|
80
|
+
export function foreignKeyPlan(
|
|
81
|
+
entity: EntityDescriptionLike,
|
|
82
|
+
live: TableDescription | undefined,
|
|
83
|
+
plans: ConstraintPlans,
|
|
84
|
+
): void {
|
|
85
|
+
const { constraints, preDrops, doomed } = plans;
|
|
86
|
+
const wanted = foreignKeysOf(entity);
|
|
87
|
+
const held = new Map((live?.foreignKeys ?? []).map((key) => [foreignKeyTarget(key), key]));
|
|
88
|
+
for (const key of wanted) {
|
|
89
|
+
const recorded = held.get(foreignKeyTarget(key));
|
|
90
|
+
if (doomed.has(key.referencedTable)) {
|
|
91
|
+
// Still declared, but its target is going away this migration. The constraint cannot outlive
|
|
92
|
+
// the table, so it goes first — and no `add constraint` is written for one that never was.
|
|
93
|
+
if (recorded !== undefined)
|
|
94
|
+
unrestorableDrop(entity.table, recorded.name, key.referencedTable, preDrops);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (recorded === undefined) {
|
|
98
|
+
constraints.up.push(addForeignKey(entity.table, key));
|
|
99
|
+
constraints.down.push(dropForeignKey(entity.table, key.name));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// The rule is not part of a key's identity, so the same key under a new one is a rebuild —
|
|
103
|
+
// Postgres has no `alter constraint` for it, the same reason `redefineIndex` recreates.
|
|
104
|
+
if (onDeleteRule(recorded.onDelete) === onDeleteRule(key.onDelete)) continue;
|
|
105
|
+
constraints.up.push(
|
|
106
|
+
dropForeignKey(entity.table, recorded.name),
|
|
107
|
+
addForeignKey(entity.table, key),
|
|
108
|
+
);
|
|
109
|
+
// Pushed forwards and read backwards, like `redefineIndex`: `down` is reversed at assembly.
|
|
110
|
+
constraints.down.push(
|
|
111
|
+
addForeignKey(entity.table, recorded),
|
|
112
|
+
dropForeignKey(entity.table, key.name),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const declared = new Set(wanted.map(foreignKeyTarget));
|
|
116
|
+
const columns = new Set(entity.columns.map((column) => column.column));
|
|
117
|
+
for (const key of live?.foreignKeys ?? []) {
|
|
118
|
+
if (declared.has(foreignKeyTarget(key))) continue;
|
|
119
|
+
// `drop column` takes the constraint with it, so a `drop constraint` after that statement is
|
|
120
|
+
// `42704` on a constraint that is already gone.
|
|
121
|
+
if (!key.columns.every((column) => columns.has(column))) continue;
|
|
122
|
+
if (doomed.has(key.referencedTable)) {
|
|
123
|
+
unrestorableDrop(entity.table, key.name, key.referencedTable, preDrops);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
constraints.up.push(dropForeignKey(entity.table, key.name));
|
|
127
|
+
constraints.down.push(addForeignKey(entity.table, key));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ConstraintPlans {
|
|
132
|
+
/** `add`/`drop` for keys between tables that survive — appended AFTER the table statements. */
|
|
133
|
+
readonly constraints: Plan;
|
|
134
|
+
/** Keys pointing at a table this migration drops — emitted BEFORE the table statements. */
|
|
135
|
+
readonly preDrops: Plan;
|
|
136
|
+
/** The tables this migration drops, by name. */
|
|
137
|
+
readonly doomed: ReadonlySet<string>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A key whose target is being dropped: gone on the way up, a note on the way back. */
|
|
141
|
+
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
142
|
+
preDrops.up.push(dropForeignKey(table, constraint));
|
|
143
|
+
preDrops.down.push(
|
|
144
|
+
`-- constraint "${constraint}" on "${table}" cannot be restored; "${target}" is gone`,
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/foreign-key.ts
CHANGED
|
@@ -5,14 +5,22 @@
|
|
|
5
5
|
import { assert } from '@ultimat3/core';
|
|
6
6
|
import type { ForeignKeyDescription } from './introspect';
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
|
|
10
|
+
*
|
|
11
|
+
* A `Map`, because `raw` is a catalog string on every read: an object literal answered
|
|
12
|
+
* `CATALOG_RULES['constructor']` with the `Object` FUNCTION, which left this `string | null`
|
|
13
|
+
* function returning one into `compareForeignKeys`.
|
|
14
|
+
*/
|
|
15
|
+
const CATALOG_RULES: ReadonlyMap<string, string> = new Map(
|
|
16
|
+
Object.entries({
|
|
17
|
+
a: 'no action',
|
|
18
|
+
c: 'cascade',
|
|
19
|
+
r: 'restrict',
|
|
20
|
+
n: 'set null',
|
|
21
|
+
d: 'set default',
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* One `on delete` vocabulary for both sides. The catalog spells the rule as a single character and
|
|
@@ -25,7 +33,7 @@ const CATALOG_RULES: Readonly<Record<string, string>> = {
|
|
|
25
33
|
*/
|
|
26
34
|
export function onDeleteRule(raw: string | null): string | null {
|
|
27
35
|
if (raw === null) return null;
|
|
28
|
-
const named = CATALOG_RULES
|
|
36
|
+
const named = CATALOG_RULES.get(raw) ?? raw.toLowerCase();
|
|
29
37
|
return named === 'no action' ? null : named;
|
|
30
38
|
}
|
|
31
39
|
|
package/src/generate.ts
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
|
|
6
6
|
import { assert, systemClock } from '@ultimat3/core';
|
|
7
7
|
import { isDestructive } from './destructive';
|
|
8
|
+
import { dropOrder } from './drop-order';
|
|
8
9
|
import type {
|
|
9
10
|
ColumnDescriptionLike,
|
|
10
11
|
EntityDescriptionLike,
|
|
11
12
|
IndexDescriptionLike,
|
|
12
13
|
} from './entity-shape';
|
|
13
14
|
import { migrationIrreversible } from './errors';
|
|
14
|
-
import {
|
|
15
|
+
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
15
16
|
import {
|
|
16
17
|
type ColumnDescription,
|
|
17
|
-
type ForeignKeyDescription,
|
|
18
18
|
findTable,
|
|
19
19
|
type IndexDescription,
|
|
20
20
|
type SchemaDescription,
|
|
@@ -52,16 +52,6 @@ function defaultExpression(column: ColumnDescriptionLike): string | null {
|
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
* The two ends of a `references()`, which entity renders as `"<table>.<column>"`. Read once: the
|
|
57
|
-
* clause that writes the constraint and the snapshot that records it must not disagree about what
|
|
58
|
-
* it points at, or drift reports a key the database holds exactly as declared.
|
|
59
|
-
*/
|
|
60
|
-
function referenceParts(references: string): readonly [string, string] {
|
|
61
|
-
const [table = references, column = 'id'] = references.split('.');
|
|
62
|
-
return [table, column];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
55
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
66
56
|
const parts = [`"${column.column}"`, sqlType(column.kind)];
|
|
67
57
|
const expression = defaultExpression(column);
|
|
@@ -103,37 +93,6 @@ function impliedByColumnClause(
|
|
|
103
93
|
return column !== undefined && column.unique && !column.primaryKey && added.has(only);
|
|
104
94
|
}
|
|
105
95
|
|
|
106
|
-
/**
|
|
107
|
-
* The keys this entity declares, recorded so drift can see one dropped by hand. Recording
|
|
108
|
-
* `foreignKeys: []` while the same run emitted a constraint was a snapshot claiming a constraint
|
|
109
|
-
* does not exist that the migration beside it creates, and `compareTable` had nothing to compare —
|
|
110
|
-
* a key dropped on the database was invisible to every check the framework runs.
|
|
111
|
-
*
|
|
112
|
-
* The name is `<table>_<column>_fkey` — what Postgres would have called an inline `references`
|
|
113
|
-
* clause — and `addForeignKey` now writes it out, so the snapshot records a name the migration
|
|
114
|
-
* beside it chose rather than one it guessed. It is still *not* what drift matches on: see
|
|
115
|
-
* `compareForeignKeys` in `drift.ts`.
|
|
116
|
-
*
|
|
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.
|
|
120
|
-
*/
|
|
121
|
-
function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescription[] {
|
|
122
|
-
return entity.columns
|
|
123
|
-
.filter((column) => column.references !== null)
|
|
124
|
-
.map((column): ForeignKeyDescription => {
|
|
125
|
-
const [table, key] = referenceParts(column.references ?? '');
|
|
126
|
-
return {
|
|
127
|
-
name: `${entity.table}_${column.column}_fkey`,
|
|
128
|
-
columns: [column.column],
|
|
129
|
-
referencedTable: table,
|
|
130
|
-
referencedColumns: [key],
|
|
131
|
-
onDelete: column.onDelete ?? null,
|
|
132
|
-
};
|
|
133
|
-
})
|
|
134
|
-
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
96
|
export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
|
|
138
97
|
const tables = [...entities]
|
|
139
98
|
.sort((a, b) => (a.table < b.table ? -1 : 1))
|
|
@@ -202,69 +161,6 @@ function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
|
202
161
|
return `${kind} "${index.name}" on "${table}" (${columns})${predicate};`;
|
|
203
162
|
}
|
|
204
163
|
|
|
205
|
-
interface Plan {
|
|
206
|
-
readonly up: string[];
|
|
207
|
-
readonly down: string[];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Every foreign key the entity declares that the previous snapshot does not already record, as its
|
|
212
|
-
* own `add constraint`. One call site for both cases the generator has — a table being created and
|
|
213
|
-
* a `references()` added to a column that already exists — because they are one question: which of
|
|
214
|
-
* this entity's keys does the database not hold yet.
|
|
215
|
-
*
|
|
216
|
-
* The statements land in a bucket of their own, appended after every table statement, so a key
|
|
217
|
-
* never runs before the table it points at. `down` is reversed as a whole, so pushing the drops
|
|
218
|
-
* last here puts them *first* on the way back: `drop table "posts"` with `comments` still
|
|
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.
|
|
228
|
-
*/
|
|
229
|
-
function foreignKeyPlan(
|
|
230
|
-
entity: EntityDescriptionLike,
|
|
231
|
-
live: TableDescription | undefined,
|
|
232
|
-
constraints: Plan,
|
|
233
|
-
): void {
|
|
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
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
164
|
/**
|
|
269
165
|
* Skipping an existing column by name alone missed the type moving under it: a table created
|
|
270
166
|
* while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
|
|
@@ -405,11 +301,19 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
405
301
|
const plan: Plan = { up: [], down: [] };
|
|
406
302
|
// Merged into `plan` once every table statement is in, never interleaved with them.
|
|
407
303
|
const constraints: Plan = { up: [], down: [] };
|
|
304
|
+
// Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
|
|
305
|
+
// migration drops makes that `drop table` `2BP01`.
|
|
306
|
+
const preDrops: Plan = { up: [], down: [] };
|
|
408
307
|
const wanted = new Set(options.entities.map((entity) => entity.table));
|
|
409
308
|
|
|
309
|
+
const doomed = new Set(
|
|
310
|
+
current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
|
|
311
|
+
);
|
|
312
|
+
const plans: ConstraintPlans = { constraints, preDrops, doomed };
|
|
313
|
+
|
|
410
314
|
for (const entity of options.entities) {
|
|
411
315
|
const live = findTable(current, entity.table);
|
|
412
|
-
foreignKeyPlan(entity, live,
|
|
316
|
+
foreignKeyPlan(entity, live, plans);
|
|
413
317
|
if (live === undefined) {
|
|
414
318
|
plan.up.push(...createTable(entity));
|
|
415
319
|
plan.down.push(`drop table "${entity.table}";`);
|
|
@@ -433,14 +337,18 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
433
337
|
}
|
|
434
338
|
}
|
|
435
339
|
|
|
436
|
-
|
|
437
|
-
|
|
340
|
+
const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
|
|
341
|
+
for (const table of order.tables) {
|
|
438
342
|
if (options.allowDestructive !== true) {
|
|
439
343
|
throw migrationIrreversible(
|
|
440
344
|
`dropping table "${table.name}" discards every row and cannot be undone`,
|
|
441
345
|
`x db gen "${options.name}" --allow-destructive # or delete the entity in two releases`,
|
|
442
346
|
);
|
|
443
347
|
}
|
|
348
|
+
}
|
|
349
|
+
plan.up.push(...preDrops.up, ...order.constraints);
|
|
350
|
+
plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
|
|
351
|
+
for (const table of order.tables) {
|
|
444
352
|
plan.up.push(`drop table "${table.name}";`);
|
|
445
353
|
plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
|
|
446
354
|
}
|
package/src/sql.ts
CHANGED
|
@@ -135,6 +135,13 @@ export function identifier(name: string): SqlFragment {
|
|
|
135
135
|
* A quoted string literal. Postgres utility statements (`CREATE DATABASE`, `COMMENT ON`) reject
|
|
136
136
|
* bound parameters, so this is the only place a value may be inlined — and it escapes quotes.
|
|
137
137
|
* Never reach for it in a query: `sql` binds parameters there.
|
|
138
|
+
*
|
|
139
|
+
* The doubling is only an escape while `standard_conforming_strings` is `on`, which has been the
|
|
140
|
+
* server default since 9.1: with it OFF, a backslash escapes the quote that follows and a value
|
|
141
|
+
* ending in one closes the literal early. So this is safe for framework-supplied names — a
|
|
142
|
+
* database, a schema, a comment this repo writes — and is NOT an escape for untrusted text under
|
|
143
|
+
* an arbitrary server configuration. Nothing passes it caller input today; if something must,
|
|
144
|
+
* bind a parameter instead, or send `E''`-style quoting from a statement that can take one.
|
|
138
145
|
*/
|
|
139
146
|
export function literal(value: string): SqlFragment {
|
|
140
147
|
return raw(`'${value.replaceAll("'", "''")}'`);
|