@supabase/pg-delta 1.0.0-alpha.20 → 1.0.0-alpha.22

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.
Files changed (81) hide show
  1. package/dist/core/catalog.diff.js +4 -4
  2. package/dist/core/catalog.model.d.ts +8 -1
  3. package/dist/core/catalog.model.js +9 -8
  4. package/dist/core/expand-replace-dependencies.js +23 -0
  5. package/dist/core/objects/extract-with-retry.d.ts +36 -0
  6. package/dist/core/objects/extract-with-retry.js +51 -0
  7. package/dist/core/objects/index/index.diff.js +0 -1
  8. package/dist/core/objects/index/index.model.d.ts +2 -3
  9. package/dist/core/objects/index/index.model.js +17 -6
  10. package/dist/core/objects/materialized-view/materialized-view.model.d.ts +2 -1
  11. package/dist/core/objects/materialized-view/materialized-view.model.js +20 -4
  12. package/dist/core/objects/procedure/procedure.model.d.ts +2 -1
  13. package/dist/core/objects/procedure/procedure.model.js +20 -4
  14. package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
  15. package/dist/core/objects/rls-policy/rls-policy.diff.js +13 -1
  16. package/dist/core/objects/rule/rule.model.d.ts +2 -1
  17. package/dist/core/objects/rule/rule.model.js +20 -3
  18. package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
  19. package/dist/core/objects/sequence/sequence.diff.js +41 -9
  20. package/dist/core/objects/table/changes/table.alter.d.ts +16 -1
  21. package/dist/core/objects/table/changes/table.alter.js +39 -6
  22. package/dist/core/objects/table/table.diff.js +40 -17
  23. package/dist/core/objects/table/table.model.d.ts +6 -1
  24. package/dist/core/objects/table/table.model.js +50 -12
  25. package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
  26. package/dist/core/objects/trigger/trigger.model.js +20 -4
  27. package/dist/core/objects/utils.d.ts +1 -0
  28. package/dist/core/objects/utils.js +3 -0
  29. package/dist/core/objects/view/view.model.d.ts +2 -1
  30. package/dist/core/objects/view/view.model.js +20 -4
  31. package/dist/core/plan/create.js +3 -1
  32. package/dist/core/plan/types.d.ts +8 -0
  33. package/dist/core/post-diff-normalization.d.ts +36 -0
  34. package/dist/core/post-diff-normalization.js +202 -0
  35. package/dist/core/sort/cycle-breakers.d.ts +15 -0
  36. package/dist/core/sort/cycle-breakers.js +269 -0
  37. package/dist/core/sort/sort-changes.js +97 -43
  38. package/dist/core/sort/utils.d.ts +10 -0
  39. package/dist/core/sort/utils.js +28 -0
  40. package/package.json +1 -1
  41. package/src/core/catalog.diff.ts +4 -3
  42. package/src/core/catalog.model.ts +20 -8
  43. package/src/core/expand-replace-dependencies.test.ts +139 -5
  44. package/src/core/expand-replace-dependencies.ts +24 -0
  45. package/src/core/objects/extract-with-retry.test.ts +143 -0
  46. package/src/core/objects/extract-with-retry.ts +87 -0
  47. package/src/core/objects/index/index.diff.ts +0 -1
  48. package/src/core/objects/index/index.model.test.ts +37 -1
  49. package/src/core/objects/index/index.model.ts +25 -6
  50. package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
  51. package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
  52. package/src/core/objects/procedure/procedure.model.test.ts +117 -0
  53. package/src/core/objects/procedure/procedure.model.ts +28 -5
  54. package/src/core/objects/publication/changes/publication.alter.ts +1 -1
  55. package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
  56. package/src/core/objects/rule/rule.model.test.ts +99 -0
  57. package/src/core/objects/rule/rule.model.ts +28 -4
  58. package/src/core/objects/sequence/sequence.diff.test.ts +93 -1
  59. package/src/core/objects/sequence/sequence.diff.ts +43 -10
  60. package/src/core/objects/table/changes/table.alter.test.ts +26 -23
  61. package/src/core/objects/table/changes/table.alter.ts +66 -10
  62. package/src/core/objects/table/table.diff.test.ts +43 -0
  63. package/src/core/objects/table/table.diff.ts +52 -23
  64. package/src/core/objects/table/table.model.test.ts +209 -0
  65. package/src/core/objects/table/table.model.ts +62 -14
  66. package/src/core/objects/trigger/trigger.model.test.ts +113 -0
  67. package/src/core/objects/trigger/trigger.model.ts +28 -5
  68. package/src/core/objects/utils.ts +3 -0
  69. package/src/core/objects/view/view.model.test.ts +90 -0
  70. package/src/core/objects/view/view.model.ts +28 -5
  71. package/src/core/plan/create.ts +3 -1
  72. package/src/core/plan/types.ts +8 -0
  73. package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -160
  74. package/src/core/post-diff-normalization.ts +260 -0
  75. package/src/core/sort/cycle-breakers.test.ts +476 -0
  76. package/src/core/sort/cycle-breakers.ts +311 -0
  77. package/src/core/sort/sort-changes.ts +135 -50
  78. package/src/core/sort/utils.ts +38 -0
  79. package/dist/core/post-diff-cycle-breaking.d.ts +0 -29
  80. package/dist/core/post-diff-cycle-breaking.js +0 -209
  81. package/src/core/post-diff-cycle-breaking.ts +0 -317
