@supabase/pg-delta 1.0.0-alpha.20 → 1.0.0-alpha.21
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/dist/core/catalog.diff.js +0 -1
- package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
- package/dist/core/objects/sequence/sequence.diff.js +13 -5
- package/dist/core/objects/table/changes/table.alter.d.ts +4 -0
- package/dist/core/objects/table/changes/table.alter.js +19 -4
- package/dist/core/objects/table/table.diff.js +21 -2
- package/dist/core/objects/table/table.model.js +10 -7
- package/dist/core/post-diff-cycle-breaking.d.ts +21 -21
- package/dist/core/post-diff-cycle-breaking.js +24 -133
- package/dist/core/sort/cycle-breakers.d.ts +15 -0
- package/dist/core/sort/cycle-breakers.js +269 -0
- package/dist/core/sort/sort-changes.js +97 -43
- package/package.json +1 -1
- package/src/core/catalog.diff.ts +0 -1
- package/src/core/expand-replace-dependencies.test.ts +8 -5
- package/src/core/objects/publication/changes/publication.alter.ts +1 -1
- package/src/core/objects/sequence/sequence.diff.test.ts +6 -1
- package/src/core/objects/sequence/sequence.diff.ts +12 -4
- package/src/core/objects/table/changes/table.alter.test.ts +13 -2
- package/src/core/objects/table/changes/table.alter.ts +36 -7
- package/src/core/objects/table/table.diff.test.ts +43 -0
- package/src/core/objects/table/table.diff.ts +28 -4
- package/src/core/objects/table/table.model.ts +10 -7
- package/src/core/post-diff-cycle-breaking.test.ts +0 -156
- package/src/core/post-diff-cycle-breaking.ts +23 -202
- package/src/core/sort/cycle-breakers.test.ts +476 -0
- package/src/core/sort/cycle-breakers.ts +311 -0
- package/src/core/sort/sort-changes.ts +135 -50
|
@@ -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-cycle-breaking.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
|
-
*
|
|
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
|
|
118
|
-
* 3. Convert Constraints to edges
|
|
119
|
-
* 4. Iteratively detect and break cycles
|
|
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
|
|
158
|
+
* In DROP phase, edges are inverted so drops run in reverse dependency
|
|
159
|
+
* order.
|
|
123
160
|
*/
|
|
124
|
-
function
|
|
161
|
+
function attemptSortRound(
|
|
125
162
|
phaseChanges: Change[],
|
|
126
163
|
dependencyRows: PgDependRow[],
|
|
127
|
-
options: PhaseSortOptions
|
|
128
|
-
):
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
}
|