@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,269 @@
|
|
|
1
|
+
import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.js";
|
|
2
|
+
import { AlterTableDropColumn, AlterTableDropConstraint, } from "../objects/table/changes/table.alter.js";
|
|
3
|
+
import { DropTable } from "../objects/table/changes/table.drop.js";
|
|
4
|
+
import { stableId } from "../objects/utils.js";
|
|
5
|
+
/**
|
|
6
|
+
* Try to break an unbreakable cycle by INJECTING NEW CHANGES or REWRITING
|
|
7
|
+
* existing ones (rather than removing graph edges).
|
|
8
|
+
*
|
|
9
|
+
* Called by `sortPhaseChanges` when its edge-removal cycle handler has seen
|
|
10
|
+
* the same cycle twice — i.e. weak-edge filtering exhausted itself but the
|
|
11
|
+
* cycle is still there. At that point we know the cycle is composed of
|
|
12
|
+
* "hard" edges (explicit `requires` or pg_depend rows) that can only be
|
|
13
|
+
* broken by changing the change list itself.
|
|
14
|
+
*
|
|
15
|
+
* Returns a rewritten `phaseChanges` array, or `null` if no breaker matches
|
|
16
|
+
* (in which case the caller throws the existing CycleError).
|
|
17
|
+
*/
|
|
18
|
+
export function tryBreakCycleByChangeInjection(cycleNodeIndexes, phaseChanges) {
|
|
19
|
+
// ─── Branch A: FK cycle among DropTable changes ──────────────────────
|
|
20
|
+
// Triggered when N≥2 dropped tables reference each other via foreign
|
|
21
|
+
// keys. With no surviving table on either side, every FK constraint
|
|
22
|
+
// stable-id ends up tied back to a DropTable node, and every
|
|
23
|
+
// pg_depend row produces a hard explicit edge between two DropTables.
|
|
24
|
+
// Edge filtering can't break it — the edges are not weak.
|
|
25
|
+
//
|
|
26
|
+
// Example (3-cycle):
|
|
27
|
+
// DROP TABLE a; DROP TABLE b; DROP TABLE c;
|
|
28
|
+
// where a.b_id REFERENCES b, b.c_id REFERENCES c, c.a_id REFERENCES a
|
|
29
|
+
//
|
|
30
|
+
// Fix: inject a dedicated `ALTER TABLE ... DROP CONSTRAINT fk` ahead of
|
|
31
|
+
// each DropTable in the cycle, and mark the constraint name on
|
|
32
|
+
// `DropTable.externallyDroppedConstraints` so the table drop won't try
|
|
33
|
+
// to re-claim the same constraint stable-id. The injected drops have
|
|
34
|
+
// their own stable-id ownership and run before any DropTable, breaking
|
|
35
|
+
// the cycle.
|
|
36
|
+
//
|
|
37
|
+
// This naturally handles any N (2-cycle, 3-cycle, …) because
|
|
38
|
+
// `findCycle` already gave us the full member list — no separate SCC
|
|
39
|
+
// enumeration needed.
|
|
40
|
+
const fkBroken = tryBreakFkCycle(cycleNodeIndexes, phaseChanges);
|
|
41
|
+
if (fkBroken)
|
|
42
|
+
return fkBroken;
|
|
43
|
+
// ─── Branch B: Publication ↔ Column on a surviving table ─────────────
|
|
44
|
+
// Triggered when a publication has an explicit column list and one of
|
|
45
|
+
// those columns is dropped on a table that itself is NOT being dropped
|
|
46
|
+
// (the table just loses one column).
|
|
47
|
+
//
|
|
48
|
+
// Example:
|
|
49
|
+
// CREATE PUBLICATION p FOR TABLE lab_results (id, flash_summary);
|
|
50
|
+
// ALTER TABLE lab_results DROP COLUMN flash_summary;
|
|
51
|
+
//
|
|
52
|
+
// Diff emits two drop-phase changes:
|
|
53
|
+
// AlterPublicationDropTables(p, [lab_results])
|
|
54
|
+
// AlterTableDropColumn(lab_results.flash_summary)
|
|
55
|
+
//
|
|
56
|
+
// The cycle:
|
|
57
|
+
// pub:p → col:lab_results.flash_summary (catalog, pg_depend)
|
|
58
|
+
// col:lab_results.flash_summary → table:lab_results
|
|
59
|
+
// (explicit, AlterTableDropColumn.requires)
|
|
60
|
+
//
|
|
61
|
+
// Fix: rebuild the AlterTableDropColumn with `omitTableRequirement=true`
|
|
62
|
+
// so it no longer requires `table:lab_results`. Safe because
|
|
63
|
+
// `lab_results` survives the migration; its lifetime trivially covers
|
|
64
|
+
// the column drop. The catalog edge `pub → col` correctly orders the
|
|
65
|
+
// publication drop before the column drop.
|
|
66
|
+
const pubColBroken = tryBreakPublicationColumnCycle(cycleNodeIndexes, phaseChanges);
|
|
67
|
+
if (pubColBroken)
|
|
68
|
+
return pubColBroken;
|
|
69
|
+
// No known pattern. Returning null lets sortPhaseChanges throw the
|
|
70
|
+
// formatted CycleError with full diagnostic — better a clear bug
|
|
71
|
+
// report than silently shipping a broken plan.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Branch A worker — inject `AlterTableDropConstraint` for every FK linking
|
|
76
|
+
* two DropTables in the cycle.
|
|
77
|
+
*
|
|
78
|
+
* Returns the rewritten changes array, or `null` if the cycle does not
|
|
79
|
+
* match (e.g. mixed types, or no cross-cycle FK exists).
|
|
80
|
+
*/
|
|
81
|
+
function tryBreakFkCycle(cycleNodeIndexes, phaseChanges) {
|
|
82
|
+
// Guard: every member of the cycle must be a DropTable. Mixed cycles
|
|
83
|
+
// (e.g. DropTable + DropView, or DropTable + DropMaterializedView) are
|
|
84
|
+
// out of scope — they need a different breaker.
|
|
85
|
+
const cycleDropTables = [];
|
|
86
|
+
for (const nodeIndex of cycleNodeIndexes) {
|
|
87
|
+
const change = phaseChanges[nodeIndex];
|
|
88
|
+
if (!(change instanceof DropTable))
|
|
89
|
+
return null;
|
|
90
|
+
cycleDropTables.push(change);
|
|
91
|
+
}
|
|
92
|
+
const cycleTableIds = new Set(cycleDropTables.map((change) => change.table.stableId));
|
|
93
|
+
// For each DropTable in the cycle, find every FK whose referenced table
|
|
94
|
+
// is also in the cycle. Each such FK becomes one injected
|
|
95
|
+
// `AlterTableDropConstraint` and one entry on the source table's
|
|
96
|
+
// `externallyDroppedConstraints`.
|
|
97
|
+
//
|
|
98
|
+
// 2-cycle example: { A→B, B→A } — two FKs, two injected drops.
|
|
99
|
+
// 3-cycle example: { A→B, B→C, C→A } — three FKs, three injected drops.
|
|
100
|
+
const injectedDropsByTableId = new Map();
|
|
101
|
+
const updatedExternalsByTableId = new Map();
|
|
102
|
+
let didMutate = false;
|
|
103
|
+
for (const dropTable of cycleDropTables) {
|
|
104
|
+
const tableId = dropTable.table.stableId;
|
|
105
|
+
const existingExternals = new Set(dropTable.externallyDroppedConstraints);
|
|
106
|
+
let tableMutated = false;
|
|
107
|
+
for (const fk of iterCrossCycleFkConstraints(dropTable.table.constraints, tableId, cycleTableIds)) {
|
|
108
|
+
// Skip if a same-table `AlterTableDropConstraint` is already in the
|
|
109
|
+
// change list — could happen if a previous breaker iteration
|
|
110
|
+
// injected one, or the diff layer emitted one explicitly.
|
|
111
|
+
if (existingExternals.has(fk.name))
|
|
112
|
+
continue;
|
|
113
|
+
if (alreadyHasExplicitDrop(phaseChanges, tableId, fk.name))
|
|
114
|
+
continue;
|
|
115
|
+
const injected = new AlterTableDropConstraint({
|
|
116
|
+
table: dropTable.table,
|
|
117
|
+
constraint: fk,
|
|
118
|
+
});
|
|
119
|
+
const list = injectedDropsByTableId.get(tableId) ?? [];
|
|
120
|
+
list.push(injected);
|
|
121
|
+
injectedDropsByTableId.set(tableId, list);
|
|
122
|
+
existingExternals.add(fk.name);
|
|
123
|
+
tableMutated = true;
|
|
124
|
+
didMutate = true;
|
|
125
|
+
}
|
|
126
|
+
if (tableMutated) {
|
|
127
|
+
updatedExternalsByTableId.set(tableId, existingExternals);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!didMutate)
|
|
131
|
+
return null;
|
|
132
|
+
// Rebuild phaseChanges: keep all non-DropTable changes in place. For
|
|
133
|
+
// each DropTable in the cycle that gained injected drops, emit the
|
|
134
|
+
// injected drops first, then a fresh DropTable carrying the updated
|
|
135
|
+
// `externallyDroppedConstraints` so it stops claiming the FK
|
|
136
|
+
// stable-ids.
|
|
137
|
+
const rewritten = [];
|
|
138
|
+
for (const change of phaseChanges) {
|
|
139
|
+
if (!(change instanceof DropTable)) {
|
|
140
|
+
rewritten.push(change);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const tableId = change.table.stableId;
|
|
144
|
+
const injected = injectedDropsByTableId.get(tableId);
|
|
145
|
+
if (injected) {
|
|
146
|
+
rewritten.push(...injected);
|
|
147
|
+
}
|
|
148
|
+
const updatedExternals = updatedExternalsByTableId.get(tableId);
|
|
149
|
+
if (updatedExternals) {
|
|
150
|
+
rewritten.push(new DropTable({
|
|
151
|
+
table: change.table,
|
|
152
|
+
externallyDroppedConstraints: updatedExternals,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
rewritten.push(change);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return rewritten;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Yield FK constraints on `constraints` whose referenced table is also a
|
|
163
|
+
* member of the cycle (i.e. an FK strictly between two cycle DropTables).
|
|
164
|
+
*
|
|
165
|
+
* Self-referencing FKs are skipped — they create a self-loop in the
|
|
166
|
+
* dependency graph which the existing sort-phase handler resolves on its
|
|
167
|
+
* own; injecting an `AlterTableDropConstraint` for a self-FK would just
|
|
168
|
+
* add noise.
|
|
169
|
+
*/
|
|
170
|
+
function* iterCrossCycleFkConstraints(constraints, ownTableId, cycleTableIds) {
|
|
171
|
+
for (const constraint of constraints) {
|
|
172
|
+
if (constraint.constraint_type !== "f")
|
|
173
|
+
continue;
|
|
174
|
+
if (constraint.is_partition_clone)
|
|
175
|
+
continue;
|
|
176
|
+
if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const referencedId = stableId.table(constraint.foreign_key_schema, constraint.foreign_key_table);
|
|
180
|
+
if (referencedId === ownTableId)
|
|
181
|
+
continue;
|
|
182
|
+
if (!cycleTableIds.has(referencedId))
|
|
183
|
+
continue;
|
|
184
|
+
yield constraint;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* True iff `phaseChanges` already contains an explicit
|
|
189
|
+
* `AlterTableDropConstraint(table, constraint)` for the given pair —
|
|
190
|
+
* either emitted by the diff layer or by a previous breaker iteration.
|
|
191
|
+
* Avoids duplicate constraint drops.
|
|
192
|
+
*/
|
|
193
|
+
function alreadyHasExplicitDrop(phaseChanges, tableId, constraintName) {
|
|
194
|
+
for (const change of phaseChanges) {
|
|
195
|
+
if (!(change instanceof AlterTableDropConstraint))
|
|
196
|
+
continue;
|
|
197
|
+
if (change.table.stableId !== tableId)
|
|
198
|
+
continue;
|
|
199
|
+
if (change.constraint.name === constraintName)
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Branch B worker — break the publication↔column cycle by rebuilding the
|
|
206
|
+
* `AlterTableDropColumn` change with `omitTableRequirement=true`.
|
|
207
|
+
*
|
|
208
|
+
* Returns the rewritten changes array, or `null` if the cycle does not
|
|
209
|
+
* match (e.g. table is also being dropped, or no `AlterPublicationDropTables`
|
|
210
|
+
* references the table).
|
|
211
|
+
*/
|
|
212
|
+
function tryBreakPublicationColumnCycle(cycleNodeIndexes, phaseChanges) {
|
|
213
|
+
// Find an `AlterTableDropColumn` and an `AlterPublicationDropTables` in
|
|
214
|
+
// the cycle that reference the same table. Both must be present —
|
|
215
|
+
// otherwise this is a different cycle shape.
|
|
216
|
+
let dropColumnIndex = -1;
|
|
217
|
+
let dropColumnChange = null;
|
|
218
|
+
let pubMatchesTable = false;
|
|
219
|
+
let pubChange = null;
|
|
220
|
+
for (const nodeIndex of cycleNodeIndexes) {
|
|
221
|
+
const change = phaseChanges[nodeIndex];
|
|
222
|
+
if (change instanceof AlterTableDropColumn &&
|
|
223
|
+
!change.omitTableRequirement) {
|
|
224
|
+
dropColumnIndex = nodeIndex;
|
|
225
|
+
dropColumnChange = change;
|
|
226
|
+
}
|
|
227
|
+
else if (change instanceof AlterPublicationDropTables) {
|
|
228
|
+
pubChange = change;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (dropColumnChange === null || pubChange === null)
|
|
232
|
+
return null;
|
|
233
|
+
// Verify the publication is actually dropping membership for the same
|
|
234
|
+
// table whose column is being dropped. Without this check we'd risk
|
|
235
|
+
// rewriting an unrelated AlterTableDropColumn that happens to share a
|
|
236
|
+
// cycle with some other publication change.
|
|
237
|
+
const targetTableId = dropColumnChange.table.stableId;
|
|
238
|
+
for (const t of pubChange.tables) {
|
|
239
|
+
if (stableId.table(t.schema, t.name) === targetTableId) {
|
|
240
|
+
pubMatchesTable = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!pubMatchesTable)
|
|
245
|
+
return null;
|
|
246
|
+
// Verify the table is NOT itself being dropped. If `DropTable(T)` is in
|
|
247
|
+
// the same phase, the existing structural rewrites in
|
|
248
|
+
// `post-diff-cycle-breaking.ts` (replace-expansion superseded filter)
|
|
249
|
+
// already prune the redundant `AlterTableDropColumn`, so we should not
|
|
250
|
+
// see this combination here. Be defensive and bail anyway — flipping
|
|
251
|
+
// `omitTableRequirement` when T is being dropped would let the column
|
|
252
|
+
// drop reorder against the table drop, which is unsafe.
|
|
253
|
+
for (const change of phaseChanges) {
|
|
254
|
+
if (change instanceof DropTable &&
|
|
255
|
+
change.table.stableId === targetTableId) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Replace the AlterTableDropColumn with a fresh instance carrying
|
|
260
|
+
// `omitTableRequirement=true`. All other changes pass through
|
|
261
|
+
// unchanged.
|
|
262
|
+
const rewritten = phaseChanges.slice();
|
|
263
|
+
rewritten[dropColumnIndex] = new AlterTableDropColumn({
|
|
264
|
+
table: dropColumnChange.table,
|
|
265
|
+
column: dropColumnChange.column,
|
|
266
|
+
omitTableRequirement: true,
|
|
267
|
+
});
|
|
268
|
+
return rewritten;
|
|
269
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import debug from "debug";
|
|
14
14
|
import { generateCustomConstraints } from "./custom-constraints.js";
|
|
15
|
+
import { tryBreakCycleByChangeInjection } from "./cycle-breakers.js";
|
|
15
16
|
import { printDebugGraph } from "./debug-visualization.js";
|
|
16
17
|
const debugGraph = debug("pg-delta:graph");
|
|
17
18
|
import { filterEdgesForCycleBreaking, getEdgesInCycle, } from "./dependency-filter.js";
|
|
@@ -20,6 +21,13 @@ import { dedupeEdges } from "./graph-utils.js";
|
|
|
20
21
|
import { logicalSort } from "./logical-sort.js";
|
|
21
22
|
import { findCycle, formatCycleError, performStableTopologicalSort, } from "./topological-sort.js";
|
|
22
23
|
import { getExecutionPhase } from "./utils.js";
|
|
24
|
+
// `sortPhaseChanges` caps the change-injection breaker at one round per
|
|
25
|
+
// node in the initial phase: there can never be more disjoint unbreakable
|
|
26
|
+
// cycles than there are change nodes (each cycle has ≥ 2 distinct nodes).
|
|
27
|
+
// The cap exists only to surface a buggy breaker as `CycleError` instead
|
|
28
|
+
// of an infinite loop — the actual loop-protection guarantee comes from
|
|
29
|
+
// `breakerRoundSignatures`, which throws the moment the same cycle
|
|
30
|
+
// reappears after a break.
|
|
23
31
|
/**
|
|
24
32
|
* Sort changes using dependency information from catalogs and custom constraints.
|
|
25
33
|
*
|
|
@@ -63,20 +71,38 @@ function sortChangesByPhasedGraph(catalogContext, changeList) {
|
|
|
63
71
|
return [...sortedDropPhase, ...sortedCreateAlterPhase];
|
|
64
72
|
}
|
|
65
73
|
/**
|
|
66
|
-
*
|
|
74
|
+
* Normalize a cycle by rotating it to start with the smallest node index, so
|
|
75
|
+
* cycles that loop through the same nodes in the same direction compare equal
|
|
76
|
+
* regardless of where DFS happened to enter them.
|
|
77
|
+
*/
|
|
78
|
+
function normalizeCycle(cycleNodeIndexes) {
|
|
79
|
+
if (cycleNodeIndexes.length === 0)
|
|
80
|
+
return "";
|
|
81
|
+
const minIndex = Math.min(...cycleNodeIndexes);
|
|
82
|
+
const minIndexPos = cycleNodeIndexes.indexOf(minIndex);
|
|
83
|
+
const rotated = [
|
|
84
|
+
...cycleNodeIndexes.slice(minIndexPos),
|
|
85
|
+
...cycleNodeIndexes.slice(0, minIndexPos),
|
|
86
|
+
];
|
|
87
|
+
return rotated.join(",");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One attempt at sorting `phaseChanges`. Builds the graph from scratch,
|
|
91
|
+
* runs the iterative edge-removal cycle handler, and either returns a
|
|
92
|
+
* topologically sorted list or reports an unbreakable cycle so the caller
|
|
93
|
+
* can decide whether to dispatch a change-injection breaker.
|
|
67
94
|
*
|
|
68
95
|
* Algorithm:
|
|
69
|
-
* 1. Build graph data (change sets and reverse indexes)
|
|
70
|
-
* 2. Convert all sources to Constraints (catalog, explicit, custom
|
|
71
|
-
* 3. Convert Constraints to edges
|
|
72
|
-
* 4. Iteratively detect and break cycles
|
|
73
|
-
* 5. Perform stable topological sort on the acyclic graph
|
|
96
|
+
* 1. Build graph data (change sets and reverse indexes).
|
|
97
|
+
* 2. Convert all sources to Constraints (catalog, explicit, custom).
|
|
98
|
+
* 3. Convert Constraints to edges.
|
|
99
|
+
* 4. Iteratively detect and break cycles by removing weak edges.
|
|
100
|
+
* 5. Perform stable topological sort on the acyclic graph.
|
|
74
101
|
*
|
|
75
|
-
* In DROP phase, edges are inverted so drops run in reverse dependency
|
|
102
|
+
* In DROP phase, edges are inverted so drops run in reverse dependency
|
|
103
|
+
* order.
|
|
76
104
|
*/
|
|
77
|
-
function
|
|
78
|
-
if (phaseChanges.length <= 1)
|
|
79
|
-
return phaseChanges;
|
|
105
|
+
function attemptSortRound(phaseChanges, dependencyRows, options) {
|
|
80
106
|
// Step 1: Build graph data structures
|
|
81
107
|
const graphData = buildGraphData(phaseChanges, options);
|
|
82
108
|
// Step 2: Convert all sources to Constraints
|
|
@@ -90,50 +116,31 @@ function sortPhaseChanges(phaseChanges, dependencyRows, options = {}) {
|
|
|
90
116
|
];
|
|
91
117
|
// Step 3: Convert constraints to edges and deduplicate immediately
|
|
92
118
|
let edges = dedupeEdges(convertConstraintsToEdges(allConstraints, options));
|
|
93
|
-
// Step 4: Iteratively detect and break cycles
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
119
|
+
// Step 4: Iteratively detect and break cycles by edge filtering.
|
|
120
|
+
// We loop until no cycles remain OR we see the same cycle twice — the
|
|
121
|
+
// latter signals that edge filtering exhausted itself. At that point
|
|
122
|
+
// the caller may dispatch a change-injection breaker; if no breaker
|
|
123
|
+
// matches, the original throw path runs.
|
|
97
124
|
const seenCycles = new Set();
|
|
98
|
-
/**
|
|
99
|
-
* Normalize a cycle by rotating it to start with the smallest node index.
|
|
100
|
-
* This allows us to compare cycles regardless of where they start.
|
|
101
|
-
*/
|
|
102
|
-
function normalizeCycle(cycleNodeIndexes) {
|
|
103
|
-
if (cycleNodeIndexes.length === 0)
|
|
104
|
-
return "";
|
|
105
|
-
const minIndex = Math.min(...cycleNodeIndexes);
|
|
106
|
-
const minIndexPos = cycleNodeIndexes.indexOf(minIndex);
|
|
107
|
-
const rotated = [
|
|
108
|
-
...cycleNodeIndexes.slice(minIndexPos),
|
|
109
|
-
...cycleNodeIndexes.slice(0, minIndexPos),
|
|
110
|
-
];
|
|
111
|
-
return rotated.join(",");
|
|
112
|
-
}
|
|
113
125
|
while (true) {
|
|
114
|
-
// Edge deduplication moved outside loop
|
|
115
126
|
const edgePairs = edgesToPairs(edges);
|
|
116
|
-
// Detect cycles
|
|
117
127
|
const cycleNodeIndexes = findCycle(phaseChanges.length, edgePairs);
|
|
118
|
-
if (!cycleNodeIndexes)
|
|
119
|
-
// No cycles found, we're done
|
|
128
|
+
if (!cycleNodeIndexes)
|
|
120
129
|
break;
|
|
121
|
-
}
|
|
122
|
-
// Normalize cycle to check if we've seen it before
|
|
123
130
|
const cycleSignature = normalizeCycle(cycleNodeIndexes);
|
|
124
131
|
if (seenCycles.has(cycleSignature)) {
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
// Edge filtering can't break this cycle. Report it back to the
|
|
133
|
+
// caller so it can try change-injection before throwing.
|
|
134
|
+
return {
|
|
135
|
+
kind: "unbreakable",
|
|
136
|
+
cycleNodeIndexes,
|
|
137
|
+
cycleEdges: getEdgesInCycle(cycleNodeIndexes, edges),
|
|
138
|
+
};
|
|
129
139
|
}
|
|
130
|
-
// Track this cycle
|
|
131
140
|
seenCycles.add(cycleSignature);
|
|
132
|
-
// Filter only edges involved in the cycle to break it
|
|
133
141
|
edges = filterEdgesForCycleBreaking(edges, cycleNodeIndexes, phaseChanges, graphData);
|
|
134
142
|
}
|
|
135
143
|
const finalEdgePairs = edgesToPairs(edges);
|
|
136
|
-
// Debug visualization
|
|
137
144
|
if (debugGraph.enabled) {
|
|
138
145
|
printDebugGraph(phaseChanges, graphData, finalEdgePairs, dependencyRows, allConstraints);
|
|
139
146
|
}
|
|
@@ -143,5 +150,52 @@ function sortPhaseChanges(phaseChanges, dependencyRows, options = {}) {
|
|
|
143
150
|
// This should never happen if findCycle returned null, but guard anyway
|
|
144
151
|
throw new Error("CycleError: dependency graph contains a cycle");
|
|
145
152
|
}
|
|
146
|
-
return
|
|
153
|
+
return {
|
|
154
|
+
kind: "sorted",
|
|
155
|
+
sorted: topologicalOrder.map((changeIndex) => phaseChanges[changeIndex]),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Sort changes within a phase. Tries `attemptSortRound`; on an unbreakable
|
|
160
|
+
* cycle, dispatches to `tryBreakCycleByChangeInjection`, retries with the
|
|
161
|
+
* rewritten changes, and bails after `MAX_CYCLE_BREAKER_ROUNDS` to surface
|
|
162
|
+
* a buggy breaker as `CycleError` instead of an infinite loop.
|
|
163
|
+
*
|
|
164
|
+
* Best case (no cycles, the vast majority of plans): one round, no
|
|
165
|
+
* change-injection breaker code runs at all.
|
|
166
|
+
*/
|
|
167
|
+
function sortPhaseChanges(initialPhaseChanges, dependencyRows, options = {}) {
|
|
168
|
+
if (initialPhaseChanges.length <= 1)
|
|
169
|
+
return initialPhaseChanges;
|
|
170
|
+
let phaseChanges = initialPhaseChanges;
|
|
171
|
+
const breakerRoundSignatures = new Set();
|
|
172
|
+
// `attemptSortRound` returns at most one unbreakable cycle per call,
|
|
173
|
+
// so a phase with K independent unbreakable cycles needs K+1 rounds.
|
|
174
|
+
// Every cycle contains ≥ 2 distinct change nodes, so the maximum
|
|
175
|
+
// possible value of K is `floor(initialPhaseChanges.length / 2)` —
|
|
176
|
+
// using `initialPhaseChanges.length` itself is therefore a real upper
|
|
177
|
+
// bound with one round of slack (and matches the early-return guard
|
|
178
|
+
// above, which already excluded length-0 and length-1 phases).
|
|
179
|
+
const maxRounds = initialPhaseChanges.length;
|
|
180
|
+
for (let round = 0; round <= maxRounds; round++) {
|
|
181
|
+
const result = attemptSortRound(phaseChanges, dependencyRows, options);
|
|
182
|
+
if (result.kind === "sorted")
|
|
183
|
+
return result.sorted;
|
|
184
|
+
// Edge filtering hit an unbreakable cycle. Try the change-injection
|
|
185
|
+
// breakers (FK pattern, publication↔column pattern). If none matches,
|
|
186
|
+
// throw with the same diagnostic the original code emitted.
|
|
187
|
+
const broken = tryBreakCycleByChangeInjection(result.cycleNodeIndexes, phaseChanges);
|
|
188
|
+
if (broken === null) {
|
|
189
|
+
throw new Error(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges));
|
|
190
|
+
}
|
|
191
|
+
// Loop guard: if the same cycle node-set re-appears after a break,
|
|
192
|
+
// the breaker isn't making progress. Throw with full context.
|
|
193
|
+
const signature = normalizeCycle(result.cycleNodeIndexes);
|
|
194
|
+
if (breakerRoundSignatures.has(signature)) {
|
|
195
|
+
throw new Error(formatCycleError(result.cycleNodeIndexes, phaseChanges, result.cycleEdges));
|
|
196
|
+
}
|
|
197
|
+
breakerRoundSignatures.add(signature);
|
|
198
|
+
phaseChanges = broken;
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`CycleError: change-injection breaker exceeded ${maxRounds} rounds (one per node in the phase) — likely a buggy breaker rule`);
|
|
147
201
|
}
|
package/package.json
CHANGED
package/src/core/catalog.diff.ts
CHANGED
|
@@ -99,17 +99,21 @@ describe("expandReplaceDependencies", () => {
|
|
|
99
99
|
|
|
100
100
|
test("does not replace the owning table for an owned sequence recreation", async () => {
|
|
101
101
|
const baseline = await createEmptyCatalog(170000, "postgres");
|
|
102
|
+
// Use `persistence` (UNLOGGED → LOGGED) to trigger the
|
|
103
|
+
// non-alterable replace path: it's the only field still in
|
|
104
|
+
// NON_ALTERABLE_FIELDS. `data_type` was previously in that list
|
|
105
|
+
// but is now alterable in place via ALTER SEQUENCE ... AS <type>.
|
|
102
106
|
const mainSequence = new Sequence({
|
|
103
107
|
schema: "public",
|
|
104
108
|
name: "user_id_seq",
|
|
105
|
-
data_type: "
|
|
109
|
+
data_type: "bigint",
|
|
106
110
|
start_value: 1,
|
|
107
111
|
minimum_value: 1n,
|
|
108
|
-
maximum_value:
|
|
112
|
+
maximum_value: 9223372036854775807n,
|
|
109
113
|
increment: 1,
|
|
110
114
|
cycle_option: false,
|
|
111
115
|
cache_size: 1,
|
|
112
|
-
persistence: "
|
|
116
|
+
persistence: "u",
|
|
113
117
|
owned_by_schema: "public",
|
|
114
118
|
owned_by_table: "users",
|
|
115
119
|
owned_by_column: "id",
|
|
@@ -119,8 +123,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
119
123
|
});
|
|
120
124
|
const branchSequence = new Sequence({
|
|
121
125
|
...mainSequence,
|
|
122
|
-
|
|
123
|
-
maximum_value: 9223372036854775807n,
|
|
126
|
+
persistence: "p",
|
|
124
127
|
});
|
|
125
128
|
const usersTable = new Table({
|
|
126
129
|
schema: "public",
|
|
@@ -128,7 +128,7 @@ export class AlterPublicationAddTables extends AlterPublicationChange {
|
|
|
128
128
|
export class AlterPublicationDropTables extends AlterPublicationChange {
|
|
129
129
|
public readonly publication: Publication;
|
|
130
130
|
public readonly scope = "object" as const;
|
|
131
|
-
|
|
131
|
+
public readonly tables: PublicationTableProps[];
|
|
132
132
|
|
|
133
133
|
constructor(props: {
|
|
134
134
|
publication: Publication;
|
|
@@ -109,15 +109,20 @@ describe.concurrent("sequence.diff", () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test("replacing an owned sequence re-emits the owning column default", () => {
|
|
112
|
+
// Use `persistence` (UNLOGGED → LOGGED) to trigger the
|
|
113
|
+
// non-alterable replace path: it's the only field still in
|
|
114
|
+
// NON_ALTERABLE_FIELDS. `data_type` was previously in that list
|
|
115
|
+
// but is now alterable in place via ALTER SEQUENCE ... AS <type>.
|
|
112
116
|
const main = new Sequence({
|
|
113
117
|
...base,
|
|
114
|
-
|
|
118
|
+
persistence: "u",
|
|
115
119
|
owned_by_schema: "public",
|
|
116
120
|
owned_by_table: "users",
|
|
117
121
|
owned_by_column: "id",
|
|
118
122
|
});
|
|
119
123
|
const branch = new Sequence({
|
|
120
124
|
...base,
|
|
125
|
+
persistence: "p",
|
|
121
126
|
owned_by_schema: "public",
|
|
122
127
|
owned_by_table: "users",
|
|
123
128
|
owned_by_column: "id",
|
|
@@ -150,10 +150,7 @@ export function diffSequences(
|
|
|
150
150
|
|
|
151
151
|
// Check if non-alterable properties have changed
|
|
152
152
|
// These require dropping and recreating the sequence
|
|
153
|
-
const NON_ALTERABLE_FIELDS: Array<keyof Sequence> = [
|
|
154
|
-
"data_type",
|
|
155
|
-
"persistence",
|
|
156
|
-
];
|
|
153
|
+
const NON_ALTERABLE_FIELDS: Array<keyof Sequence> = ["persistence"];
|
|
157
154
|
const nonAlterablePropsChanged = hasNonAlterableChanges(
|
|
158
155
|
mainSequence,
|
|
159
156
|
branchSequence,
|
|
@@ -215,6 +212,7 @@ export function diffSequences(
|
|
|
215
212
|
} else {
|
|
216
213
|
// Only alterable properties changed - emit ALTER for options/owner
|
|
217
214
|
const optionsChanged =
|
|
215
|
+
mainSequence.data_type !== branchSequence.data_type ||
|
|
218
216
|
mainSequence.increment !== branchSequence.increment ||
|
|
219
217
|
mainSequence.minimum_value !== branchSequence.minimum_value ||
|
|
220
218
|
mainSequence.maximum_value !== branchSequence.maximum_value ||
|
|
@@ -224,6 +222,16 @@ export function diffSequences(
|
|
|
224
222
|
|
|
225
223
|
if (optionsChanged) {
|
|
226
224
|
const options: string[] = [];
|
|
225
|
+
// `AS <type>` must come before any MIN/MAX/RESTART clauses per the
|
|
226
|
+
// PG ALTER SEQUENCE grammar. Valid types are smallint, integer,
|
|
227
|
+
// bigint — the same set CREATE SEQUENCE accepts — so the universe
|
|
228
|
+
// of legal transitions is closed. PG enforces last_value range at
|
|
229
|
+
// apply time when shrinking; that's the desired behavior because
|
|
230
|
+
// the previous Drop+Create path silently reset last_value to 1
|
|
231
|
+
// (data-loss bug, see Sentry SUPABASE-API-7RS).
|
|
232
|
+
if (mainSequence.data_type !== branchSequence.data_type) {
|
|
233
|
+
options.push("AS", branchSequence.data_type);
|
|
234
|
+
}
|
|
227
235
|
if (mainSequence.increment !== branchSequence.increment) {
|
|
228
236
|
options.push("INCREMENT BY", String(branchSequence.increment));
|
|
229
237
|
}
|
|
@@ -444,6 +444,11 @@ describe.concurrent("table", () => {
|
|
|
444
444
|
data_type: "text",
|
|
445
445
|
data_type_str: "text",
|
|
446
446
|
};
|
|
447
|
+
const colTextBefore: ColumnProps = {
|
|
448
|
+
...colText,
|
|
449
|
+
data_type: "integer",
|
|
450
|
+
data_type_str: "integer",
|
|
451
|
+
};
|
|
447
452
|
const withCols = new Table({
|
|
448
453
|
...tableProps,
|
|
449
454
|
owner: "o1",
|
|
@@ -477,10 +482,11 @@ describe.concurrent("table", () => {
|
|
|
477
482
|
const changeType = new AlterTableAlterColumnType({
|
|
478
483
|
table: withCols,
|
|
479
484
|
column: colText,
|
|
485
|
+
previousColumn: colTextBefore,
|
|
480
486
|
});
|
|
481
487
|
await assertValidSql(changeType.serialize());
|
|
482
488
|
expect(changeType.serialize()).toBe(
|
|
483
|
-
"ALTER TABLE public.test_table ALTER COLUMN b TYPE text",
|
|
489
|
+
"ALTER TABLE public.test_table ALTER COLUMN b TYPE text USING b::text",
|
|
484
490
|
);
|
|
485
491
|
|
|
486
492
|
const changeSetDefault = new AlterTableAlterColumnSetDefault({
|
|
@@ -659,10 +665,15 @@ describe.concurrent("table", () => {
|
|
|
659
665
|
const change = new AlterTableAlterColumnType({
|
|
660
666
|
table: withCols,
|
|
661
667
|
column: col,
|
|
668
|
+
previousColumn: {
|
|
669
|
+
...col,
|
|
670
|
+
data_type: "integer",
|
|
671
|
+
data_type_str: "integer",
|
|
672
|
+
},
|
|
662
673
|
});
|
|
663
674
|
await assertValidSql(change.serialize());
|
|
664
675
|
expect(change.serialize()).toBe(
|
|
665
|
-
"ALTER TABLE public.test_table ALTER COLUMN b TYPE text COLLATE mycoll",
|
|
676
|
+
"ALTER TABLE public.test_table ALTER COLUMN b TYPE text COLLATE mycoll USING b::text",
|
|
666
677
|
);
|
|
667
678
|
});
|
|
668
679
|
|