@ultimat3/db 9.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 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**: two tables
551
- referencing each other cannot be expressed inline in any order, and separate constraints need no
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": "9.0.0",
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": "9.0.0"
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
@@ -97,7 +97,6 @@ export class DbError extends UltimateError {
97
97
  code: init.code,
98
98
  cause: init.cause,
99
99
  fix: init.fix,
100
- docs: `https://ultimate.dev/errors/${init.code}`,
101
100
  meta: init.meta,
102
101
  sourceError: init.sourceError,
103
102
  });
@@ -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
+ }
@@ -5,14 +5,22 @@
5
5
  import { assert } from '@ultimat3/core';
6
6
  import type { ForeignKeyDescription } from './introspect';
7
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
- };
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[raw] ?? raw.toLowerCase();
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 { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
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, constraints);
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
- for (const table of current.tables) {
437
- if (wanted.has(table.name)) continue;
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("'", "''")}'`);