@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.
Files changed (28) hide show
  1. package/dist/core/catalog.diff.js +0 -1
  2. package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
  3. package/dist/core/objects/sequence/sequence.diff.js +13 -5
  4. package/dist/core/objects/table/changes/table.alter.d.ts +4 -0
  5. package/dist/core/objects/table/changes/table.alter.js +19 -4
  6. package/dist/core/objects/table/table.diff.js +21 -2
  7. package/dist/core/objects/table/table.model.js +10 -7
  8. package/dist/core/post-diff-cycle-breaking.d.ts +21 -21
  9. package/dist/core/post-diff-cycle-breaking.js +24 -133
  10. package/dist/core/sort/cycle-breakers.d.ts +15 -0
  11. package/dist/core/sort/cycle-breakers.js +269 -0
  12. package/dist/core/sort/sort-changes.js +97 -43
  13. package/package.json +1 -1
  14. package/src/core/catalog.diff.ts +0 -1
  15. package/src/core/expand-replace-dependencies.test.ts +8 -5
  16. package/src/core/objects/publication/changes/publication.alter.ts +1 -1
  17. package/src/core/objects/sequence/sequence.diff.test.ts +6 -1
  18. package/src/core/objects/sequence/sequence.diff.ts +12 -4
  19. package/src/core/objects/table/changes/table.alter.test.ts +13 -2
  20. package/src/core/objects/table/changes/table.alter.ts +36 -7
  21. package/src/core/objects/table/table.diff.test.ts +43 -0
  22. package/src/core/objects/table/table.diff.ts +28 -4
  23. package/src/core/objects/table/table.model.ts +10 -7
  24. package/src/core/post-diff-cycle-breaking.test.ts +0 -156
  25. package/src/core/post-diff-cycle-breaking.ts +23 -202
  26. package/src/core/sort/cycle-breakers.test.ts +476 -0
  27. package/src/core/sort/cycle-breakers.ts +311 -0
  28. 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
- * Sort changes within a phase using Constraints derived from all dependency sources.
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 constraints)
71
- * 3. Convert Constraints to edges
72
- * 4. Iteratively detect and break cycles (deduplicate edges, detect cycles, filter problematic edges)
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 order.
102
+ * In DROP phase, edges are inverted so drops run in reverse dependency
103
+ * order.
76
104
  */
77
- function sortPhaseChanges(phaseChanges, dependencyRows, options = {}) {
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
- // Track cycles we've seen to detect when filtering fails to break a cycle.
95
- // The only way we loop indefinitely is if we encounter a cycle we've already seen,
96
- // which means filtering didn't break it. Otherwise, we continue until all cycles are broken.
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
- // We've seen this cycle before - filtering didn't break it
126
- // Get edges involved in the cycle for detailed error message
127
- const cycleEdges = getEdgesInCycle(cycleNodeIndexes, edges);
128
- throw new Error(formatCycleError(cycleNodeIndexes, phaseChanges, cycleEdges));
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 topologicalOrder.map((changeIndex) => phaseChanges[changeIndex]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.20",
3
+ "version": "1.0.0-alpha.21",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -240,7 +240,6 @@ export function diffCatalogs(
240
240
  });
241
241
  filteredChanges = normalizePostDiffCycles({
242
242
  changes: expandedDependencies.changes,
243
- mainCatalog: main,
244
243
  replacedTableIds: expandedDependencies.replacedTableIds,
245
244
  });
246
245
 
@@ -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: "integer",
109
+ data_type: "bigint",
106
110
  start_value: 1,
107
111
  minimum_value: 1n,
108
- maximum_value: 2147483647n,
112
+ maximum_value: 9223372036854775807n,
109
113
  increment: 1,
110
114
  cycle_option: false,
111
115
  cache_size: 1,
112
- persistence: "p",
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
- data_type: "bigint",
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
- private readonly tables: PublicationTableProps[];
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
- data_type: "integer",
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