@supabase/pg-delta 1.0.0-alpha.14 → 1.0.0-alpha.15

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.
@@ -0,0 +1,317 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
3
+ import type { Change } from "./change.types.ts";
4
+ import {
5
+ AlterTableChangeOwner,
6
+ AlterTableDropColumn,
7
+ AlterTableDropConstraint,
8
+ AlterTableEnableRowLevelSecurity,
9
+ AlterTableSetReplicaIdentity,
10
+ } from "./objects/table/changes/table.alter.ts";
11
+ import { CreateTable } from "./objects/table/changes/table.create.ts";
12
+ import { DropTable } from "./objects/table/changes/table.drop.ts";
13
+ import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts";
14
+ import { Table } from "./objects/table/table.model.ts";
15
+ import { stableId } from "./objects/utils.ts";
16
+ import { normalizePostDiffCycles } from "./post-diff-cycle-breaking.ts";
17
+
18
+ const baseTableProps = {
19
+ schema: "public",
20
+ persistence: "p" as const,
21
+ row_security: false,
22
+ force_row_security: false,
23
+ has_indexes: false,
24
+ has_rules: false,
25
+ has_triggers: false,
26
+ has_subclasses: false,
27
+ is_populated: true,
28
+ replica_identity: "d" as const,
29
+ is_partition: false,
30
+ options: null,
31
+ partition_bound: null,
32
+ partition_by: null,
33
+ owner: "postgres",
34
+ comment: null,
35
+ parent_schema: null,
36
+ parent_name: null,
37
+ privileges: [],
38
+ };
39
+
40
+ function integerColumn(name: string, position: number) {
41
+ return {
42
+ name,
43
+ position,
44
+ data_type: "integer" as const,
45
+ data_type_str: "integer",
46
+ is_custom_type: false as const,
47
+ custom_type_type: null,
48
+ custom_type_category: null,
49
+ custom_type_schema: null,
50
+ custom_type_name: null,
51
+ not_null: false,
52
+ is_identity: false,
53
+ is_identity_always: false,
54
+ is_generated: false,
55
+ collation: null,
56
+ default: null,
57
+ comment: null,
58
+ };
59
+ }
60
+
61
+ describe("normalizePostDiffCycles", () => {
62
+ test("injects explicit FK drops for mutually dependent dropped tables", async () => {
63
+ const baseline = await createEmptyCatalog(170000, "postgres");
64
+ const tableA = new Table({
65
+ ...baseTableProps,
66
+ name: "a",
67
+ columns: [
68
+ { ...integerColumn("id", 1), not_null: true },
69
+ integerColumn("b_id", 2),
70
+ ],
71
+ constraints: [
72
+ {
73
+ name: "a_b_fkey",
74
+ constraint_type: "f",
75
+ deferrable: false,
76
+ initially_deferred: false,
77
+ validated: true,
78
+ is_local: true,
79
+ no_inherit: false,
80
+ is_partition_clone: false,
81
+ parent_constraint_schema: null,
82
+ parent_constraint_name: null,
83
+ parent_table_schema: null,
84
+ parent_table_name: null,
85
+ key_columns: ["b_id"],
86
+ foreign_key_columns: ["id"],
87
+ foreign_key_table: "b",
88
+ foreign_key_schema: "public",
89
+ foreign_key_table_is_partition: false,
90
+ foreign_key_parent_schema: null,
91
+ foreign_key_parent_table: null,
92
+ foreign_key_effective_schema: "public",
93
+ foreign_key_effective_table: "b",
94
+ on_update: "a",
95
+ on_delete: "a",
96
+ match_type: "s",
97
+ check_expression: null,
98
+ owner: "postgres",
99
+ definition: "FOREIGN KEY (b_id) REFERENCES public.b(id)",
100
+ comment: null,
101
+ },
102
+ ],
103
+ });
104
+ const tableB = new Table({
105
+ ...baseTableProps,
106
+ name: "b",
107
+ columns: [
108
+ { ...integerColumn("id", 1), not_null: true },
109
+ integerColumn("a_id", 2),
110
+ ],
111
+ constraints: [
112
+ {
113
+ name: "b_a_fkey",
114
+ constraint_type: "f",
115
+ deferrable: false,
116
+ initially_deferred: false,
117
+ validated: true,
118
+ is_local: true,
119
+ no_inherit: false,
120
+ is_partition_clone: false,
121
+ parent_constraint_schema: null,
122
+ parent_constraint_name: null,
123
+ parent_table_schema: null,
124
+ parent_table_name: null,
125
+ key_columns: ["a_id"],
126
+ foreign_key_columns: ["id"],
127
+ foreign_key_table: "a",
128
+ foreign_key_schema: "public",
129
+ foreign_key_table_is_partition: false,
130
+ foreign_key_parent_schema: null,
131
+ foreign_key_parent_table: null,
132
+ foreign_key_effective_schema: "public",
133
+ foreign_key_effective_table: "a",
134
+ on_update: "a",
135
+ on_delete: "a",
136
+ match_type: "s",
137
+ check_expression: null,
138
+ owner: "postgres",
139
+ definition: "FOREIGN KEY (a_id) REFERENCES public.a(id)",
140
+ comment: null,
141
+ },
142
+ ],
143
+ });
144
+ const mainCatalog = new Catalog({
145
+ ...baseline,
146
+ tables: {
147
+ [tableA.stableId]: tableA,
148
+ [tableB.stableId]: tableB,
149
+ },
150
+ });
151
+ const changes: Change[] = [
152
+ new DropTable({ table: tableA }),
153
+ new DropTable({ table: tableB }),
154
+ ];
155
+
156
+ const normalized = normalizePostDiffCycles({
157
+ changes,
158
+ mainCatalog,
159
+ });
160
+
161
+ const explicitConstraintDrops = normalized.filter(
162
+ (change) => change instanceof AlterTableDropConstraint,
163
+ );
164
+ expect(explicitConstraintDrops).toHaveLength(2);
165
+
166
+ const normalizedDropTableA = normalized.find(
167
+ (change) =>
168
+ change instanceof DropTable &&
169
+ change.table.stableId === tableA.stableId,
170
+ );
171
+ const normalizedDropTableB = normalized.find(
172
+ (change) =>
173
+ change instanceof DropTable &&
174
+ change.table.stableId === tableB.stableId,
175
+ );
176
+ if (!(normalizedDropTableA instanceof DropTable)) {
177
+ throw new Error("expected normalized DropTable(public.a)");
178
+ }
179
+ if (!(normalizedDropTableB instanceof DropTable)) {
180
+ throw new Error("expected normalized DropTable(public.b)");
181
+ }
182
+
183
+ expect(
184
+ normalizedDropTableA.externallyDroppedConstraints.has("a_b_fkey"),
185
+ ).toBe(true);
186
+ expect(
187
+ normalizedDropTableB.externallyDroppedConstraints.has("b_a_fkey"),
188
+ ).toBe(true);
189
+ expect(
190
+ normalizedDropTableA.requires.includes(
191
+ stableId.constraint("public", "a", "a_b_fkey"),
192
+ ),
193
+ ).toBe(false);
194
+ expect(
195
+ normalizedDropTableB.requires.includes(
196
+ stableId.constraint("public", "b", "b_a_fkey"),
197
+ ),
198
+ ).toBe(false);
199
+ });
200
+
201
+ test("prunes same-table drop-column and drop-constraint ALTERs for replaced tables only", async () => {
202
+ const baseline = await createEmptyCatalog(170000, "postgres");
203
+ const mainChildren = new Table({
204
+ ...baseTableProps,
205
+ name: "children",
206
+ columns: [
207
+ { ...integerColumn("id", 1), not_null: true },
208
+ integerColumn("parent_ref", 2),
209
+ integerColumn("status", 3),
210
+ ],
211
+ });
212
+ const branchChildren = new Table({
213
+ ...baseTableProps,
214
+ name: "children",
215
+ columns: [
216
+ { ...integerColumn("id", 1), not_null: true },
217
+ integerColumn("status", 2),
218
+ ],
219
+ });
220
+
221
+ const droppedColumn = mainChildren.columns.find(
222
+ (column) => column.name === "parent_ref",
223
+ );
224
+ if (!droppedColumn) throw new Error("test setup: parent_ref missing");
225
+
226
+ const preExistingDropColumn = new AlterTableDropColumn({
227
+ table: mainChildren,
228
+ column: droppedColumn,
229
+ });
230
+ const preExistingDropConstraint = new AlterTableDropConstraint({
231
+ table: mainChildren,
232
+ constraint: {
233
+ name: "children_parent_ref_fkey",
234
+ constraint_type: "f",
235
+ deferrable: false,
236
+ initially_deferred: false,
237
+ validated: true,
238
+ is_local: true,
239
+ no_inherit: false,
240
+ is_partition_clone: false,
241
+ parent_constraint_schema: null,
242
+ parent_constraint_name: null,
243
+ parent_table_schema: null,
244
+ parent_table_name: null,
245
+ key_columns: ["parent_ref"],
246
+ foreign_key_columns: ["id"],
247
+ foreign_key_table: "parents",
248
+ foreign_key_schema: "public",
249
+ foreign_key_table_is_partition: false,
250
+ foreign_key_parent_schema: null,
251
+ foreign_key_parent_table: null,
252
+ foreign_key_effective_schema: "public",
253
+ foreign_key_effective_table: "parents",
254
+ on_update: "a",
255
+ on_delete: "a",
256
+ match_type: "s",
257
+ check_expression: null,
258
+ owner: "postgres",
259
+ definition: "FOREIGN KEY (parent_ref) REFERENCES public.parents(id)",
260
+ comment: null,
261
+ },
262
+ });
263
+ const preExistingChangeOwner = new AlterTableChangeOwner({
264
+ table: branchChildren,
265
+ owner: "new_owner",
266
+ });
267
+ const preExistingEnableRls = new AlterTableEnableRowLevelSecurity({
268
+ table: branchChildren,
269
+ });
270
+ const preExistingReplicaIdentity = new AlterTableSetReplicaIdentity({
271
+ table: branchChildren,
272
+ mode: "f",
273
+ });
274
+ const preExistingGrant = new GrantTablePrivileges({
275
+ table: branchChildren,
276
+ grantee: "reader",
277
+ privileges: [{ privilege: "SELECT", grantable: false }],
278
+ });
279
+ const changes: Change[] = [
280
+ new DropTable({ table: mainChildren }),
281
+ new CreateTable({ table: branchChildren }),
282
+ preExistingDropColumn,
283
+ preExistingDropConstraint,
284
+ preExistingChangeOwner,
285
+ preExistingEnableRls,
286
+ preExistingReplicaIdentity,
287
+ preExistingGrant,
288
+ ];
289
+ const mainCatalog = new Catalog({
290
+ ...baseline,
291
+ tables: { [mainChildren.stableId]: mainChildren },
292
+ });
293
+
294
+ const normalized = normalizePostDiffCycles({
295
+ changes,
296
+ mainCatalog,
297
+ replacedTableIds: new Set([mainChildren.stableId]),
298
+ });
299
+
300
+ expect(normalized.some((change) => change instanceof DropTable)).toBe(true);
301
+ expect(normalized.some((change) => change instanceof CreateTable)).toBe(
302
+ true,
303
+ );
304
+ expect(normalized).not.toContain(preExistingDropColumn);
305
+ expect(normalized).not.toContain(preExistingDropConstraint);
306
+ expect(
307
+ normalized.some((change) => change instanceof AlterTableDropColumn),
308
+ ).toBe(false);
309
+ expect(
310
+ normalized.some((change) => change instanceof AlterTableDropConstraint),
311
+ ).toBe(false);
312
+ expect(normalized).toContain(preExistingChangeOwner);
313
+ expect(normalized).toContain(preExistingEnableRls);
314
+ expect(normalized).toContain(preExistingReplicaIdentity);
315
+ expect(normalized).toContain(preExistingGrant);
316
+ });
317
+ });
@@ -0,0 +1,236 @@
1
+ import type { Catalog } from "./catalog.model.ts";
2
+ import type { Change } from "./change.types.ts";
3
+ import {
4
+ AlterTableDropColumn,
5
+ AlterTableDropConstraint,
6
+ } from "./objects/table/changes/table.alter.ts";
7
+ import { DropTable } from "./objects/table/changes/table.drop.ts";
8
+ import { stableId } from "./objects/utils.ts";
9
+
10
+ function constraintStableId(
11
+ table: { schema: string; name: string },
12
+ constraintName: string,
13
+ ) {
14
+ return stableId.constraint(table.schema, table.name, constraintName);
15
+ }
16
+
17
+ /**
18
+ * Yield FK constraints on `table` whose referenced table is also dropped in the
19
+ * final plan. Self-references are left alone because the sort phase already
20
+ * handles the resulting self-loop correctly.
21
+ */
22
+ function* iterCrossDropFkConstraints(
23
+ table: Catalog["tables"][string],
24
+ droppedSet: ReadonlySet<string>,
25
+ ) {
26
+ for (const constraint of table.constraints) {
27
+ if (constraint.constraint_type !== "f") continue;
28
+ if (constraint.is_partition_clone) continue;
29
+ if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
30
+ continue;
31
+ }
32
+ const referencedId = stableId.table(
33
+ constraint.foreign_key_schema,
34
+ constraint.foreign_key_table,
35
+ );
36
+ if (referencedId === table.stableId) continue;
37
+ if (!droppedSet.has(referencedId)) continue;
38
+ yield { constraint, referencedId };
39
+ }
40
+ }
41
+
42
+ function isSupersededByTableReplacement(
43
+ change: Change,
44
+ replacedTableIds: ReadonlySet<string>,
45
+ ): boolean {
46
+ if (
47
+ !(change instanceof AlterTableDropColumn) &&
48
+ !(change instanceof AlterTableDropConstraint)
49
+ ) {
50
+ return false;
51
+ }
52
+ return replacedTableIds.has(change.table.stableId);
53
+ }
54
+
55
+ function collectExplicitConstraintDropIds(changes: Change[]) {
56
+ const explicitConstraintDropIds = new Set<string>();
57
+
58
+ for (const change of changes) {
59
+ if (!(change instanceof AlterTableDropConstraint)) continue;
60
+ explicitConstraintDropIds.add(
61
+ constraintStableId(change.table, change.constraint.name),
62
+ );
63
+ }
64
+
65
+ return explicitConstraintDropIds;
66
+ }
67
+
68
+ function hasSameEntries(
69
+ left: ReadonlySet<string>,
70
+ right: ReadonlySet<string>,
71
+ ): boolean {
72
+ if (left.size !== right.size) return false;
73
+ for (const value of left) {
74
+ if (!right.has(value)) return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Normalize change-list cycles that only become apparent after all object
81
+ * diffs have been collected.
82
+ *
83
+ * This pass intentionally handles whole-plan interactions only:
84
+ * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
85
+ * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
86
+ * redundant and create an unbreakable drop-phase cycle, so we elide them.
87
+ * - If two dropped tables reference each other via FK, we insert dedicated
88
+ * `AlterTableDropConstraint` changes and teach the paired `DropTable`
89
+ * changes not to claim those FK stable IDs.
90
+ *
91
+ * Object-local PostgreSQL semantics (for example owned-sequence cascades) stay
92
+ * in the corresponding `diff*` function instead of this pass.
93
+ */
94
+ export function normalizePostDiffCycles({
95
+ changes,
96
+ mainCatalog,
97
+ replacedTableIds = new Set<string>(),
98
+ }: {
99
+ changes: Change[];
100
+ mainCatalog: Catalog;
101
+ replacedTableIds?: ReadonlySet<string>;
102
+ }): Change[] {
103
+ const structurallyNormalizedChanges =
104
+ replacedTableIds.size === 0
105
+ ? changes
106
+ : changes.filter(
107
+ (change) => !isSupersededByTableReplacement(change, replacedTableIds),
108
+ );
109
+
110
+ const dropTableChanges = structurallyNormalizedChanges.filter(
111
+ (change): change is DropTable => change instanceof DropTable,
112
+ );
113
+
114
+ if (dropTableChanges.length < 2) {
115
+ return structurallyNormalizedChanges;
116
+ }
117
+
118
+ const droppedSet = new Set(
119
+ dropTableChanges.map((change) => change.table.stableId),
120
+ );
121
+ const droppedFkTargets = new Map<string, Set<string>>();
122
+
123
+ for (const dropTableChange of dropTableChanges) {
124
+ const mainTable =
125
+ mainCatalog.tables[dropTableChange.table.stableId] ??
126
+ dropTableChange.table;
127
+ const targets = new Set<string>();
128
+
129
+ for (const { referencedId } of iterCrossDropFkConstraints(
130
+ mainTable,
131
+ droppedSet,
132
+ )) {
133
+ targets.add(referencedId);
134
+ }
135
+
136
+ droppedFkTargets.set(mainTable.stableId, targets);
137
+ }
138
+
139
+ const explicitConstraintDropIds = collectExplicitConstraintDropIds(
140
+ structurallyNormalizedChanges,
141
+ );
142
+ const injectedConstraintDropsByTableId = new Map<
143
+ string,
144
+ AlterTableDropConstraint[]
145
+ >();
146
+ const externallyDroppedConstraintsByTableId = new Map<
147
+ string,
148
+ ReadonlySet<string>
149
+ >();
150
+ let didMutate = structurallyNormalizedChanges !== changes;
151
+
152
+ for (const dropTableChange of dropTableChanges) {
153
+ const mainTable =
154
+ mainCatalog.tables[dropTableChange.table.stableId] ??
155
+ dropTableChange.table;
156
+ const externallyDroppedConstraints = new Set(
157
+ dropTableChange.externallyDroppedConstraints,
158
+ );
159
+
160
+ for (const { constraint, referencedId } of iterCrossDropFkConstraints(
161
+ mainTable,
162
+ droppedSet,
163
+ )) {
164
+ const isMutual =
165
+ droppedFkTargets.get(referencedId)?.has(mainTable.stableId) === true;
166
+ if (!isMutual) continue;
167
+
168
+ const droppedConstraintStableId = constraintStableId(
169
+ mainTable,
170
+ constraint.name,
171
+ );
172
+ externallyDroppedConstraints.add(constraint.name);
173
+
174
+ if (!explicitConstraintDropIds.has(droppedConstraintStableId)) {
175
+ const injectedDrop = new AlterTableDropConstraint({
176
+ table: mainTable,
177
+ constraint,
178
+ });
179
+ const existingDrops =
180
+ injectedConstraintDropsByTableId.get(mainTable.stableId) ?? [];
181
+ existingDrops.push(injectedDrop);
182
+ injectedConstraintDropsByTableId.set(mainTable.stableId, existingDrops);
183
+ explicitConstraintDropIds.add(droppedConstraintStableId);
184
+ didMutate = true;
185
+ }
186
+ }
187
+
188
+ if (
189
+ !hasSameEntries(
190
+ dropTableChange.externallyDroppedConstraints,
191
+ externallyDroppedConstraints,
192
+ )
193
+ ) {
194
+ externallyDroppedConstraintsByTableId.set(
195
+ mainTable.stableId,
196
+ externallyDroppedConstraints,
197
+ );
198
+ didMutate = true;
199
+ }
200
+ }
201
+
202
+ if (!didMutate) {
203
+ return changes;
204
+ }
205
+
206
+ const normalizedChanges: Change[] = [];
207
+
208
+ for (const change of structurallyNormalizedChanges) {
209
+ if (!(change instanceof DropTable)) {
210
+ normalizedChanges.push(change);
211
+ continue;
212
+ }
213
+
214
+ const injectedConstraintDrops =
215
+ injectedConstraintDropsByTableId.get(change.table.stableId) ?? [];
216
+ if (injectedConstraintDrops.length > 0) {
217
+ normalizedChanges.push(...injectedConstraintDrops);
218
+ }
219
+
220
+ const externallyDroppedConstraints =
221
+ externallyDroppedConstraintsByTableId.get(change.table.stableId);
222
+ if (!externallyDroppedConstraints) {
223
+ normalizedChanges.push(change);
224
+ continue;
225
+ }
226
+
227
+ normalizedChanges.push(
228
+ new DropTable({
229
+ table: change.table,
230
+ externallyDroppedConstraints,
231
+ }),
232
+ );
233
+ }
234
+
235
+ return normalizedChanges;
236
+ }