@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.
- package/dist/core/catalog.diff.js +4 -4
- package/dist/core/catalog.model.d.ts +8 -1
- package/dist/core/catalog.model.js +9 -8
- package/dist/core/expand-replace-dependencies.js +23 -0
- package/dist/core/objects/extract-with-retry.d.ts +36 -0
- package/dist/core/objects/extract-with-retry.js +51 -0
- package/dist/core/objects/index/index.diff.js +0 -1
- package/dist/core/objects/index/index.model.d.ts +2 -3
- package/dist/core/objects/index/index.model.js +17 -6
- package/dist/core/objects/materialized-view/materialized-view.model.d.ts +2 -1
- package/dist/core/objects/materialized-view/materialized-view.model.js +20 -4
- package/dist/core/objects/procedure/procedure.model.d.ts +2 -1
- package/dist/core/objects/procedure/procedure.model.js +20 -4
- package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
- package/dist/core/objects/rls-policy/rls-policy.diff.js +13 -1
- package/dist/core/objects/rule/rule.model.d.ts +2 -1
- package/dist/core/objects/rule/rule.model.js +20 -3
- package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
- package/dist/core/objects/sequence/sequence.diff.js +41 -9
- package/dist/core/objects/table/changes/table.alter.d.ts +16 -1
- package/dist/core/objects/table/changes/table.alter.js +39 -6
- package/dist/core/objects/table/table.diff.js +40 -17
- package/dist/core/objects/table/table.model.d.ts +6 -1
- package/dist/core/objects/table/table.model.js +50 -12
- package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
- package/dist/core/objects/trigger/trigger.model.js +20 -4
- package/dist/core/objects/utils.d.ts +1 -0
- package/dist/core/objects/utils.js +3 -0
- package/dist/core/objects/view/view.model.d.ts +2 -1
- package/dist/core/objects/view/view.model.js +20 -4
- package/dist/core/plan/create.js +3 -1
- package/dist/core/plan/types.d.ts +8 -0
- package/dist/core/post-diff-normalization.d.ts +36 -0
- package/dist/core/post-diff-normalization.js +202 -0
- 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/dist/core/sort/utils.d.ts +10 -0
- package/dist/core/sort/utils.js +28 -0
- package/package.json +1 -1
- package/src/core/catalog.diff.ts +4 -3
- package/src/core/catalog.model.ts +20 -8
- package/src/core/expand-replace-dependencies.test.ts +139 -5
- package/src/core/expand-replace-dependencies.ts +24 -0
- package/src/core/objects/extract-with-retry.test.ts +143 -0
- package/src/core/objects/extract-with-retry.ts +87 -0
- package/src/core/objects/index/index.diff.ts +0 -1
- package/src/core/objects/index/index.model.test.ts +37 -1
- package/src/core/objects/index/index.model.ts +25 -6
- package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
- package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
- package/src/core/objects/procedure/procedure.model.test.ts +117 -0
- package/src/core/objects/procedure/procedure.model.ts +28 -5
- package/src/core/objects/publication/changes/publication.alter.ts +1 -1
- package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
- package/src/core/objects/rule/rule.model.test.ts +99 -0
- package/src/core/objects/rule/rule.model.ts +28 -4
- package/src/core/objects/sequence/sequence.diff.test.ts +93 -1
- package/src/core/objects/sequence/sequence.diff.ts +43 -10
- package/src/core/objects/table/changes/table.alter.test.ts +26 -23
- package/src/core/objects/table/changes/table.alter.ts +66 -10
- package/src/core/objects/table/table.diff.test.ts +43 -0
- package/src/core/objects/table/table.diff.ts +52 -23
- package/src/core/objects/table/table.model.test.ts +209 -0
- package/src/core/objects/table/table.model.ts +62 -14
- package/src/core/objects/trigger/trigger.model.test.ts +113 -0
- package/src/core/objects/trigger/trigger.model.ts +28 -5
- package/src/core/objects/utils.ts +3 -0
- package/src/core/objects/view/view.model.test.ts +90 -0
- package/src/core/objects/view/view.model.ts +28 -5
- package/src/core/plan/create.ts +3 -1
- package/src/core/plan/types.ts +8 -0
- package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -160
- package/src/core/post-diff-normalization.ts +260 -0
- 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
- package/src/core/sort/utils.ts +38 -0
- package/dist/core/post-diff-cycle-breaking.d.ts +0 -29
- package/dist/core/post-diff-cycle-breaking.js +0 -209
- 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
|
-
*
|
|
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
|
}
|
package/src/core/sort/utils.ts
CHANGED
|
@@ -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[];
|