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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/core/catalog.diff.js +4 -4
  2. package/dist/core/catalog.model.d.ts +8 -1
  3. package/dist/core/catalog.model.js +9 -8
  4. package/dist/core/expand-replace-dependencies.js +23 -0
  5. package/dist/core/objects/extract-with-retry.d.ts +36 -0
  6. package/dist/core/objects/extract-with-retry.js +51 -0
  7. package/dist/core/objects/index/index.diff.js +0 -1
  8. package/dist/core/objects/index/index.model.d.ts +2 -3
  9. package/dist/core/objects/index/index.model.js +17 -6
  10. package/dist/core/objects/materialized-view/materialized-view.model.d.ts +2 -1
  11. package/dist/core/objects/materialized-view/materialized-view.model.js +20 -4
  12. package/dist/core/objects/procedure/procedure.model.d.ts +2 -1
  13. package/dist/core/objects/procedure/procedure.model.js +20 -4
  14. package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
  15. package/dist/core/objects/rls-policy/rls-policy.diff.js +13 -1
  16. package/dist/core/objects/rule/rule.model.d.ts +2 -1
  17. package/dist/core/objects/rule/rule.model.js +20 -3
  18. package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
  19. package/dist/core/objects/sequence/sequence.diff.js +41 -9
  20. package/dist/core/objects/table/changes/table.alter.d.ts +16 -1
  21. package/dist/core/objects/table/changes/table.alter.js +39 -6
  22. package/dist/core/objects/table/table.diff.js +40 -17
  23. package/dist/core/objects/table/table.model.d.ts +6 -1
  24. package/dist/core/objects/table/table.model.js +50 -12
  25. package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
  26. package/dist/core/objects/trigger/trigger.model.js +20 -4
  27. package/dist/core/objects/utils.d.ts +1 -0
  28. package/dist/core/objects/utils.js +3 -0
  29. package/dist/core/objects/view/view.model.d.ts +2 -1
  30. package/dist/core/objects/view/view.model.js +20 -4
  31. package/dist/core/plan/create.js +3 -1
  32. package/dist/core/plan/types.d.ts +8 -0
  33. package/dist/core/post-diff-normalization.d.ts +36 -0
  34. package/dist/core/post-diff-normalization.js +202 -0
  35. package/dist/core/sort/cycle-breakers.d.ts +15 -0
  36. package/dist/core/sort/cycle-breakers.js +269 -0
  37. package/dist/core/sort/sort-changes.js +97 -43
  38. package/dist/core/sort/utils.d.ts +10 -0
  39. package/dist/core/sort/utils.js +28 -0
  40. package/package.json +1 -1
  41. package/src/core/catalog.diff.ts +4 -3
  42. package/src/core/catalog.model.ts +20 -8
  43. package/src/core/expand-replace-dependencies.test.ts +139 -5
  44. package/src/core/expand-replace-dependencies.ts +24 -0
  45. package/src/core/objects/extract-with-retry.test.ts +143 -0
  46. package/src/core/objects/extract-with-retry.ts +87 -0
  47. package/src/core/objects/index/index.diff.ts +0 -1
  48. package/src/core/objects/index/index.model.test.ts +37 -1
  49. package/src/core/objects/index/index.model.ts +25 -6
  50. package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
  51. package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
  52. package/src/core/objects/procedure/procedure.model.test.ts +117 -0
  53. package/src/core/objects/procedure/procedure.model.ts +28 -5
  54. package/src/core/objects/publication/changes/publication.alter.ts +1 -1
  55. package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
  56. package/src/core/objects/rule/rule.model.test.ts +99 -0
  57. package/src/core/objects/rule/rule.model.ts +28 -4
  58. package/src/core/objects/sequence/sequence.diff.test.ts +93 -1
  59. package/src/core/objects/sequence/sequence.diff.ts +43 -10
  60. package/src/core/objects/table/changes/table.alter.test.ts +26 -23
  61. package/src/core/objects/table/changes/table.alter.ts +66 -10
  62. package/src/core/objects/table/table.diff.test.ts +43 -0
  63. package/src/core/objects/table/table.diff.ts +52 -23
  64. package/src/core/objects/table/table.model.test.ts +209 -0
  65. package/src/core/objects/table/table.model.ts +62 -14
  66. package/src/core/objects/trigger/trigger.model.test.ts +113 -0
  67. package/src/core/objects/trigger/trigger.model.ts +28 -5
  68. package/src/core/objects/utils.ts +3 -0
  69. package/src/core/objects/view/view.model.test.ts +90 -0
  70. package/src/core/objects/view/view.model.ts +28 -5
  71. package/src/core/plan/create.ts +3 -1
  72. package/src/core/plan/types.ts +8 -0
  73. package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -160
  74. package/src/core/post-diff-normalization.ts +260 -0
  75. package/src/core/sort/cycle-breakers.test.ts +476 -0
  76. package/src/core/sort/cycle-breakers.ts +311 -0
  77. package/src/core/sort/sort-changes.ts +135 -50
  78. package/src/core/sort/utils.ts +38 -0
  79. package/dist/core/post-diff-cycle-breaking.d.ts +0 -29
  80. package/dist/core/post-diff-cycle-breaking.js +0 -209
  81. package/src/core/post-diff-cycle-breaking.ts +0 -317