@@ -0,0 +1,311 @@
1
+ import type { Change } from "../change.types.ts";
2
+ import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts";
3
+ import {
4
+ AlterTableDropColumn,
5
+ AlterTableDropConstraint,
6
+ } from "../objects/table/changes/table.alter.ts";
7
+ import { DropTable } from "../objects/table/changes/table.drop.ts";
8
+ import type { TableConstraintProps } from "../objects/table/table.model.ts";
9
+ import { stableId } from "../objects/utils.ts";
10
+
11
+ /**
12
+ * Try to break an unbreakable cycle by INJECTING NEW CHANGES or REWRITING
13
+ * existing ones (rather than removing graph edges).
14
+ *
15
+ * Called by `sortPhaseChanges` when its edge-removal cycle handler has seen
16
+ * the same cycle twice — i.e. weak-edge filtering exhausted itself but the
17
+ * cycle is still there. At that point we know the cycle is composed of
18
+ * "hard" edges (explicit `requires` or pg_depend rows) that can only be
19
+ * broken by changing the change list itself.
20
+ *
21
+ * Returns a rewritten `phaseChanges` array, or `null` if no breaker matches
22
+ * (in which case the caller throws the existing CycleError).
23
+ */
24
+ export function tryBreakCycleByChangeInjection(
25
+ cycleNodeIndexes: readonly number[],
26
+ phaseChanges: readonly Change[],
27
+ ): Change[] | null {
28
+ // ─── Branch A: FK cycle among DropTable changes ──────────────────────
29
+ // Triggered when N≥2 dropped tables reference each other via foreign
30
+ // keys. With no surviving table on either side, every FK constraint
31
+ // stable-id ends up tied back to a DropTable node, and every
32
+ // pg_depend row produces a hard explicit edge between two DropTables.
33
+ // Edge filtering can't break it — the edges are not weak.
34
+ //
35
+ // Example (3-cycle):
36
+ // DROP TABLE a; DROP TABLE b; DROP TABLE c;
37
+ // where a.b_id REFERENCES b, b.c_id REFERENCES c, c.a_id REFERENCES a
38
+ //
39
+ // Fix: inject a dedicated `ALTER TABLE ... DROP CONSTRAINT fk` ahead of
40
+ // each DropTable in the cycle, and mark the constraint name on
41
+ // `DropTable.externallyDroppedConstraints` so the table drop won't try
42
+ // to re-claim the same constraint stable-id. The injected drops have
43
+ // their own stable-id ownership and run before any DropTable, breaking
44
+ // the cycle.
45
+ //
46
+ // This naturally handles any N (2-cycle, 3-cycle, …) because
47
+ // `findCycle` already gave us the full member list — no separate SCC
48
+ // enumeration needed.
49
+ const fkBroken = tryBreakFkCycle(cycleNodeIndexes, phaseChanges);
50
+ if (fkBroken) return fkBroken;
51
+
52
+ // ─── Branch B: Publication ↔ Column on a surviving table ─────────────
53
+ // Triggered when a publication has an explicit column list and one of
54
+ // those columns is dropped on a table that itself is NOT being dropped
55
+ // (the table just loses one column).
56
+ //
57
+ // Example:
58
+ // CREATE PUBLICATION p FOR TABLE lab_results (id, flash_summary);
59
+ // ALTER TABLE lab_results DROP COLUMN flash_summary;
60
+ //
61
+ // Diff emits two drop-phase changes:
62
+ // AlterPublicationDropTables(p, [lab_results])
63
+ // AlterTableDropColumn(lab_results.flash_summary)
64
+ //
65
+ // The cycle:
66
+ // pub:p → col:lab_results.flash_summary (catalog, pg_depend)
67
+ // col:lab_results.flash_summary → table:lab_results
68
+ // (explicit, AlterTableDropColumn.requires)
69
+ //
70
+ // Fix: rebuild the AlterTableDropColumn with `omitTableRequirement=true`
71
+ // so it no longer requires `table:lab_results`. Safe because
72
+ // `lab_results` survives the migration; its lifetime trivially covers
73
+ // the column drop. The catalog edge `pub → col` correctly orders the
74
+ // publication drop before the column drop.
75
+ const pubColBroken = tryBreakPublicationColumnCycle(
76
+ cycleNodeIndexes,
77
+ phaseChanges,
78
+ );
79
+ if (pubColBroken) return pubColBroken;
80
+
81
+ // No known pattern. Returning null lets sortPhaseChanges throw the
82
+ // formatted CycleError with full diagnostic — better a clear bug
83
+ // report than silently shipping a broken plan.
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Branch A worker — inject `AlterTableDropConstraint` for every FK linking
89
+ * two DropTables in the cycle.
90
+ *
91
+ * Returns the rewritten changes array, or `null` if the cycle does not
92
+ * match (e.g. mixed types, or no cross-cycle FK exists).
93
+ */
94
+ function tryBreakFkCycle(
95
+ cycleNodeIndexes: readonly number[],
96
+ phaseChanges: readonly Change[],
97
+ ): Change[] | null {
98
+ // Guard: every member of the cycle must be a DropTable. Mixed cycles
99
+ // (e.g. DropTable + DropView, or DropTable + DropMaterializedView) are
100
+ // out of scope — they need a different breaker.
101
+ const cycleDropTables: DropTable[] = [];
102
+ for (const nodeIndex of cycleNodeIndexes) {
103
+ const change = phaseChanges[nodeIndex];
104
+ if (!(change instanceof DropTable)) return null;
105
+ cycleDropTables.push(change);
106
+ }
107
+
108
+ const cycleTableIds = new Set(
109
+ cycleDropTables.map((change) => change.table.stableId),
110
+ );
111
+
112
+ // For each DropTable in the cycle, find every FK whose referenced table
113
+ // is also in the cycle. Each such FK becomes one injected
114
+ // `AlterTableDropConstraint` and one entry on the source table's
115
+ // `externallyDroppedConstraints`.
116
+ //
117
+ // 2-cycle example: { A→B, B→A } — two FKs, two injected drops.
118
+ // 3-cycle example: { A→B, B→C, C→A } — three FKs, three injected drops.
119
+ const injectedDropsByTableId = new Map<string, AlterTableDropConstraint[]>();
120
+ const updatedExternalsByTableId = new Map<string, Set<string>>();
121
+ let didMutate = false;
122
+
123
+ for (const dropTable of cycleDropTables) {
124
+ const tableId = dropTable.table.stableId;
125
+ const existingExternals = new Set(dropTable.externallyDroppedConstraints);
126
+ let tableMutated = false;
127
+
128
+ for (const fk of iterCrossCycleFkConstraints(
129
+ dropTable.table.constraints,
130
+ tableId,
131
+ cycleTableIds,
132
+ )) {
133
+ // Skip if a same-table `AlterTableDropConstraint` is already in the
134
+ // change list — could happen if a previous breaker iteration
135
+ // injected one, or the diff layer emitted one explicitly.
136
+ if (existingExternals.has(fk.name)) continue;
137
+ if (alreadyHasExplicitDrop(phaseChanges, tableId, fk.name)) continue;
138
+
139
+ const injected = new AlterTableDropConstraint({
140
+ table: dropTable.table,
141
+ constraint: fk,
142
+ });
143
+ const list = injectedDropsByTableId.get(tableId) ?? [];
144
+ list.push(injected);
145
+ injectedDropsByTableId.set(tableId, list);
146
+ existingExternals.add(fk.name);
147
+ tableMutated = true;
148
+ didMutate = true;
149
+ }
150
+
151
+ if (tableMutated) {
152
+ updatedExternalsByTableId.set(tableId, existingExternals);
153
+ }
154
+ }
155
+
156
+ if (!didMutate) return null;
157
+
158
+ // Rebuild phaseChanges: keep all non-DropTable changes in place. For
159
+ // each DropTable in the cycle that gained injected drops, emit the
160
+ // injected drops first, then a fresh DropTable carrying the updated
161
+ // `externallyDroppedConstraints` so it stops claiming the FK
162
+ // stable-ids.
163
+ const rewritten: Change[] = [];
164
+ for (const change of phaseChanges) {
165
+ if (!(change instanceof DropTable)) {
166
+ rewritten.push(change);
167
+ continue;
168
+ }
169
+ const tableId = change.table.stableId;
170
+ const injected = injectedDropsByTableId.get(tableId);
171
+ if (injected) {
172
+ rewritten.push(...injected);
173
+ }
174
+ const updatedExternals = updatedExternalsByTableId.get(tableId);
175
+ if (updatedExternals) {
176
+ rewritten.push(
177
+ new DropTable({
178
+ table: change.table,
179
+ externallyDroppedConstraints: updatedExternals,
180
+ }),
181
+ );
182
+ } else {
183
+ rewritten.push(change);
184
+ }
185
+ }
186
+ return rewritten;
187
+ }
188
+
189
+ /**
190
+ * Yield FK constraints on `constraints` whose referenced table is also a
191
+ * member of the cycle (i.e. an FK strictly between two cycle DropTables).
192
+ *
193
+ * Self-referencing FKs are skipped — they create a self-loop in the
194
+ * dependency graph which the existing sort-phase handler resolves on its
195
+ * own; injecting an `AlterTableDropConstraint` for a self-FK would just
196
+ * add noise.
197
+ */
198
+ function* iterCrossCycleFkConstraints(
199
+ constraints: readonly TableConstraintProps[],
200
+ ownTableId: string,
201
+ cycleTableIds: ReadonlySet<string>,
202
+ ): Iterable<TableConstraintProps> {
203
+ for (const constraint of constraints) {
204
+ if (constraint.constraint_type !== "f") continue;
205
+ if (constraint.is_partition_clone) continue;
206
+ if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
207
+ continue;
208
+ }
209
+ const referencedId = stableId.table(
210
+ constraint.foreign_key_schema,
211
+ constraint.foreign_key_table,
212
+ );
213
+ if (referencedId === ownTableId) continue;
214
+ if (!cycleTableIds.has(referencedId)) continue;
215
+ yield constraint;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * True iff `phaseChanges` already contains an explicit
221
+ * `AlterTableDropConstraint(table, constraint)` for the given pair —
222
+ * either emitted by the diff layer or by a previous breaker iteration.
223
+ * Avoids duplicate constraint drops.
224
+ */
225
+ function alreadyHasExplicitDrop(
226
+ phaseChanges: readonly Change[],
227
+ tableId: string,
228
+ constraintName: string,
229
+ ): boolean {
230
+ for (const change of phaseChanges) {
231
+ if (!(change instanceof AlterTableDropConstraint)) continue;
232
+ if (change.table.stableId !== tableId) continue;
233
+ if (change.constraint.name === constraintName) return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ /**
239
+ * Branch B worker — break the publication↔column cycle by rebuilding the
240
+ * `AlterTableDropColumn` change with `omitTableRequirement=true`.
241
+ *
242
+ * Returns the rewritten changes array, or `null` if the cycle does not
243
+ * match (e.g. table is also being dropped, or no `AlterPublicationDropTables`
244
+ * references the table).
245
+ */
246
+ function tryBreakPublicationColumnCycle(
247
+ cycleNodeIndexes: readonly number[],
248
+ phaseChanges: readonly Change[],
249
+ ): Change[] | null {
250
+ // Find an `AlterTableDropColumn` and an `AlterPublicationDropTables` in
251
+ // the cycle that reference the same table. Both must be present —
252
+ // otherwise this is a different cycle shape.
253
+ let dropColumnIndex = -1;
254
+ let dropColumnChange: AlterTableDropColumn | null = null;
255
+ let pubMatchesTable = false;
256
+ let pubChange: AlterPublicationDropTables | null = null;
257
+
258
+ for (const nodeIndex of cycleNodeIndexes) {
259
+ const change = phaseChanges[nodeIndex];
260
+ if (
261
+ change instanceof AlterTableDropColumn &&
262
+ !change.omitTableRequirement
263
+ ) {
264
+ dropColumnIndex = nodeIndex;
265
+ dropColumnChange = change;
266
+ } else if (change instanceof AlterPublicationDropTables) {
267
+ pubChange = change;
268
+ }
269
+ }
270
+ if (dropColumnChange === null || pubChange === null) return null;
271
+
272
+ // Verify the publication is actually dropping membership for the same
273
+ // table whose column is being dropped. Without this check we'd risk
274
+ // rewriting an unrelated AlterTableDropColumn that happens to share a
275
+ // cycle with some other publication change.
276
+ const targetTableId = dropColumnChange.table.stableId;
277
+ for (const t of pubChange.tables) {
278
+ if (stableId.table(t.schema, t.name) === targetTableId) {
279
+ pubMatchesTable = true;
280
+ break;
281
+ }
282
+ }
283
+ if (!pubMatchesTable) return null;
284
+
285
+ // Verify the table is NOT itself being dropped. If `DropTable(T)` is in
286
+ // the same phase, the existing structural rewrites in
287
+ // `post-diff-normalization.ts` (replace-expansion superseded filter)
288
+ // already prune the redundant `AlterTableDropColumn`, so we should not
289
+ // see this combination here. Be defensive and bail anyway — flipping
290
+ // `omitTableRequirement` when T is being dropped would let the column
291
+ // drop reorder against the table drop, which is unsafe.
292
+ for (const change of phaseChanges) {
293
+ if (
294
+ change instanceof DropTable &&
295
+ change.table.stableId === targetTableId
296
+ ) {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ // Replace the AlterTableDropColumn with a fresh instance carrying
302
+ // `omitTableRequirement=true`. All other changes pass through
303
+ // unchanged.
304
+ const rewritten: Change[] = phaseChanges.slice();
305
+ rewritten[dropColumnIndex] = new AlterTableDropColumn({
306
+ table: dropColumnChange.table,
307
+ column: dropColumnChange.column,
308
+ omitTableRequirement: true,
309
+ });
310
+ return rewritten;
311
+ }
@@ -15,6 +15,7 @@ import debug from "debug";
15
15
  import type { Catalog } from "../catalog.model.ts";
16
16
  import type { Change } from "../change.types.ts";
17
17
  import { generateCustomConstraints } from "./custom-constraints.ts";
18
+ import { tryBreakCycleByChangeInjection } from "./cycle-breakers.ts";
18
19
  import { printDebugGraph } from "./debug-visualization.ts";
19
20
 
20
21
  const debugGraph = debug("pg-delta:graph");
@@ -40,6 +41,14 @@ import {
40
41
  import type { PgDependRow, PhaseSortOptions } from "./types.ts";
41
42
  import { getExecutionPhase, type Phase } from "./utils.ts";
42
43
 
44
+ // `sortPhaseChanges` caps the change-injection breaker at one round per
45
+ // node in the initial phase: there can never be more disjoint unbreakable
46
+ // cycles than there are change nodes (each cycle has ≥ 2 distinct nodes).
47
+ // The cap exists only to surface a buggy breaker as `CycleError` instead
48
+ // of an infinite loop — the actual loop-protection guarantee comes from
49
+ // `breakerRoundSignatures`, which throws the moment the same cycle
50
+ // reappears after a break.
51
+
43
52
  /**
44
53
  * Sort changes using dependency information from catalogs and custom constraints.
45
54
  *
@@ -110,24 +119,50 @@ function sortChangesByPhasedGraph(
110
119
  }
111
120
 
112
121
  /**
113
- * Sort changes within a phase using Constraints derived from all dependency sources.
122
+ * Normalize a cycle by rotating it to start with the smallest node index, so
123
+ * cycles that loop through the same nodes in the same direction compare equal
124
+ * regardless of where DFS happened to enter them.
125
+ */
126
+ function normalizeCycle(cycleNodeIndexes: number[]): string {
127
+ if (cycleNodeIndexes.length === 0) return "";
128
+ const minIndex = Math.min(...cycleNodeIndexes);
129
+ const minIndexPos = cycleNodeIndexes.indexOf(minIndex);
130
+ const rotated = [
131
+ ...cycleNodeIndexes.slice(minIndexPos),
132
+ ...cycleNodeIndexes.slice(0, minIndexPos),
133
+ ];
134
+ return rotated.join(",");
135
+ }
136
+
137
+ type SortRoundResult =
138
+ | { kind: "sorted"; sorted: Change[] }
139
+ | {
140
+ kind: "unbreakable";
141
+ cycleNodeIndexes: number[];
142
+ cycleEdges: ReturnType<typeof getEdgesInCycle>;
143
+ };
144
+
145
+ /**
146
+ * One attempt at sorting `phaseChanges`. Builds the graph from scratch,
147
+ * runs the iterative edge-removal cycle handler, and either returns a
148
+ * topologically sorted list or reports an unbreakable cycle so the caller
149
+ * can decide whether to dispatch a change-injection breaker.
114
150
  *
115
151
  * Algorithm:
116
- * 1. Build graph data (change sets and reverse indexes)
117
- * 2. Convert all sources to Constraints (catalog, explicit, custom constraints)
118
- * 3. Convert Constraints to edges
119
- * 4. Iteratively detect and break cycles (deduplicate edges, detect cycles, filter problematic edges)
120
- * 5. Perform stable topological sort on the acyclic graph
152
+ * 1. Build graph data (change sets and reverse indexes).
153
+ * 2. Convert all sources to Constraints (catalog, explicit, custom).
154
+ * 3. Convert Constraints to edges.
155
+ * 4. Iteratively detect and break cycles by removing weak edges.
156
+ * 5. Perform stable topological sort on the acyclic graph.
121
157
  *
122
- * In DROP phase, edges are inverted so drops run in reverse dependency order.
158
+ * In DROP phase, edges are inverted so drops run in reverse dependency
159
+ * order.
123
160
  */
124
- function sortPhaseChanges(
161
+ function attemptSortRound(
125
162
  phaseChanges: Change[],
126
163
  dependencyRows: PgDependRow[],
127
- options: PhaseSortOptions = {},
128
- ): Change[] {
129
- if (phaseChanges.length <= 1) return phaseChanges;
130
-
164
+ options: PhaseSortOptions,
165
+ ): SortRoundResult {
131
166
  // Step 1: Build graph data structures
132
167
  const graphData = buildGraphData(phaseChanges, options);
133
168
 
@@ -150,54 +185,31 @@ function sortPhaseChanges(
150
185
  // Step 3: Convert constraints to edges and deduplicate immediately
151
186
  let edges = dedupeEdges(convertConstraintsToEdges(allConstraints, options));
152
187
 
153
- // Step 4: Iteratively detect and break cycles
154
- // Track cycles we've seen to detect when filtering fails to break a cycle.
155
- // The only way we loop indefinitely is if we encounter a cycle we've already seen,
156
- // which means filtering didn't break it. Otherwise, we continue until all cycles are broken.
188
+ // Step 4: Iteratively detect and break cycles by edge filtering.
189
+ // We loop until no cycles remain OR we see the same cycle twice the
190
+ // latter signals that edge filtering exhausted itself. At that point
191
+ // the caller may dispatch a change-injection breaker; if no breaker
192
+ // matches, the original throw path runs.
157
193
  const seenCycles = new Set<string>();
158
194
 
159
- /**
160
- * Normalize a cycle by rotating it to start with the smallest node index.
161
- * This allows us to compare cycles regardless of where they start.
162
- */
163
- function normalizeCycle(cycleNodeIndexes: number[]): string {
164
- if (cycleNodeIndexes.length === 0) return "";
165
- const minIndex = Math.min(...cycleNodeIndexes);
166
- const minIndexPos = cycleNodeIndexes.indexOf(minIndex);
167
- const rotated = [
168
- ...cycleNodeIndexes.slice(minIndexPos),
169
- ...cycleNodeIndexes.slice(0, minIndexPos),
170
- ];
171
- return rotated.join(",");
172
- }
173
-
174
195
  while (true) {
175
- // Edge deduplication moved outside loop
176
196
  const edgePairs = edgesToPairs(edges);
177
-
178
- // Detect cycles
179
197
  const cycleNodeIndexes = findCycle(phaseChanges.length, edgePairs);
180
198
 
181
- if (!cycleNodeIndexes) {
182
- // No cycles found, we're done
183
- break;
184
- }
199
+ if (!cycleNodeIndexes) break;
185
200
 
186
- // Normalize cycle to check if we've seen it before
187
201
  const cycleSignature = normalizeCycle(cycleNodeIndexes);
188
202
  if (seenCycles.has(cycleSignature)) {
189
- // We've seen this cycle before - filtering didn't break it
190
- // Get edges involved in the cycle for detailed error message
191
- const cycleEdges = getEdgesInCycle(cycleNodeIndexes, edges);
192
- throw new Error(
193
- formatCycleError(cycleNodeIndexes, phaseChanges, cycleEdges),
194
- );
203
+ // Edge filtering can't break this cycle. Report it back to the
204
+ // caller so it can try change-injection before throwing.
205
+ return {
206
+ kind: "unbreakable",
207
+ cycleNodeIndexes,
208
+ cycleEdges: getEdgesInCycle(cycleNodeIndexes, edges),
209
+ };
195
210
  }
196
-
197
- // Track this cycle
198
211
  seenCycles.add(cycleSignature);
199
212
 
200
- // Filter only edges involved in the cycle to break it
201
213
  edges = filterEdgesForCycleBreaking(
202
214
  edges,
203
215
  cycleNodeIndexes,
@@ -208,7 +220,6 @@ function sortPhaseChanges(
208
220
 
209
221
  const finalEdgePairs = edgesToPairs(edges);
210
222
 
211
- // Debug visualization
212
223
  if (debugGraph.enabled) {
213
224
  printDebugGraph(
214
225
  phaseChanges,
@@ -230,5 +241,79 @@ function sortPhaseChanges(
230
241
  throw new Error("CycleError: dependency graph contains a cycle");
231
242
  }
232
243
 
233
- return topologicalOrder.map((changeIndex) => phaseChanges[changeIndex]);
244
+ return {
245
+ kind: "sorted",
246
+ sorted: topologicalOrder.map((changeIndex) => phaseChanges[changeIndex]),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Sort changes within a phase. Tries `attemptSortRound`; on an unbreakable
252
+ * cycle, dispatches to `tryBreakCycleByChangeInjection`, retries with the
253
+ * rewritten changes, and bails after `MAX_CYCLE_BREAKER_ROUNDS` to surface
254
+ * a buggy breaker as `CycleError` instead of an infinite loop.
255
+ *
256
+ * Best case (no cycles, the vast majority of plans): one round, no
257
+ * change-injection breaker code runs at all.
258
+ */
259
+ function sortPhaseChanges(
260
+ initialPhaseChanges: Change[],
261
+ dependencyRows: PgDependRow[],
262
+ options: PhaseSortOptions = {},
263
+ ): Change[] {
264
+ if (initialPhaseChanges.length <= 1) return initialPhaseChanges;
265
+
266
+ let phaseChanges = initialPhaseChanges;
267
+ const breakerRoundSignatures = new Set<string>();
268
+
269
+ // `attemptSortRound` returns at most one unbreakable cycle per call,
270
+ // so a phase with K independent unbreakable cycles needs K+1 rounds.
271
+ // Every cycle contains ≥ 2 distinct change nodes, so the maximum
272
+ // possible value of K is `floor(initialPhaseChanges.length / 2)` —
273
+ // using `initialPhaseChanges.length` itself is therefore a real upper
274
+ // bound with one round of slack (and matches the early-return guard
275
+ // above, which already excluded length-0 and length-1 phases).
276
+ const maxRounds = initialPhaseChanges.length;
277
+
278
+ for (let round = 0; round <= maxRounds; round++) {
279
+ const result = attemptSortRound(phaseChanges, dependencyRows, options);
280
+ if (result.kind === "sorted") return result.sorted;
281
+
282
+ // Edge filtering hit an unbreakable cycle. Try the change-injection
283
+ // breakers (FK pattern, publication↔column pattern). If none matches,
284
+ // throw with the same diagnostic the original code emitted.
285
+ const broken = tryBreakCycleByChangeInjection(
286
+ result.cycleNodeIndexes,
287
+ phaseChanges,
288
+ );
289
+ if (broken === null) {
290
+ throw new Error(
291
+ formatCycleError(
292
+ result.cycleNodeIndexes,
293
+ phaseChanges,
294
+ result.cycleEdges,
295
+ ),
296
+ );
297
+ }
298
+
299
+ // Loop guard: if the same cycle node-set re-appears after a break,
300
+ // the breaker isn't making progress. Throw with full context.
301
+ const signature = normalizeCycle(result.cycleNodeIndexes);
302
+ if (breakerRoundSignatures.has(signature)) {
303
+ throw new Error(
304
+ formatCycleError(
305
+ result.cycleNodeIndexes,
306
+ phaseChanges,
307
+ result.cycleEdges,
308
+ ),
309
+ );
310
+ }
311
+ breakerRoundSignatures.add(signature);
312
+
313
+ phaseChanges = broken;
314
+ }
315
+
316
+ throw new Error(
317
+ `CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`,
318
+ );
234
319
  }
@@ -1,4 +1,9 @@
1
1
  import type { Change } from "../change.types.ts";
2
+ import {
3
+ AlterTableAlterColumnDropDefault,
4
+ AlterTableAlterColumnDropIdentity,
5
+ AlterTableAlterColumnType,
6
+ } from "../objects/table/changes/table.alter.ts";
2
7
 
3
8
  /**
4
9
  * Execution phases for changes.
@@ -30,6 +35,16 @@ export function isMetadataStableId(stableId: string): boolean {
30
35
  * - ALTER operations with scope="privilege" → create_alter_object phase (metadata changes)
31
36
  * - ALTER operations that drop actual objects → drop phase (destructive ALTER)
32
37
  * - ALTER operations that don't drop objects → create_alter_object phase (non-destructive ALTER)
38
+ *
39
+ * Dependency-breaking ALTERs that remove a `pg_depend` edge to another
40
+ * object that may be dropped in the same plan (for example
41
+ * `ALTER COLUMN ... DROP DEFAULT` releasing a sequence reference, or
42
+ * `ALTER COLUMN ... TYPE <built-in>` releasing a user-defined type
43
+ * reference) are routed to the drop phase. The drop phase sorts in reverse
44
+ * dependency order using the main catalog, so the catalog edges already
45
+ * in `pg_depend` order the ALTER before any dependent `DROP TYPE` /
46
+ * `DROP SEQUENCE` / `DROP FUNCTION` and PostgreSQL no longer rejects the
47
+ * drop with error 2BP01.
33
48
  */
34
49
  export function getExecutionPhase(change: Change): Phase {
35
50
  // DROP operations always go to drop phase
@@ -60,6 +75,29 @@ export function getExecutionPhase(change: Change): Phase {
60
75
  return "drop";
61
76
  }
62
77
 
78
+ // Dependency-breaking column ALTERs that release a pg_depend edge.
79
+ // Routing these to the drop phase lets the existing catalog dependency
80
+ // edges (column → sequence, column → identity sequence) order them
81
+ // before the matching DROP statement.
82
+ if (
83
+ change instanceof AlterTableAlterColumnDropDefault ||
84
+ change instanceof AlterTableAlterColumnDropIdentity
85
+ ) {
86
+ return "drop";
87
+ }
88
+
89
+ // ALTER COLUMN ... TYPE only safely runs in the drop phase when the
90
+ // target type is built-in. For user-defined target types we cannot tell
91
+ // here whether the type is created in the same plan, and the create
92
+ // happens in create_alter phase, so we keep the alter in that phase to
93
+ // preserve the create-then-alter ordering.
94
+ if (
95
+ change instanceof AlterTableAlterColumnType &&
96
+ !change.column.is_custom_type
97
+ ) {
98
+ return "drop";
99
+ }
100
+
63
101
  // Non-destructive ALTER (ADD COLUMN, GRANT, etc.) → create_alter phase
64
102
  return "create_alter_object";
65
103
  }
@@ -1,29 +0,0 @@
1
- import type { Catalog } from "./catalog.model.ts";
2
- import type { Change } from "./change.types.ts";
3
- /**
4
- * Normalize change-list cycles that only become apparent after all object
5
- * diffs have been collected.
6
- *
7
- * This pass intentionally handles whole-plan interactions only:
8
- * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
9
- * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
10
- * redundant and create an unbreakable drop-phase cycle, so we elide them.
11
- * - When the same `DropTable+CreateTable` pair is present, the expansion
12
- * also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
13
- * / `CreateCommentOnConstraint` per branch constraint, which may collide
14
- * with the same change already emitted by `diffTables()` (for example on a
15
- * shape flip or a new constraint). We dedupe these keeping only the last
16
- * occurrence so the expansion's emission survives and the diffTables
17
- * duplicate is removed.
18
- * - If two dropped tables reference each other via FK, we insert dedicated
19
- * `AlterTableDropConstraint` changes and teach the paired `DropTable`
20
- * changes not to claim those FK stable IDs.
21
- *
22
- * Object-local PostgreSQL semantics (for example owned-sequence cascades) stay
23
- * in the corresponding `diff*` function instead of this pass.
24
- */
25
- export declare function normalizePostDiffCycles({ changes, mainCatalog, replacedTableIds, }: {
26
- changes: Change[];
27
- mainCatalog: Catalog;
28
- replacedTableIds?: ReadonlySet<string>;
29
- }): Change[];