Skip to content

Commit 471f770

Browse files
authored
fix(pg-delta): enhance cycle-breaking logic (#248)
1 parent beb72cf commit 471f770

10 files changed

Lines changed: 1146 additions & 33 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@supabase/pg-delta": patch
3+
---
4+
5+
Fix drop-phase cycle breaking when publication table membership removal intersects with dropped foreign-key chains and a referenced constraint drop.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@supabase/pg-delta": patch
3+
---
4+
5+
Fix `DropSequence ↔ DropTable` drop-phase cycle when an owning table is
6+
promoted to `DropTable + CreateTable` by `expandReplaceDependencies` (for
7+
example when a referenced enum has a label removed) and the same plan also
8+
drops the SERIAL sequence because branch no longer carries the owned sequence.
9+
10+
`diffSequences.dropped` short-circuits `DropSequence` only when the owning
11+
table itself is absent from the branch catalog. When the table survives in
12+
branch but is later replaced via expansion (table is in `replacedTableIds`),
13+
the explicit `DROP SEQUENCE` survives into the drop phase alongside the
14+
expander's `DropTable`, and the bidirectional pg_depend edges between the
15+
sequence and its owning column close an unbreakable 2-cycle that none of the
16+
existing dependency-filter / change-injection breakers match.
17+
18+
`normalizePostDiffChanges` now prunes `DropSequence(S)` whenever S is `OWNED
19+
BY` a column on a table in `replacedTableIds`. The `DROP TABLE` cascade
20+
already drops the OWNED BY sequence at apply time, so the explicit
21+
`DROP SEQUENCE` was both redundant and the source of the cycle.

CONTEXT.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# pg-toolbelt
2+
3+
PostgreSQL tooling for comparing schemas, planning migrations, and ordering DDL safely. This context captures the project language used when discussing pg-delta migration planning and dependency-cycle handling.
4+
5+
## Language
6+
7+
**Migration plan**:
8+
An ordered set of DDL changes that transforms a source database schema into a target database schema.
9+
_Avoid_: Script, diff output
10+
11+
**Change**:
12+
A single schema operation emitted by a diff, carrying the stable identifiers it creates, drops, or requires.
13+
_Avoid_: Statement, when referring to the typed operation before serialization
14+
15+
**Stable identifier**:
16+
An environment-independent name for a schema object used to connect changes to catalog dependencies.
17+
_Avoid_: OID
18+
19+
**Dependency cycle**:
20+
A cycle in the migration-plan dependency graph that prevents topological ordering.
21+
_Avoid_: Circular diff
22+
23+
**Structural normalization**:
24+
A deterministic rewrite of the final change list before dependency sorting.
25+
_Avoid_: Cycle breaker
26+
27+
**Cycle-breaking change injection**:
28+
A sort-phase rewrite that injects or rebuilds changes after an unbreakable dependency cycle is detected.
29+
_Avoid_: Post-diff normalization, when the fix is specific to a detected graph cycle
30+
31+
**Publication FK-chain constraint-drop cycle**:
32+
A dependency cycle where publication membership is being removed for dropped tables, those dropped tables carry a foreign-key chain, and the chain ends at a separately dropped referenced constraint.
33+
_Avoid_: Publication drop cycle, dropped-table publication membership cycle
34+
35+
**FK constraint-drop injection**:
36+
Cycle-breaking change injection that creates explicit foreign-key constraint drops and makes table drops stop claiming those constraint stable identifiers.
37+
_Avoid_: Relaxed publication requirement, when resolving dropped-table publication membership cycles
38+
39+
## Relationships
40+
41+
- A **Migration plan** contains one or more **Changes**.
42+
- A **Change** names the **Stable identifiers** it creates, drops, or requires.
43+
- **Structural normalization** happens before dependency sorting and does not inspect a specific cycle path.
44+
- **Cycle-breaking change injection** happens during dependency sorting and responds to a concrete **Dependency cycle**.
45+
- A **Publication FK-chain constraint-drop cycle** is resolved by **Cycle-breaking change injection**, not by structural normalization.
46+
- A **Publication FK-chain constraint-drop cycle** is resolved with **FK constraint-drop injection** while leaving publication membership and referenced-constraint drop changes unchanged.
47+
- In a **Publication FK-chain constraint-drop cycle**, the terminal referenced-constraint drop table must be part of the publication membership being removed.
48+
- **FK constraint-drop injection** for a **Publication FK-chain constraint-drop cycle** is cycle-local: inject only FK drops that point to a dropped table in the cycle or to the terminal referenced constraint being dropped.
49+
- **FK constraint-drop injection** can be shared by multiple cycle breakers; each breaker still owns its own matcher and safety checks.
50+
51+
## Example dialogue
52+
53+
> **Dev:** "Should this publication/table drop issue be handled by structural normalization?"
54+
> **Domain expert:** "No. The final change list is valid; the problem appears only after dependency sorting detects the specific cycle, so it belongs in cycle-breaking change injection."
55+
56+
## Flagged ambiguities
57+
58+
- "Whole-plan interaction" was used for both **Structural normalization** and **Cycle-breaking change injection**. Resolved: deterministic rewrites of the final change list are structural normalization; rewrites triggered by a concrete unbreakable dependency cycle are cycle-breaking change injection.
59+
- `AlterTableDropConstraint` was first described as optional in the publication/table drop cycle. Resolved: the observed production cycle is a **Publication FK-chain constraint-drop cycle**, so a separately emitted referenced-constraint drop is part of that specific matcher.
60+
- Rebuilding `AlterPublicationDropTables` with relaxed requirements was considered for **Publication FK-chain constraint-drop cycles**. Resolved: keep publication membership changes unchanged and break the foreign-key chain with **FK constraint-drop injection**.

packages/pg-delta/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ for (const pgVersion of POSTGRES_VERSIONS) {
8888
Keep cycle handling split by the scope of information it needs:
8989

9090
- **Object-local PostgreSQL semantics stay in `diff*`**. If a single object diff can prove a statement is redundant or invalid on its own, fix it there. Example: `src/core/objects/sequence/sequence.diff.ts` skips `DROP SEQUENCE` when `OWNED BY` means PostgreSQL will already cascade-drop it with the owning table/column.
91-
- **Whole-plan interactions belong in post-diff normalization**. If the fix only becomes obvious after multiple emitted changes are combined, implement it in `src/core/post-diff-normalization.ts` (`normalizePostDiffChanges`), wired from `src/core/catalog.diff.ts` after raw diffs and `expandReplaceDependencies()`. The pass is the single chokepoint that observes the final `Change[]`, so it catches cross-object effects regardless of whether the relevant change pair was emitted by an object's `diff*` (e.g. `index.diff` for a definition-changed index) or by `expandReplaceDependencies()` (dependency-closure replacement). Current examples: pruning same-table `AlterTableDropColumn` / `AlterTableDropConstraint` changes superseded by an expansion-added `DropTable+CreateTable` pair, deduplicating constraint Add/Validate/Comment on replaced tables, and re-emitting `ALTER TABLE … REPLICA IDENTITY USING INDEX` after a replica-identity index is dropped+recreated.
91+
- **Deterministic whole-plan rewrites belong in post-diff normalization**. If the final `Change[]` itself should be rewritten before dependency sorting, implement it in `src/core/post-diff-normalization.ts` (`normalizePostDiffChanges`), wired from `src/core/catalog.diff.ts` after raw diffs and `expandReplaceDependencies()`. The pass is the single chokepoint that observes the final `Change[]`, so it catches cross-object effects regardless of whether the relevant change pair was emitted by an object's `diff*` (e.g. `index.diff` for a definition-changed index) or by `expandReplaceDependencies()` (dependency-closure replacement). Current examples: pruning same-table `AlterTableDropColumn` / `AlterTableDropConstraint` changes superseded by an expansion-added `DropTable+CreateTable` pair, deduplicating constraint Add/Validate/Comment on replaced tables, and re-emitting `ALTER TABLE … REPLICA IDENTITY USING INDEX` after a replica-identity index is dropped+recreated.
92+
- **Unbreakable graph cycles belong in sort-phase change injection**. If the emitted statements are valid but topological sorting discovers a hard dependency cycle that cannot be solved by weak-edge filtering, implement the narrow pattern in `src/core/sort/cycle-breakers.ts` (`tryBreakCycleByChangeInjection`). Existing examples: injecting explicit FK constraint drops for dropped-table FK cycles and rebuilding `AlterTableDropColumn` for publication-column cycles on surviving tables.
9293
- **`expandReplaceDependencies()` only computes replacement closure**. It may report metadata such as which tables were promoted to replacement pairs, but it should not own unrelated cycle-pruning policy.
9394
- **`src/core/sort/dependency-filter.ts` is a narrow last resort**. Use it only for safe edge filtering where the emitted statements are already valid and only the graph edge is artificial. Do not extend sort-phase filtering to paper over plans that would still fail at apply time.
9495

95-
Rule of thumb: if the fix needs the full final `Change[]`, it is post-diff; if it needs only one object's semantics, it belongs in that object's `diff*`; if it only removes a graph edge without changing emitted SQL, it belongs in the sort filter.
96+
Rule of thumb: if the fix changes a valid final `Change[]` before graph construction, it is post-diff; if it reacts to a concrete unbreakable dependency cycle and needs to inject or rebuild changes, it belongs in the sort-phase cycle breakers; if it needs only one object's semantics, it belongs in that object's `diff*`; if it only removes a graph edge without changing emitted SQL, it belongs in the sort filter.
9697

9798
## Key Concepts
9899

packages/pg-delta/src/core/post-diff-normalization.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import type { Change } from "./change.types.ts";
33
import { CreateIndex } from "./objects/index/changes/index.create.ts";
44
import { DropIndex } from "./objects/index/changes/index.drop.ts";
55
import { Index, type IndexProps } from "./objects/index/index.model.ts";
6+
import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
7+
import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
8+
import {
9+
Sequence,
10+
type SequenceProps,
11+
} from "./objects/sequence/sequence.model.ts";
612
import {
713
AlterTableAddConstraint,
814
AlterTableChangeOwner,
@@ -304,6 +310,123 @@ describe("normalizePostDiffChanges", () => {
304310
).toHaveLength(1);
305311
});
306312

313+
describe("DropSequence pruning on replaced tables", () => {
314+
const baseSequenceProps: SequenceProps = {
315+
schema: "public",
316+
name: "project_link_type_id_seq",
317+
data_type: "integer",
318+
start_value: 1,
319+
minimum_value: 1n,
320+
maximum_value: 2147483647n,
321+
increment: 1,
322+
cycle_option: false,
323+
cache_size: 1,
324+
persistence: "p",
325+
owned_by_schema: "public",
326+
owned_by_table: "project_link_type",
327+
owned_by_column: "id",
328+
comment: null,
329+
privileges: [],
330+
owner: "postgres",
331+
};
332+
333+
test("prunes DropSequence when its OWNED BY table is in replacedTableIds", () => {
334+
const replacedTable = new Table({
335+
...baseTableProps,
336+
name: "project_link_type",
337+
columns: [{ ...integerColumn("id", 1), not_null: true }],
338+
});
339+
const ownedSequence = new Sequence(baseSequenceProps);
340+
341+
const dropSequence = new DropSequence({ sequence: ownedSequence });
342+
const dropTable = new DropTable({ table: replacedTable });
343+
const createTable = new CreateTable({ table: replacedTable });
344+
345+
const changes: Change[] = [dropSequence, dropTable, createTable];
346+
347+
const normalized = normalizePostDiffChanges({
348+
changes,
349+
replacedTableIds: new Set([replacedTable.stableId]),
350+
});
351+
352+
expect(normalized.some((change) => change instanceof DropSequence)).toBe(
353+
false,
354+
);
355+
expect(normalized).toContain(dropTable);
356+
expect(normalized).toContain(createTable);
357+
});
358+
359+
test("keeps DropSequence whose OWNED BY table is not in replacedTableIds", () => {
360+
const survivingTable = new Table({
361+
...baseTableProps,
362+
name: "project_link_type",
363+
columns: [{ ...integerColumn("id", 1), not_null: true }],
364+
});
365+
const ownedSequence = new Sequence(baseSequenceProps);
366+
367+
const dropSequence = new DropSequence({ sequence: ownedSequence });
368+
369+
const normalized = normalizePostDiffChanges({
370+
changes: [dropSequence],
371+
// Different table is being replaced; the sequence's OWNED BY does
372+
// not match, so DropSequence must survive.
373+
replacedTableIds: new Set([
374+
`table:${survivingTable.schema}.unrelated_table` as const,
375+
]),
376+
});
377+
378+
expect(normalized).toContain(dropSequence);
379+
});
380+
381+
test("keeps DropSequence with no OWNED BY when replacedTableIds is non-empty", () => {
382+
const orphanSequence = new Sequence({
383+
...baseSequenceProps,
384+
owned_by_schema: null,
385+
owned_by_table: null,
386+
owned_by_column: null,
387+
});
388+
389+
const dropSequence = new DropSequence({ sequence: orphanSequence });
390+
391+
const normalized = normalizePostDiffChanges({
392+
changes: [dropSequence],
393+
replacedTableIds: new Set(["table:public.project_link_type" as const]),
394+
});
395+
396+
expect(normalized).toContain(dropSequence);
397+
});
398+
399+
test("keeps unrelated CreateSequence and DropSequence even when its non-owning table is replaced", () => {
400+
const sequenceA = new Sequence(baseSequenceProps);
401+
const sequenceB = new Sequence({
402+
...baseSequenceProps,
403+
name: "unrelated_seq",
404+
owned_by_schema: null,
405+
owned_by_table: null,
406+
owned_by_column: null,
407+
});
408+
409+
const dropOwned = new DropSequence({ sequence: sequenceA });
410+
const createUnrelated = new CreateSequence({ sequence: sequenceB });
411+
412+
const replacedTable = new Table({
413+
...baseTableProps,
414+
name: "project_link_type",
415+
columns: [{ ...integerColumn("id", 1), not_null: true }],
416+
});
417+
418+
const normalized = normalizePostDiffChanges({
419+
changes: [dropOwned, createUnrelated],
420+
replacedTableIds: new Set([replacedTable.stableId]),
421+
});
422+
423+
expect(normalized.some((change) => change instanceof DropSequence)).toBe(
424+
false,
425+
);
426+
expect(normalized).toContain(createUnrelated);
427+
});
428+
});
429+
307430
describe("restoreReplicaIdentityAfterIndexReplace", () => {
308431
const baseIndexProps: IndexProps = {
309432
schema: "public",

packages/pg-delta/src/core/post-diff-normalization.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Change } from "./change.types.ts";
22
import { CreateIndex } from "./objects/index/changes/index.create.ts";
33
import { DropIndex } from "./objects/index/changes/index.drop.ts";
4+
import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
45
import {
56
AlterTableAddConstraint,
67
AlterTableDropColumn,
@@ -24,12 +25,40 @@ function isSupersededByTableReplacement(
2425
replacedTableIds: ReadonlySet<string>,
2526
): boolean {
2627
if (
27-
!(change instanceof AlterTableDropColumn) &&
28-
!(change instanceof AlterTableDropConstraint)
28+
change instanceof AlterTableDropColumn ||
29+
change instanceof AlterTableDropConstraint
2930
) {
30-
return false;
31+
return replacedTableIds.has(change.table.stableId);
3132
}
32-
return replacedTableIds.has(change.table.stableId);
33+
34+
// `DropSequence(S)` is superseded when S is OWNED BY a column on a table
35+
// that `expandReplaceDependencies` has promoted to `DropTable + CreateTable`
36+
// in the same plan. PostgreSQL cascade-drops the OWNED BY sequence as part
37+
// of the DROP TABLE, so the explicit DROP SEQUENCE is redundant and — more
38+
// importantly — closes an unbreakable `DropSequence ↔ DropTable` cycle in
39+
// the drop phase via the bidirectional pg_depend edges between the
40+
// sequence and its owning column (`column → sequence` for the DEFAULT
41+
// nextval reference, `sequence → column` for the OWNED BY auto-dependency).
42+
// The alpha.15 short-circuit in `diffSequences.dropped` only suppresses
43+
// `DropSequence` when the owning table itself is gone from `branchTables`;
44+
// here the table survives in branch and the replacement is added later by
45+
// the expander, so this whole-plan rewrite has to happen post-diff.
46+
if (change instanceof DropSequence) {
47+
if (
48+
!change.sequence.owned_by_schema ||
49+
!change.sequence.owned_by_table ||
50+
!change.sequence.owned_by_column
51+
) {
52+
return false;
53+
}
54+
const ownedByTableId = stableId.table(
55+
change.sequence.owned_by_schema,
56+
change.sequence.owned_by_table,
57+
);
58+
return replacedTableIds.has(ownedByTableId);
59+
}
60+
61+
return false;
3362
}
3463

3564
/**
@@ -219,6 +248,13 @@ function restoreReplicaIdentityAfterIndexReplace(
219248
* `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
220249
* would try to drop a column that no longer exists in the freshly
221250
* recreated table.
251+
* - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a
252+
* table promoted to `DropTable + CreateTable` by the expander. The
253+
* `DROP TABLE` cascade drops the sequence at apply time; emitting an
254+
* explicit `DROP SEQUENCE` in the same drop phase both duplicates the
255+
* cascade and forms an unbreakable `DropSequence ↔ DropTable` cycle on
256+
* the bidirectional pg_depend edges between the sequence and the
257+
* owning column.
222258
* - Dedupes duplicate `AlterTableAddConstraint` /
223259
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
224260
* produced when `diffTables()` and `expandReplaceDependencies()` both

0 commit comments

Comments
 (0)