@@ -0,0 +1,202 @@
1
+ import { CreateIndex } from "./objects/index/changes/index.create.js";
2
+ import { DropIndex } from "./objects/index/changes/index.drop.js";
3
+ import { AlterTableAddConstraint, AlterTableDropColumn, AlterTableDropConstraint, AlterTableSetReplicaIdentity, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.js";
4
+ import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
5
+ import { stableId } from "./objects/utils.js";
6
+ function constraintStableId(table, constraintName) {
7
+ return stableId.constraint(table.schema, table.name, constraintName);
8
+ }
9
+ function isSupersededByTableReplacement(change, replacedTableIds) {
10
+ if (!(change instanceof AlterTableDropColumn) &&
11
+ !(change instanceof AlterTableDropConstraint)) {
12
+ return false;
13
+ }
14
+ return replacedTableIds.has(change.table.stableId);
15
+ }
16
+ /**
17
+ * Drop earlier duplicates of `AlterTableAddConstraint` /
18
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
19
+ * replaced tables, keeping only the last occurrence of each
20
+ * `(changeType, table.stableId, constraint.name)`.
21
+ *
22
+ * When `expandReplaceDependencies()` promotes a table to a full
23
+ * `DropTable + CreateTable` pair, it also emits one
24
+ * `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
25
+ * `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
26
+ * emitted the same change for a shape flip or a new constraint on that
27
+ * table, the plan ends up with two identical `ALTER TABLE ... ADD
28
+ * CONSTRAINT ...` statements and PostgreSQL fails at apply time with
29
+ * `constraint "..." for relation "..." already exists`. Because
30
+ * `expandReplaceDependencies()` appends its additions after the original
31
+ * `diffTables()` output, the last occurrence is the expansion's emission —
32
+ * keeping it preserves correctness while removing the duplicate.
33
+ */
34
+ function dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds) {
35
+ if (replacedTableIds.size === 0)
36
+ return changes;
37
+ const keyFor = (change) => {
38
+ if (!(change instanceof AlterTableAddConstraint) &&
39
+ !(change instanceof AlterTableValidateConstraint) &&
40
+ !(change instanceof CreateCommentOnConstraint)) {
41
+ return null;
42
+ }
43
+ if (!replacedTableIds.has(change.table.stableId))
44
+ return null;
45
+ const tag = change instanceof AlterTableAddConstraint
46
+ ? "add"
47
+ : change instanceof AlterTableValidateConstraint
48
+ ? "validate"
49
+ : "comment";
50
+ return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
51
+ };
52
+ const seen = new Set();
53
+ const reversedKept = [];
54
+ let mutated = false;
55
+ // Walk backwards: the first encounter of each key corresponds to its LAST
56
+ // occurrence in the original order. `expandReplaceDependencies()` appends
57
+ // additions after the original changes, so "last wins" keeps the
58
+ // expansion's emission and drops the earlier diffTables duplicate.
59
+ for (let i = changes.length - 1; i >= 0; i--) {
60
+ const change = changes[i];
61
+ const key = keyFor(change);
62
+ if (key !== null) {
63
+ if (seen.has(key)) {
64
+ mutated = true;
65
+ continue;
66
+ }
67
+ seen.add(key);
68
+ }
69
+ reversedKept.push(change);
70
+ }
71
+ return mutated ? reversedKept.reverse() : changes;
72
+ }
73
+ /**
74
+ * Re-emit `ALTER TABLE ... REPLICA IDENTITY USING INDEX <idx>` after any
75
+ * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica-identity
76
+ * index of a branch table.
77
+ *
78
+ * Background: PostgreSQL silently flips a table's `relreplident` to `'d'`
79
+ * (DEFAULT) when the index it points to is dropped. `CREATE INDEX` cannot
80
+ * restore the marker — only `ALTER TABLE ... REPLICA IDENTITY USING INDEX`
81
+ * can. When both main and branch carry `replica_identity = 'i'` pointing at
82
+ * the same index name, `diffTables()` emits no replica-identity change of its
83
+ * own, so the marker would be lost on apply.
84
+ *
85
+ * This is a whole-plan interaction: `diffTables()` cannot detect it without
86
+ * also looking at index changes. Per the "whole-plan interactions belong in
87
+ * post-diff normalization" rule in the package CLAUDE.md, the restoration
88
+ * lives here.
89
+ *
90
+ * Insertion is idempotent: if `diffTables()` already emitted the same
91
+ * `AlterTableSetReplicaIdentity` for this table (e.g. when the user is also
92
+ * switching the replica-identity index name in the same migration), no
93
+ * duplicate is added.
94
+ */
95
+ function restoreReplicaIdentityAfterIndexReplace(changes, branchTables) {
96
+ // Build the index-stable-id → owning-table map from branch state. Only
97
+ // tables in 'i' mode contribute, and only those whose configured index name
98
+ // is non-null (the extractor returns null for any other mode).
99
+ const replicaIdentityIndexToTable = new Map();
100
+ for (const table of Object.values(branchTables)) {
101
+ if (table.replica_identity !== "i" || !table.replica_identity_index) {
102
+ continue;
103
+ }
104
+ const indexId = stableId.index(table.schema, table.name, table.replica_identity_index);
105
+ replicaIdentityIndexToTable.set(indexId, table);
106
+ }
107
+ if (replicaIdentityIndexToTable.size === 0)
108
+ return changes;
109
+ // Find the indexes that are both dropped AND created in this plan. A pure
110
+ // drop or a pure create is handled by `diffTables()` directly (the table's
111
+ // replica_identity / replica_identity_index fields will have changed). The
112
+ // hole is specifically the drop+create pair that recreates the same name.
113
+ const droppedIndexIds = new Set();
114
+ const createdIndexIds = new Set();
115
+ for (const change of changes) {
116
+ if (change instanceof DropIndex) {
117
+ droppedIndexIds.add(change.index.stableId);
118
+ }
119
+ else if (change instanceof CreateIndex) {
120
+ createdIndexIds.add(change.index.stableId);
121
+ }
122
+ }
123
+ const replacedIndexIds = new Set();
124
+ for (const id of droppedIndexIds) {
125
+ if (createdIndexIds.has(id) && replicaIdentityIndexToTable.has(id)) {
126
+ replacedIndexIds.add(id);
127
+ }
128
+ }
129
+ if (replacedIndexIds.size === 0)
130
+ return changes;
131
+ // Skip tables for which `diffTables()` already emitted a replica-identity
132
+ // setter — re-emitting would produce a redundant ALTER TABLE (harmless on
133
+ // apply, but noisy in plan output).
134
+ const tablesWithExistingReplicaIdentitySetter = new Set();
135
+ for (const change of changes) {
136
+ if (change instanceof AlterTableSetReplicaIdentity) {
137
+ tablesWithExistingReplicaIdentitySetter.add(change.table.stableId);
138
+ }
139
+ }
140
+ // Insert one `AlterTableSetReplicaIdentity` per replaced index, immediately
141
+ // after the matching `CreateIndex`. The change's `requires` already names
142
+ // both the table and the recreated index, so the topo sort orders it
143
+ // correctly relative to the surrounding DDL.
144
+ const result = [];
145
+ for (const change of changes) {
146
+ result.push(change);
147
+ if (!(change instanceof CreateIndex) ||
148
+ !replacedIndexIds.has(change.index.stableId)) {
149
+ continue;
150
+ }
151
+ const table = replicaIdentityIndexToTable.get(change.index.stableId);
152
+ if (!table)
153
+ continue;
154
+ if (tablesWithExistingReplicaIdentitySetter.has(table.stableId))
155
+ continue;
156
+ result.push(new AlterTableSetReplicaIdentity({
157
+ table,
158
+ mode: "i",
159
+ indexName: table.replica_identity_index,
160
+ }));
161
+ // Mark as emitted so a second replaced index on the same table — if that
162
+ // ever arises — doesn't double-emit.
163
+ tablesWithExistingReplicaIdentitySetter.add(table.stableId);
164
+ }
165
+ return result;
166
+ }
167
+ /**
168
+ * Apply structural rewrites to the change list that are only obvious once
169
+ * every object diff has been collected. This pass does NOT prevent dependency
170
+ * cycles — that responsibility now lives in the sort phase, where
171
+ * `sortPhaseChanges` invokes `tryBreakCycleByChangeInjection` lazily on cycles
172
+ * that edge filtering can't break (FK SCC of dropped tables,
173
+ * AlterPublicationDropTables ↔ AlterTableDropColumn, …).
174
+ *
175
+ * Concretely, this pass:
176
+ *
177
+ * - Prunes `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)`
178
+ * changes that are made redundant by an expansion-emitted
179
+ * `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
180
+ * would try to drop a column that no longer exists in the freshly
181
+ * recreated table.
182
+ * - Dedupes duplicate `AlterTableAddConstraint` /
183
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
184
+ * produced when `diffTables()` and `expandReplaceDependencies()` both
185
+ * emit the same constraint operation for a replaced table. Last write
186
+ * wins so the expansion's emission survives.
187
+ * - Re-emits `ALTER TABLE ... REPLICA IDENTITY USING INDEX <idx>` after any
188
+ * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica
189
+ * identity index of a branch table — Postgres silently clears the marker
190
+ * when the underlying index is dropped, and `CREATE INDEX` cannot restore
191
+ * it.
192
+ *
193
+ * Object-local PostgreSQL semantics (for example owned-sequence cascades)
194
+ * stay in the corresponding `diff*` function instead of this pass.
195
+ */
196
+ export function normalizePostDiffChanges({ changes, replacedTableIds = new Set(), branchTables = {}, }) {
197
+ const restoredChanges = restoreReplicaIdentityAfterIndexReplace(changes, branchTables);
198
+ const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(restoredChanges, replacedTableIds);
199
+ if (replacedTableIds.size === 0)
200
+ return dedupedChanges;
201
+ return dedupedChanges.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
202
+ }
@@ -0,0 +1,15 @@
1
+ import type { Change } from "../change.types.ts";
2
+ /**
3
+ * Try to break an unbreakable cycle by INJECTING NEW CHANGES or REWRITING
4
+ * existing ones (rather than removing graph edges).
5
+ *
6
+ * Called by `sortPhaseChanges` when its edge-removal cycle handler has seen
7
+ * the same cycle twice — i.e. weak-edge filtering exhausted itself but the
8
+ * cycle is still there. At that point we know the cycle is composed of
9
+ * "hard" edges (explicit `requires` or pg_depend rows) that can only be
10
+ * broken by changing the change list itself.
11
+ *
12
+ * Returns a rewritten `phaseChanges` array, or `null` if no breaker matches
13
+ * (in which case the caller throws the existing CycleError).
14
+ */
15
+ export declare function tryBreakCycleByChangeInjection(cycleNodeIndexes: readonly number[], phaseChanges: readonly Change[]): Change[] | null;
@@ -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-normalization.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
  }