@supabase/pg-delta 1.0.0-alpha.17 → 1.0.0-alpha.18

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.
@@ -32,6 +32,25 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
32
32
  replaceRoots.add(id);
33
33
  }
34
34
  }
35
+ // Procedure stableIds are signature-qualified
36
+ // (`procedure:schema.name(argtypes)`), so a function whose parameter types
37
+ // change has different ids in `createdIds` and `droppedIds` and would not
38
+ // appear in the intersection above. Treat any dropped procedure whose
39
+ // `(schema, name)` matches a created procedure as a replace root so
40
+ // dependents referencing the old signature via pg_depend get promoted to
41
+ // DROP+CREATE.
42
+ const createdProcedureNames = new Set();
43
+ for (const id of createdIds) {
44
+ const key = parseProcedureSchemaName(id);
45
+ if (key)
46
+ createdProcedureNames.add(key);
47
+ }
48
+ for (const id of droppedIds) {
49
+ const key = parseProcedureSchemaName(id);
50
+ if (key && createdProcedureNames.has(key)) {
51
+ replaceRoots.add(id);
52
+ }
53
+ }
35
54
  if (replaceRoots.size === 0) {
36
55
  return {
37
56
  changes,
@@ -149,6 +168,14 @@ function isOwnedSequenceColumnDependency(referencedId, dependentId, mainCatalog,
149
168
  return (dependentId ===
150
169
  stableId.column(sequence.owned_by_schema, sequence.owned_by_table, sequence.owned_by_column));
151
170
  }
171
+ function parseProcedureSchemaName(stableId) {
172
+ if (!stableId.startsWith("procedure:"))
173
+ return null;
174
+ const paren = stableId.indexOf("(");
175
+ if (paren === -1)
176
+ return null;
177
+ return stableId.slice("procedure:".length, paren);
178
+ }
152
179
  function normalizeDependentId(dependentId) {
153
180
  let id = dependentId;
154
181
  while (id.startsWith("comment:")) {
@@ -17,8 +17,7 @@ import { GrantProcedurePrivileges, RevokeGrantOptionProcedurePrivileges, RevokeP
17
17
  export function diffProcedures(ctx, main, branch) {
18
18
  const { created, dropped, altered } = diffObjects(main, branch);
19
19
  const changes = [];
20
- for (const procedureId of created) {
21
- const proc = branch[procedureId];
20
+ const appendCreateProcedureChanges = (proc) => {
22
21
  changes.push(new CreateProcedure({ procedure: proc }));
23
22
  // OWNER: If the procedure should be owned by someone other than the current user,
24
23
  // emit ALTER FUNCTION/PROCEDURE ... OWNER TO after creation
@@ -53,6 +52,9 @@ export function diffProcedures(ctx, main, branch) {
53
52
  Revoke: RevokeProcedurePrivileges,
54
53
  RevokeGrantOption: RevokeGrantOptionProcedurePrivileges,
55
54
  }, ctx.version));
55
+ };
56
+ for (const procedureId of created) {
57
+ appendCreateProcedureChanges(branch[procedureId]);
56
58
  }
57
59
  for (const procedureId of dropped) {
58
60
  changes.push(new DropProcedure({ procedure: main[procedureId] }));
@@ -60,22 +62,18 @@ export function diffProcedures(ctx, main, branch) {
60
62
  for (const procedureId of altered) {
61
63
  const mainProcedure = main[procedureId];
62
64
  const branchProcedure = branch[procedureId];
63
- // Check if non-alterable properties have changed
64
- // These require dropping and recreating the procedure
65
- const NON_ALTERABLE_FIELDS = [
65
+ // Fields that are part of the function's identity/signature. PostgreSQL
66
+ // rejects `CREATE OR REPLACE FUNCTION` for any of these changes with
67
+ // errors such as:
68
+ // - cannot change return type of existing function
69
+ // - cannot change name of input parameter "..."
70
+ // - cannot change whether a procedure has output parameters
71
+ // - cannot remove parameter defaults from existing function
72
+ // These require `DROP FUNCTION` followed by `CREATE FUNCTION`.
73
+ const SIGNATURE_BREAKING_FIELDS = [
66
74
  "kind",
67
75
  "return_type",
68
76
  "return_type_schema",
69
- "language",
70
- // The following properties are alterable in SQL, but our generator may choose
71
- // to replace on changes not covered by explicit ALTER actions. Keep them out here
72
- // to allow ALTER for those we implement below.
73
- // security_definer,
74
- // volatility,
75
- // parallel_safety,
76
- // is_strict,
77
- // leakproof,
78
- // Returns-set is part of the signature and not alterable
79
77
  "returns_set",
80
78
  "argument_count",
81
79
  "argument_default_count",
@@ -84,20 +82,35 @@ export function diffProcedures(ctx, main, branch) {
84
82
  "all_argument_types",
85
83
  "argument_modes",
86
84
  "argument_defaults",
85
+ ];
86
+ // Fields where `CREATE OR REPLACE` is sufficient - body replacement only.
87
+ // Other fields (security_definer, volatility, parallel_safety, is_strict,
88
+ // leakproof, config) are alterable via dedicated ALTER actions below.
89
+ const OR_REPLACEABLE_NON_ALTERABLE_FIELDS = [
90
+ "language",
87
91
  "source_code",
88
92
  "binary_path",
89
93
  "sql_body",
90
- // config is alterable via SET/RESET
91
94
  ];
92
- const nonAlterablePropsChanged = hasNonAlterableChanges(mainProcedure, branchProcedure, NON_ALTERABLE_FIELDS, {
95
+ const signatureChanged = hasNonAlterableChanges(mainProcedure, branchProcedure, SIGNATURE_BREAKING_FIELDS, {
93
96
  argument_names: deepEqual,
94
97
  argument_types: deepEqual,
95
98
  all_argument_types: deepEqual,
96
99
  argument_modes: deepEqual,
97
- config: deepEqual,
98
100
  });
99
- if (nonAlterablePropsChanged) {
100
- // Replace the entire procedure
101
+ const nonAlterablePropsChanged = signatureChanged ||
102
+ hasNonAlterableChanges(mainProcedure, branchProcedure, OR_REPLACEABLE_NON_ALTERABLE_FIELDS);
103
+ if (signatureChanged) {
104
+ // PostgreSQL cannot change an existing function's signature via
105
+ // `CREATE OR REPLACE`. Drop the old signature, then recreate.
106
+ // `expandReplaceDependencies` will cascade the replacement to dependent
107
+ // objects (views, triggers, column defaults) via pg_depend edges.
108
+ changes.push(new DropProcedure({ procedure: mainProcedure }));
109
+ appendCreateProcedureChanges(branchProcedure);
110
+ }
111
+ else if (nonAlterablePropsChanged) {
112
+ // Body-only non-alterable change - `CREATE OR REPLACE` preserves the
113
+ // function OID and keeps dependent objects attached.
101
114
  changes.push(new CreateProcedure({ procedure: branchProcedure, orReplace: true }));
102
115
  if (mainProcedure.comment !== branchProcedure.comment) {
103
116
  if (branchProcedure.comment === null) {
@@ -66,6 +66,7 @@ function createAlterConstraintChange(mainTable, branchTable) {
66
66
  mainC.validated !== branchC.validated ||
67
67
  mainC.is_local !== branchC.is_local ||
68
68
  mainC.no_inherit !== branchC.no_inherit ||
69
+ mainC.is_temporal !== branchC.is_temporal ||
69
70
  JSON.stringify(mainC.key_columns) !==
70
71
  JSON.stringify(branchC.key_columns) ||
71
72
  JSON.stringify(mainC.foreign_key_columns) !==
@@ -23,6 +23,7 @@ declare const tableConstraintPropsSchema: z.ZodObject<{
23
23
  validated: z.ZodBoolean;
24
24
  is_local: z.ZodBoolean;
25
25
  no_inherit: z.ZodBoolean;
26
+ is_temporal: z.ZodBoolean;
26
27
  is_partition_clone: z.ZodBoolean;
27
28
  parent_constraint_schema: z.ZodNullable<z.ZodString>;
28
29
  parent_constraint_name: z.ZodNullable<z.ZodString>;
@@ -125,6 +126,7 @@ declare const tablePropsSchema: z.ZodObject<{
125
126
  validated: z.ZodBoolean;
126
127
  is_local: z.ZodBoolean;
127
128
  no_inherit: z.ZodBoolean;
129
+ is_temporal: z.ZodBoolean;
128
130
  is_partition_clone: z.ZodBoolean;
129
131
  parent_constraint_schema: z.ZodNullable<z.ZodString>;
130
132
  parent_constraint_name: z.ZodNullable<z.ZodString>;
@@ -239,6 +241,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
239
241
  validated: boolean;
240
242
  is_local: boolean;
241
243
  no_inherit: boolean;
244
+ is_temporal: boolean;
242
245
  is_partition_clone: boolean;
243
246
  parent_constraint_schema: string | null;
244
247
  parent_constraint_name: string | null;
@@ -300,6 +303,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
300
303
  validated: boolean;
301
304
  is_local: boolean;
302
305
  no_inherit: boolean;
306
+ is_temporal: boolean;
303
307
  is_partition_clone: boolean;
304
308
  parent_constraint_schema: string | null;
305
309
  parent_constraint_name: string | null;
@@ -42,6 +42,7 @@ const tableConstraintPropsSchema = z.object({
42
42
  validated: z.boolean(),
43
43
  is_local: z.boolean(),
44
44
  no_inherit: z.boolean(),
45
+ is_temporal: z.boolean(),
45
46
  is_partition_clone: z.boolean(),
46
47
  parent_constraint_schema: z.string().nullable(),
47
48
  parent_constraint_name: z.string().nullable(),
@@ -253,6 +254,7 @@ select
253
254
  'validated', c.convalidated,
254
255
  'is_local', c.conislocal,
255
256
  'no_inherit', c.connoinherit,
257
+ 'is_temporal', coalesce((to_jsonb(c)->>'conperiod')::boolean, false),
256
258
 
257
259
  -- NEW: propagated-to-partition tagging (PG15+)
258
260
  'is_partition_clone', (c.conparentid <> 0::oid),
@@ -294,6 +294,7 @@ const pkConstraint = {
294
294
  validated: true,
295
295
  is_local: true,
296
296
  no_inherit: false,
297
+ is_temporal: false,
297
298
  is_partition_clone: false,
298
299
  parent_constraint_schema: null,
299
300
  parent_constraint_name: null,
@@ -324,6 +325,7 @@ const uniqueConstraint = {
324
325
  validated: true,
325
326
  is_local: true,
326
327
  no_inherit: false,
328
+ is_temporal: false,
327
329
  is_partition_clone: false,
328
330
  parent_constraint_schema: null,
329
331
  parent_constraint_name: null,
@@ -353,6 +355,7 @@ const fkConstraint = {
353
355
  validated: true,
354
356
  is_local: true,
355
357
  no_inherit: false,
358
+ is_temporal: false,
356
359
  is_partition_clone: false,
357
360
  parent_constraint_schema: null,
358
361
  parent_constraint_name: null,
@@ -382,6 +385,7 @@ const checkConstraint = {
382
385
  validated: true,
383
386
  is_local: true,
384
387
  no_inherit: true,
388
+ is_temporal: false,
385
389
  is_partition_clone: false,
386
390
  parent_constraint_schema: null,
387
391
  parent_constraint_name: null,
@@ -8,6 +8,13 @@ import type { Change } from "./change.types.ts";
8
8
  * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
9
9
  * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
10
10
  * redundant and create an unbreakable drop-phase cycle, so we elide them.
11
+ * - When the same `DropTable+CreateTable` pair is present, the expansion
12
+ * also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
13
+ * / `CreateCommentOnConstraint` per branch constraint, which may collide
14
+ * with the same change already emitted by `diffTables()` (for example on a
15
+ * shape flip or a new constraint). We dedupe these keeping only the last
16
+ * occurrence so the expansion's emission survives and the diffTables
17
+ * duplicate is removed.
11
18
  * - If two dropped tables reference each other via FK, we insert dedicated
12
19
  * `AlterTableDropConstraint` changes and teach the paired `DropTable`
13
20
  * changes not to claim those FK stable IDs.
@@ -1,4 +1,5 @@
1
- import { AlterTableDropColumn, AlterTableDropConstraint, } from "./objects/table/changes/table.alter.js";
1
+ import { AlterTableAddConstraint, AlterTableDropColumn, AlterTableDropConstraint, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.js";
2
+ import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
2
3
  import { DropTable } from "./objects/table/changes/table.drop.js";
3
4
  import { stableId } from "./objects/utils.js";
4
5
  function constraintStableId(table, constraintName) {
@@ -33,6 +34,63 @@ function isSupersededByTableReplacement(change, replacedTableIds) {
33
34
  }
34
35
  return replacedTableIds.has(change.table.stableId);
35
36
  }
37
+ /**
38
+ * Drop earlier duplicates of `AlterTableAddConstraint` /
39
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
40
+ * replaced tables, keeping only the last occurrence of each
41
+ * `(changeType, table.stableId, constraint.name)`.
42
+ *
43
+ * When `expandReplaceDependencies()` promotes a table to a full
44
+ * `DropTable + CreateTable` pair, it also emits one
45
+ * `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
46
+ * `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
47
+ * emitted the same change for a shape flip or a new constraint on that
48
+ * table, the plan ends up with two identical `ALTER TABLE ... ADD
49
+ * CONSTRAINT ...` statements and PostgreSQL fails at apply time with
50
+ * `constraint "..." for relation "..." already exists`. Because
51
+ * `expandReplaceDependencies()` appends its additions after the original
52
+ * `diffTables()` output, the last occurrence is the expansion's emission —
53
+ * keeping it preserves correctness while removing the duplicate.
54
+ */
55
+ function dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds) {
56
+ if (replacedTableIds.size === 0)
57
+ return changes;
58
+ const keyFor = (change) => {
59
+ if (!(change instanceof AlterTableAddConstraint) &&
60
+ !(change instanceof AlterTableValidateConstraint) &&
61
+ !(change instanceof CreateCommentOnConstraint)) {
62
+ return null;
63
+ }
64
+ if (!replacedTableIds.has(change.table.stableId))
65
+ return null;
66
+ const tag = change instanceof AlterTableAddConstraint
67
+ ? "add"
68
+ : change instanceof AlterTableValidateConstraint
69
+ ? "validate"
70
+ : "comment";
71
+ return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
72
+ };
73
+ const seen = new Set();
74
+ const reversedKept = [];
75
+ let mutated = false;
76
+ // Walk backwards: the first encounter of each key corresponds to its LAST
77
+ // occurrence in the original order. `expandReplaceDependencies()` appends
78
+ // additions after the original changes, so "last wins" keeps the
79
+ // expansion's emission and drops the earlier diffTables duplicate.
80
+ for (let i = changes.length - 1; i >= 0; i--) {
81
+ const change = changes[i];
82
+ const key = keyFor(change);
83
+ if (key !== null) {
84
+ if (seen.has(key)) {
85
+ mutated = true;
86
+ continue;
87
+ }
88
+ seen.add(key);
89
+ }
90
+ reversedKept.push(change);
91
+ }
92
+ return mutated ? reversedKept.reverse() : changes;
93
+ }
36
94
  function collectExplicitConstraintDropIds(changes) {
37
95
  const explicitConstraintDropIds = new Set();
38
96
  for (const change of changes) {
@@ -59,6 +117,13 @@ function hasSameEntries(left, right) {
59
117
  * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
60
118
  * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
61
119
  * redundant and create an unbreakable drop-phase cycle, so we elide them.
120
+ * - When the same `DropTable+CreateTable` pair is present, the expansion
121
+ * also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
122
+ * / `CreateCommentOnConstraint` per branch constraint, which may collide
123
+ * with the same change already emitted by `diffTables()` (for example on a
124
+ * shape flip or a new constraint). We dedupe these keeping only the last
125
+ * occurrence so the expansion's emission survives and the diffTables
126
+ * duplicate is removed.
62
127
  * - If two dropped tables reference each other via FK, we insert dedicated
63
128
  * `AlterTableDropConstraint` changes and teach the paired `DropTable`
64
129
  * changes not to claim those FK stable IDs.
@@ -67,9 +132,10 @@ function hasSameEntries(left, right) {
67
132
  * in the corresponding `diff*` function instead of this pass.
68
133
  */
69
134
  export function normalizePostDiffCycles({ changes, mainCatalog, replacedTableIds = new Set(), }) {
135
+ const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds);
70
136
  const structurallyNormalizedChanges = replacedTableIds.size === 0
71
- ? changes
72
- : changes.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
137
+ ? dedupedChanges
138
+ : dedupedChanges.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
73
139
  const dropTableChanges = structurallyNormalizedChanges.filter((change) => change instanceof DropTable);
74
140
  if (dropTableChanges.length < 2) {
75
141
  return structurallyNormalizedChanges;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.18",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -3,6 +3,9 @@ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
3
3
  import type { Change } from "./change.types.ts";
4
4
  import { expandReplaceDependencies } from "./expand-replace-dependencies.ts";
5
5
  import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts";
6
+ import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
7
+ import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts";
8
+ import { Procedure } from "./objects/procedure/procedure.model.ts";
6
9
  import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
7
10
  import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
8
11
  import { diffSequences } from "./objects/sequence/sequence.diff.ts";
@@ -22,6 +25,9 @@ import { Table } from "./objects/table/table.model.ts";
22
25
  import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts";
23
26
  import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts";
24
27
  import { Enum } from "./objects/type/enum/enum.model.ts";
28
+ import { CreateView } from "./objects/view/changes/view.create.ts";
29
+ import { DropView } from "./objects/view/changes/view.drop.ts";
30
+ import { View } from "./objects/view/view.model.ts";
25
31
 
26
32
  function mockChange(overrides: {
27
33
  creates?: string[];
@@ -330,6 +336,7 @@ describe("expandReplaceDependencies", () => {
330
336
  validated: true,
331
337
  is_local: true,
332
338
  no_inherit: false,
339
+ is_temporal: false,
333
340
  is_partition_clone: false,
334
341
  parent_constraint_schema: null,
335
342
  parent_constraint_name: null,
@@ -431,4 +438,115 @@ describe("expandReplaceDependencies", () => {
431
438
  expect(expanded.changes).toContain(preExistingGrant);
432
439
  expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false);
433
440
  });
441
+
442
+ test("promotes dependent view when a procedure's parameter types change", async () => {
443
+ // Procedure stableIds are signature-qualified, so a parameter-type change
444
+ // produces different stableIds in `createdIds` and `droppedIds`. The
445
+ // expander must still treat the (schema, name)-matched pair as a replace
446
+ // root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to
447
+ // `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with
448
+ // "cannot drop function because other objects depend on it").
449
+ const baseline = await createEmptyCatalog(170000, "postgres");
450
+ const procedureBase = {
451
+ schema: "public",
452
+ name: "format_id",
453
+ kind: "f" as const,
454
+ return_type: "text",
455
+ return_type_schema: "pg_catalog",
456
+ language: "sql",
457
+ security_definer: false,
458
+ volatility: "i" as const,
459
+ parallel_safety: "u" as const,
460
+ execution_cost: 100,
461
+ result_rows: 0,
462
+ is_strict: false,
463
+ leakproof: false,
464
+ returns_set: false,
465
+ argument_count: 1,
466
+ argument_default_count: 0,
467
+ argument_names: ["id"],
468
+ all_argument_types: null,
469
+ argument_modes: null,
470
+ argument_defaults: null,
471
+ source_code: "SELECT 'id:' || id::text",
472
+ binary_path: null,
473
+ sql_body: null,
474
+ config: null,
475
+ owner: "postgres",
476
+ comment: null,
477
+ privileges: [],
478
+ };
479
+ const mainProcedure = new Procedure({
480
+ ...procedureBase,
481
+ argument_types: ["int4"],
482
+ definition: "CREATE FUNCTION public.format_id(id integer) ...",
483
+ });
484
+ const branchProcedure = new Procedure({
485
+ ...procedureBase,
486
+ argument_types: ["int8"],
487
+ definition: "CREATE FUNCTION public.format_id(id bigint) ...",
488
+ });
489
+ const viewBase = {
490
+ schema: "public",
491
+ name: "items_formatted",
492
+ row_security: false,
493
+ force_row_security: false,
494
+ has_indexes: false,
495
+ has_rules: false,
496
+ has_triggers: false,
497
+ has_subclasses: false,
498
+ is_populated: true,
499
+ replica_identity: "d" as const,
500
+ is_partition: false,
501
+ options: null,
502
+ partition_bound: null,
503
+ owner: "postgres",
504
+ comment: null,
505
+ columns: [],
506
+ privileges: [],
507
+ };
508
+ const mainView = new View({
509
+ ...viewBase,
510
+ definition: "SELECT public.format_id(id) FROM public.items",
511
+ });
512
+ const branchView = new View({
513
+ ...viewBase,
514
+ definition: "SELECT public.format_id(id::bigint) FROM public.items",
515
+ });
516
+
517
+ const changes: Change[] = [
518
+ new DropProcedure({ procedure: mainProcedure }),
519
+ new CreateProcedure({ procedure: branchProcedure }),
520
+ // view.diff emits this because pg_get_viewdef text differs after the
521
+ // underlying function signature changes.
522
+ new CreateView({ view: branchView, orReplace: true }),
523
+ ];
524
+
525
+ const mainCatalog = new Catalog({
526
+ ...baseline,
527
+ procedures: { [mainProcedure.stableId]: mainProcedure },
528
+ views: { [mainView.stableId]: mainView },
529
+ depends: [
530
+ {
531
+ dependent_stable_id: mainView.stableId,
532
+ referenced_stable_id: mainProcedure.stableId,
533
+ deptype: "n",
534
+ },
535
+ ],
536
+ });
537
+ const branchCatalog = new Catalog({
538
+ ...baseline,
539
+ procedures: { [branchProcedure.stableId]: branchProcedure },
540
+ views: { [branchView.stableId]: branchView },
541
+ depends: [],
542
+ });
543
+
544
+ const expanded = expandReplaceDependencies({
545
+ changes,
546
+ mainCatalog,
547
+ branchCatalog,
548
+ });
549
+
550
+ expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true);
551
+ });
434
552
  });
@@ -102,6 +102,25 @@ export function expandReplaceDependencies({
102
102
  }
103
103
  }
104
104
 
105
+ // Procedure stableIds are signature-qualified
106
+ // (`procedure:schema.name(argtypes)`), so a function whose parameter types
107
+ // change has different ids in `createdIds` and `droppedIds` and would not
108
+ // appear in the intersection above. Treat any dropped procedure whose
109
+ // `(schema, name)` matches a created procedure as a replace root so
110
+ // dependents referencing the old signature via pg_depend get promoted to
111
+ // DROP+CREATE.
112
+ const createdProcedureNames = new Set<string>();
113
+ for (const id of createdIds) {
114
+ const key = parseProcedureSchemaName(id);
115
+ if (key) createdProcedureNames.add(key);
116
+ }
117
+ for (const id of droppedIds) {
118
+ const key = parseProcedureSchemaName(id);
119
+ if (key && createdProcedureNames.has(key)) {
120
+ replaceRoots.add(id);
121
+ }
122
+ }
123
+
105
124
  if (replaceRoots.size === 0) {
106
125
  return {
107
126
  changes,
@@ -259,6 +278,13 @@ function isOwnedSequenceColumnDependency(
259
278
  );
260
279
  }
261
280
 
281
+ function parseProcedureSchemaName(stableId: string): string | null {
282
+ if (!stableId.startsWith("procedure:")) return null;
283
+ const paren = stableId.indexOf("(");
284
+ if (paren === -1) return null;
285
+ return stableId.slice("procedure:".length, paren);
286
+ }
287
+
262
288
  function normalizeDependentId(dependentId: string): string | null {
263
289
  let id = dependentId;
264
290
 
@@ -144,12 +144,13 @@ describe.concurrent("procedure.diff", () => {
144
144
  expect(changes[0]).toBeInstanceOf(AlterProcedureSetParallel);
145
145
  });
146
146
 
147
- test("create or replace when non-alterable property changes", () => {
147
+ test("create or replace when body-only non-alterable property changes", () => {
148
+ // Changing only the language (or source) is OR-REPLACE-safe: no DROP needed.
148
149
  const main = new Procedure(base);
149
150
  const branch = new Procedure({
150
151
  ...base,
151
- return_type: "text",
152
152
  language: "plpgsql",
153
+ source_code: "BEGIN RETURN 1; END",
153
154
  });
154
155
  const changes = diffProcedures(
155
156
  testContext,
@@ -158,6 +159,82 @@ describe.concurrent("procedure.diff", () => {
158
159
  );
159
160
  expect(changes).toHaveLength(1);
160
161
  expect(changes[0]).toBeInstanceOf(CreateProcedure);
162
+ expect((changes[0] as CreateProcedure).orReplace).toBe(true);
163
+ });
164
+
165
+ test("drop + create when return type changes", () => {
166
+ // `CREATE OR REPLACE FUNCTION` cannot change the return type.
167
+ const main = new Procedure(base);
168
+ const branch = new Procedure({ ...base, return_type: "text" });
169
+ const changes = diffProcedures(
170
+ testContext,
171
+ { [main.stableId]: main },
172
+ { [branch.stableId]: branch },
173
+ );
174
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
175
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
176
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
177
+ });
178
+
179
+ test("drop + create when parameter name changes but type is identical", () => {
180
+ // Same argument_types means same stableId (altered path), but
181
+ // `CREATE OR REPLACE` rejects changing an IN parameter's name.
182
+ const main = new Procedure({
183
+ ...base,
184
+ argument_count: 1,
185
+ argument_names: ["p"],
186
+ argument_types: ["int4"],
187
+ all_argument_types: ["int4"],
188
+ argument_modes: ["i"],
189
+ });
190
+ const branch = new Procedure({
191
+ ...base,
192
+ argument_count: 1,
193
+ argument_names: ["renamed"],
194
+ argument_types: ["int4"],
195
+ all_argument_types: ["int4"],
196
+ argument_modes: ["i"],
197
+ });
198
+ const changes = diffProcedures(
199
+ testContext,
200
+ { [main.stableId]: main },
201
+ { [branch.stableId]: branch },
202
+ );
203
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
204
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
205
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
206
+ });
207
+
208
+ test("drop + create when a parameter default is removed", () => {
209
+ // `CREATE OR REPLACE` rejects removing parameter defaults.
210
+ const main = new Procedure({
211
+ ...base,
212
+ argument_count: 1,
213
+ argument_default_count: 1,
214
+ argument_names: ["p"],
215
+ argument_types: ["int4"],
216
+ all_argument_types: ["int4"],
217
+ argument_modes: ["i"],
218
+ argument_defaults: "0",
219
+ });
220
+ const branch = new Procedure({
221
+ ...base,
222
+ argument_count: 1,
223
+ argument_default_count: 0,
224
+ argument_names: ["p"],
225
+ argument_types: ["int4"],
226
+ all_argument_types: ["int4"],
227
+ argument_modes: ["i"],
228
+ argument_defaults: null,
229
+ });
230
+ const changes = diffProcedures(
231
+ testContext,
232
+ { [main.stableId]: main },
233
+ { [branch.stableId]: branch },
234
+ );
235
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
236
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
237
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
161
238
  });
162
239
 
163
240
  test("create or replace also emits a procedure comment when the comment changes", () => {
@@ -183,4 +260,25 @@ describe.concurrent("procedure.diff", () => {
183
260
  changes.some((change) => change instanceof CreateCommentOnProcedure),
184
261
  ).toBe(true);
185
262
  });
263
+
264
+ test("signature change re-emits comment even when comment itself is unchanged", () => {
265
+ // DROP destroys the old comment, so the new CREATE path must re-emit it.
266
+ const main = new Procedure({ ...base, comment: "hello" });
267
+ const branch = new Procedure({
268
+ ...base,
269
+ return_type: "text",
270
+ comment: "hello",
271
+ });
272
+ const changes = diffProcedures(
273
+ testContext,
274
+ { [main.stableId]: main },
275
+ { [branch.stableId]: branch },
276
+ );
277
+ expect(changes.some((change) => change instanceof DropProcedure)).toBe(
278
+ true,
279
+ );
280
+ expect(
281
+ changes.some((change) => change instanceof CreateCommentOnProcedure),
282
+ ).toBe(true);
283
+ });
186
284
  });
@@ -49,8 +49,7 @@ export function diffProcedures(
49
49
 
50
50
  const changes: ProcedureChange[] = [];
51
51
 
52
- for (const procedureId of created) {
53
- const proc = branch[procedureId];
52
+ const appendCreateProcedureChanges = (proc: Procedure) => {
54
53
  changes.push(new CreateProcedure({ procedure: proc }));
55
54
 
56
55
  // OWNER: If the procedure should be owned by someone other than the current user,
@@ -112,6 +111,10 @@ export function diffProcedures(
112
111
  ctx.version,
113
112
  ) as ProcedureChange[]),
114
113
  );
114
+ };
115
+
116
+ for (const procedureId of created) {
117
+ appendCreateProcedureChanges(branch[procedureId]);
115
118
  }
116
119
 
117
120
  for (const procedureId of dropped) {
@@ -122,22 +125,18 @@ export function diffProcedures(
122
125
  const mainProcedure = main[procedureId];
123
126
  const branchProcedure = branch[procedureId];
124
127
 
125
- // Check if non-alterable properties have changed
126
- // These require dropping and recreating the procedure
127
- const NON_ALTERABLE_FIELDS: Array<keyof Procedure> = [
128
+ // Fields that are part of the function's identity/signature. PostgreSQL
129
+ // rejects `CREATE OR REPLACE FUNCTION` for any of these changes with
130
+ // errors such as:
131
+ // - cannot change return type of existing function
132
+ // - cannot change name of input parameter "..."
133
+ // - cannot change whether a procedure has output parameters
134
+ // - cannot remove parameter defaults from existing function
135
+ // These require `DROP FUNCTION` followed by `CREATE FUNCTION`.
136
+ const SIGNATURE_BREAKING_FIELDS: Array<keyof Procedure> = [
128
137
  "kind",
129
138
  "return_type",
130
139
  "return_type_schema",
131
- "language",
132
- // The following properties are alterable in SQL, but our generator may choose
133
- // to replace on changes not covered by explicit ALTER actions. Keep them out here
134
- // to allow ALTER for those we implement below.
135
- // security_definer,
136
- // volatility,
137
- // parallel_safety,
138
- // is_strict,
139
- // leakproof,
140
- // Returns-set is part of the signature and not alterable
141
140
  "returns_set",
142
141
  "argument_count",
143
142
  "argument_default_count",
@@ -146,26 +145,45 @@ export function diffProcedures(
146
145
  "all_argument_types",
147
146
  "argument_modes",
148
147
  "argument_defaults",
148
+ ];
149
+ // Fields where `CREATE OR REPLACE` is sufficient - body replacement only.
150
+ // Other fields (security_definer, volatility, parallel_safety, is_strict,
151
+ // leakproof, config) are alterable via dedicated ALTER actions below.
152
+ const OR_REPLACEABLE_NON_ALTERABLE_FIELDS: Array<keyof Procedure> = [
153
+ "language",
149
154
  "source_code",
150
155
  "binary_path",
151
156
  "sql_body",
152
- // config is alterable via SET/RESET
153
157
  ];
154
- const nonAlterablePropsChanged = hasNonAlterableChanges(
158
+ const signatureChanged = hasNonAlterableChanges(
155
159
  mainProcedure,
156
160
  branchProcedure,
157
- NON_ALTERABLE_FIELDS,
161
+ SIGNATURE_BREAKING_FIELDS,
158
162
  {
159
163
  argument_names: deepEqual,
160
164
  argument_types: deepEqual,
161
165
  all_argument_types: deepEqual,
162
166
  argument_modes: deepEqual,
163
- config: deepEqual,
164
167
  },
165
168
  );
169
+ const nonAlterablePropsChanged =
170
+ signatureChanged ||
171
+ hasNonAlterableChanges(
172
+ mainProcedure,
173
+ branchProcedure,
174
+ OR_REPLACEABLE_NON_ALTERABLE_FIELDS,
175
+ );
166
176
 
167
- if (nonAlterablePropsChanged) {
168
- // Replace the entire procedure
177
+ if (signatureChanged) {
178
+ // PostgreSQL cannot change an existing function's signature via
179
+ // `CREATE OR REPLACE`. Drop the old signature, then recreate.
180
+ // `expandReplaceDependencies` will cascade the replacement to dependent
181
+ // objects (views, triggers, column defaults) via pg_depend edges.
182
+ changes.push(new DropProcedure({ procedure: mainProcedure }));
183
+ appendCreateProcedureChanges(branchProcedure);
184
+ } else if (nonAlterablePropsChanged) {
185
+ // Body-only non-alterable change - `CREATE OR REPLACE` preserves the
186
+ // function OID and keeps dependent objects attached.
169
187
  changes.push(
170
188
  new CreateProcedure({ procedure: branchProcedure, orReplace: true }),
171
189
  );
@@ -766,6 +766,7 @@ describe.concurrent("table", () => {
766
766
  validated: true,
767
767
  is_local: true,
768
768
  no_inherit: false,
769
+ is_temporal: false,
769
770
  is_partition_clone: false,
770
771
  parent_constraint_schema: null,
771
772
  parent_constraint_name: null,
@@ -112,6 +112,7 @@ describe.concurrent("table.diff", () => {
112
112
  validated: false,
113
113
  is_local: true,
114
114
  no_inherit: false,
115
+ is_temporal: false,
115
116
  is_partition_clone: false,
116
117
  parent_constraint_schema: null,
117
118
  parent_constraint_name: null,
@@ -328,6 +329,7 @@ describe.concurrent("table.diff", () => {
328
329
  validated: false,
329
330
  is_local: true,
330
331
  no_inherit: false,
332
+ is_temporal: false,
331
333
  is_partition_clone: false,
332
334
  parent_constraint_schema: null,
333
335
  parent_constraint_name: null,
@@ -453,6 +455,7 @@ describe.concurrent("table.diff", () => {
453
455
  validated: true,
454
456
  is_local: true,
455
457
  no_inherit: false,
458
+ is_temporal: false,
456
459
  is_partition_clone: false,
457
460
  parent_constraint_schema: null,
458
461
  parent_constraint_name: null,
@@ -531,6 +534,7 @@ describe.concurrent("table.diff", () => {
531
534
  validated: true,
532
535
  is_local: true,
533
536
  no_inherit: false,
537
+ is_temporal: false,
534
538
  is_partition_clone: false,
535
539
  parent_constraint_schema: null,
536
540
  parent_constraint_name: null,
@@ -606,6 +610,104 @@ describe.concurrent("table.diff", () => {
606
610
  );
607
611
  });
608
612
 
613
+ test("altered temporal constraint metadata triggers drop+add", () => {
614
+ const tMain = new Table({
615
+ ...base,
616
+ name: "t_temporal",
617
+ columns: [
618
+ {
619
+ name: "room_id",
620
+ position: 1,
621
+ data_type: "integer",
622
+ data_type_str: "integer",
623
+ is_custom_type: false,
624
+ custom_type_type: null,
625
+ custom_type_category: null,
626
+ custom_type_schema: null,
627
+ custom_type_name: null,
628
+ not_null: false,
629
+ is_identity: false,
630
+ is_identity_always: false,
631
+ is_generated: false,
632
+ collation: null,
633
+ default: null,
634
+ comment: null,
635
+ },
636
+ {
637
+ name: "booking_period",
638
+ position: 2,
639
+ data_type: "tstzrange",
640
+ data_type_str: "tstzrange",
641
+ is_custom_type: false,
642
+ custom_type_type: null,
643
+ custom_type_category: null,
644
+ custom_type_schema: null,
645
+ custom_type_name: null,
646
+ not_null: false,
647
+ is_identity: false,
648
+ is_identity_always: false,
649
+ is_generated: false,
650
+ collation: null,
651
+ default: null,
652
+ comment: null,
653
+ },
654
+ ],
655
+ constraints: [
656
+ {
657
+ name: "bookings_pkey",
658
+ constraint_type: "p",
659
+ deferrable: false,
660
+ initially_deferred: false,
661
+ validated: true,
662
+ is_local: true,
663
+ no_inherit: false,
664
+ is_temporal: false,
665
+ is_partition_clone: false,
666
+ parent_constraint_schema: null,
667
+ parent_constraint_name: null,
668
+ parent_table_schema: null,
669
+ parent_table_name: null,
670
+ key_columns: ["room_id", "booking_period"],
671
+ foreign_key_columns: null,
672
+ foreign_key_table: null,
673
+ foreign_key_schema: null,
674
+ foreign_key_table_is_partition: null,
675
+ foreign_key_parent_schema: null,
676
+ foreign_key_parent_table: null,
677
+ foreign_key_effective_schema: null,
678
+ foreign_key_effective_table: null,
679
+ on_update: null,
680
+ on_delete: null,
681
+ match_type: null,
682
+ check_expression: null,
683
+ owner: "o1",
684
+ definition: "PRIMARY KEY (room_id, booking_period)",
685
+ },
686
+ ],
687
+ });
688
+ const tBranch = new Table({
689
+ ...tMain,
690
+ constraints: [
691
+ {
692
+ ...tMain.constraints[0],
693
+ is_temporal: true,
694
+ definition: "PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS)",
695
+ },
696
+ ],
697
+ });
698
+ const changes = diffTables(
699
+ testContext,
700
+ { [tMain.stableId]: tMain },
701
+ { [tBranch.stableId]: tBranch },
702
+ );
703
+ expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe(
704
+ true,
705
+ );
706
+ expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe(
707
+ true,
708
+ );
709
+ });
710
+
609
711
  test("columns added/dropped/altered (type, default, not null)", () => {
610
712
  const main = new Table({ ...base, name: "t2", columns: [] });
611
713
  const withCol = new Table({
@@ -130,6 +130,7 @@ function createAlterConstraintChange(mainTable: Table, branchTable: Table) {
130
130
  mainC.validated !== branchC.validated ||
131
131
  mainC.is_local !== branchC.is_local ||
132
132
  mainC.no_inherit !== branchC.no_inherit ||
133
+ mainC.is_temporal !== branchC.is_temporal ||
133
134
  JSON.stringify(mainC.key_columns) !==
134
135
  JSON.stringify(branchC.key_columns) ||
135
136
  JSON.stringify(mainC.foreign_key_columns) !==
@@ -56,6 +56,7 @@ const tableConstraintPropsSchema = z.object({
56
56
  validated: z.boolean(),
57
57
  is_local: z.boolean(),
58
58
  no_inherit: z.boolean(),
59
+ is_temporal: z.boolean(),
59
60
  is_partition_clone: z.boolean(),
60
61
  parent_constraint_schema: z.string().nullable(),
61
62
  parent_constraint_name: z.string().nullable(),
@@ -284,6 +285,7 @@ select
284
285
  'validated', c.convalidated,
285
286
  'is_local', c.conislocal,
286
287
  'no_inherit', c.connoinherit,
288
+ 'is_temporal', coalesce((to_jsonb(c)->>'conperiod')::boolean, false),
287
289
 
288
290
  -- NEW: propagated-to-partition tagging (PG15+)
289
291
  'is_partition_clone', (c.conparentid <> 0::oid),
@@ -578,6 +578,7 @@ const pkConstraint = {
578
578
  validated: true,
579
579
  is_local: true,
580
580
  no_inherit: false,
581
+ is_temporal: false,
581
582
  is_partition_clone: false,
582
583
  parent_constraint_schema: null,
583
584
  parent_constraint_name: null,
@@ -609,6 +610,7 @@ const uniqueConstraint = {
609
610
  validated: true,
610
611
  is_local: true,
611
612
  no_inherit: false,
613
+ is_temporal: false,
612
614
  is_partition_clone: false,
613
615
  parent_constraint_schema: null,
614
616
  parent_constraint_name: null,
@@ -639,6 +641,7 @@ const fkConstraint = {
639
641
  validated: true,
640
642
  is_local: true,
641
643
  no_inherit: false,
644
+ is_temporal: false,
642
645
  is_partition_clone: false,
643
646
  parent_constraint_schema: null,
644
647
  parent_constraint_name: null,
@@ -670,6 +673,7 @@ const checkConstraint = {
670
673
  validated: true,
671
674
  is_local: true,
672
675
  no_inherit: true,
676
+ is_temporal: false,
673
677
  is_partition_clone: false,
674
678
  parent_constraint_schema: null,
675
679
  parent_constraint_name: null,
@@ -2,12 +2,15 @@ import { describe, expect, test } from "bun:test";
2
2
  import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
3
3
  import type { Change } from "./change.types.ts";
4
4
  import {
5
+ AlterTableAddConstraint,
5
6
  AlterTableChangeOwner,
6
7
  AlterTableDropColumn,
7
8
  AlterTableDropConstraint,
8
9
  AlterTableEnableRowLevelSecurity,
9
10
  AlterTableSetReplicaIdentity,
11
+ AlterTableValidateConstraint,
10
12
  } from "./objects/table/changes/table.alter.ts";
13
+ import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts";
11
14
  import { CreateTable } from "./objects/table/changes/table.create.ts";
12
15
  import { DropTable } from "./objects/table/changes/table.drop.ts";
13
16
  import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts";
@@ -77,6 +80,7 @@ describe("normalizePostDiffCycles", () => {
77
80
  validated: true,
78
81
  is_local: true,
79
82
  no_inherit: false,
83
+ is_temporal: false,
80
84
  is_partition_clone: false,
81
85
  parent_constraint_schema: null,
82
86
  parent_constraint_name: null,
@@ -117,6 +121,7 @@ describe("normalizePostDiffCycles", () => {
117
121
  validated: true,
118
122
  is_local: true,
119
123
  no_inherit: false,
124
+ is_temporal: false,
120
125
  is_partition_clone: false,
121
126
  parent_constraint_schema: null,
122
127
  parent_constraint_name: null,
@@ -237,6 +242,7 @@ describe("normalizePostDiffCycles", () => {
237
242
  validated: true,
238
243
  is_local: true,
239
244
  no_inherit: false,
245
+ is_temporal: false,
240
246
  is_partition_clone: false,
241
247
  parent_constraint_schema: null,
242
248
  parent_constraint_name: null,
@@ -314,4 +320,140 @@ describe("normalizePostDiffCycles", () => {
314
320
  expect(normalized).toContain(preExistingReplicaIdentity);
315
321
  expect(normalized).toContain(preExistingGrant);
316
322
  });
323
+
324
+ test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => {
325
+ const baseline = await createEmptyCatalog(170000, "postgres");
326
+ const branchChildren = new Table({
327
+ ...baseTableProps,
328
+ name: "children",
329
+ columns: [
330
+ { ...integerColumn("id", 1), not_null: true },
331
+ integerColumn("parent_ref", 2),
332
+ ],
333
+ });
334
+ const otherTable = new Table({
335
+ ...baseTableProps,
336
+ name: "other",
337
+ columns: [{ ...integerColumn("id", 1), not_null: true }],
338
+ });
339
+
340
+ const fkConstraint = {
341
+ name: "children_parent_ref_fkey",
342
+ constraint_type: "f" as const,
343
+ deferrable: false,
344
+ initially_deferred: false,
345
+ validated: false,
346
+ is_local: true,
347
+ no_inherit: false,
348
+ is_temporal: true,
349
+ is_partition_clone: false,
350
+ parent_constraint_schema: null,
351
+ parent_constraint_name: null,
352
+ parent_table_schema: null,
353
+ parent_table_name: null,
354
+ key_columns: ["parent_ref"],
355
+ foreign_key_columns: ["id"],
356
+ foreign_key_table: "parents",
357
+ foreign_key_schema: "public",
358
+ foreign_key_table_is_partition: false,
359
+ foreign_key_parent_schema: null,
360
+ foreign_key_parent_table: null,
361
+ foreign_key_effective_schema: "public",
362
+ foreign_key_effective_table: "parents",
363
+ on_update: "a" as const,
364
+ on_delete: "a" as const,
365
+ match_type: "s" as const,
366
+ check_expression: null,
367
+ owner: "postgres",
368
+ definition:
369
+ "FOREIGN KEY (parent_ref, PERIOD valid_period) REFERENCES public.parents(id, PERIOD valid_period)",
370
+ comment: "fk comment",
371
+ };
372
+ const otherConstraint = {
373
+ ...fkConstraint,
374
+ name: "other_unique",
375
+ constraint_type: "u" as const,
376
+ foreign_key_table: null,
377
+ foreign_key_schema: null,
378
+ foreign_key_effective_schema: null,
379
+ foreign_key_effective_table: null,
380
+ foreign_key_columns: [],
381
+ key_columns: ["id"],
382
+ definition: "UNIQUE (id)",
383
+ };
384
+
385
+ const diffTablesAdd = new AlterTableAddConstraint({
386
+ table: branchChildren,
387
+ constraint: fkConstraint,
388
+ });
389
+ const diffTablesValidate = new AlterTableValidateConstraint({
390
+ table: branchChildren,
391
+ constraint: fkConstraint,
392
+ });
393
+ const diffTablesComment = new CreateCommentOnConstraint({
394
+ table: branchChildren,
395
+ constraint: fkConstraint,
396
+ });
397
+ const expansionAdd = new AlterTableAddConstraint({
398
+ table: branchChildren,
399
+ constraint: fkConstraint,
400
+ });
401
+ const expansionValidate = new AlterTableValidateConstraint({
402
+ table: branchChildren,
403
+ constraint: fkConstraint,
404
+ });
405
+ const expansionComment = new CreateCommentOnConstraint({
406
+ table: branchChildren,
407
+ constraint: fkConstraint,
408
+ });
409
+ const soloOtherTableAdd = new AlterTableAddConstraint({
410
+ table: otherTable,
411
+ constraint: otherConstraint,
412
+ });
413
+
414
+ const changes: Change[] = [
415
+ new DropTable({ table: branchChildren }),
416
+ new CreateTable({ table: branchChildren }),
417
+ diffTablesAdd,
418
+ diffTablesValidate,
419
+ diffTablesComment,
420
+ soloOtherTableAdd,
421
+ expansionAdd,
422
+ expansionValidate,
423
+ expansionComment,
424
+ ];
425
+
426
+ const mainCatalog = new Catalog({
427
+ ...baseline,
428
+ tables: { [branchChildren.stableId]: branchChildren },
429
+ });
430
+
431
+ const normalized = normalizePostDiffCycles({
432
+ changes,
433
+ mainCatalog,
434
+ replacedTableIds: new Set([branchChildren.stableId]),
435
+ });
436
+
437
+ expect(normalized).not.toContain(diffTablesAdd);
438
+ expect(normalized).not.toContain(diffTablesValidate);
439
+ expect(normalized).not.toContain(diffTablesComment);
440
+ expect(normalized).toContain(expansionAdd);
441
+ expect(normalized).toContain(expansionValidate);
442
+ expect(normalized).toContain(expansionComment);
443
+ expect(normalized).toContain(soloOtherTableAdd);
444
+
445
+ expect(
446
+ normalized.filter((change) => change instanceof AlterTableAddConstraint),
447
+ ).toHaveLength(2);
448
+ expect(
449
+ normalized.filter(
450
+ (change) => change instanceof AlterTableValidateConstraint,
451
+ ),
452
+ ).toHaveLength(1);
453
+ expect(
454
+ normalized.filter(
455
+ (change) => change instanceof CreateCommentOnConstraint,
456
+ ),
457
+ ).toHaveLength(1);
458
+ });
317
459
  });
@@ -1,9 +1,12 @@
1
1
  import type { Catalog } from "./catalog.model.ts";
2
2
  import type { Change } from "./change.types.ts";
3
3
  import {
4
+ AlterTableAddConstraint,
4
5
  AlterTableDropColumn,
5
6
  AlterTableDropConstraint,
7
+ AlterTableValidateConstraint,
6
8
  } from "./objects/table/changes/table.alter.ts";
9
+ import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts";
7
10
  import { DropTable } from "./objects/table/changes/table.drop.ts";
8
11
  import { stableId } from "./objects/utils.ts";
9
12
 
@@ -52,6 +55,72 @@ function isSupersededByTableReplacement(
52
55
  return replacedTableIds.has(change.table.stableId);
53
56
  }
54
57
 
58
+ /**
59
+ * Drop earlier duplicates of `AlterTableAddConstraint` /
60
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
61
+ * replaced tables, keeping only the last occurrence of each
62
+ * `(changeType, table.stableId, constraint.name)`.
63
+ *
64
+ * When `expandReplaceDependencies()` promotes a table to a full
65
+ * `DropTable + CreateTable` pair, it also emits one
66
+ * `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
67
+ * `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
68
+ * emitted the same change for a shape flip or a new constraint on that
69
+ * table, the plan ends up with two identical `ALTER TABLE ... ADD
70
+ * CONSTRAINT ...` statements and PostgreSQL fails at apply time with
71
+ * `constraint "..." for relation "..." already exists`. Because
72
+ * `expandReplaceDependencies()` appends its additions after the original
73
+ * `diffTables()` output, the last occurrence is the expansion's emission —
74
+ * keeping it preserves correctness while removing the duplicate.
75
+ */
76
+ function dropReplacedTableDuplicateConstraintChanges(
77
+ changes: Change[],
78
+ replacedTableIds: ReadonlySet<string>,
79
+ ): Change[] {
80
+ if (replacedTableIds.size === 0) return changes;
81
+
82
+ const keyFor = (change: Change): string | null => {
83
+ if (
84
+ !(change instanceof AlterTableAddConstraint) &&
85
+ !(change instanceof AlterTableValidateConstraint) &&
86
+ !(change instanceof CreateCommentOnConstraint)
87
+ ) {
88
+ return null;
89
+ }
90
+ if (!replacedTableIds.has(change.table.stableId)) return null;
91
+ const tag =
92
+ change instanceof AlterTableAddConstraint
93
+ ? "add"
94
+ : change instanceof AlterTableValidateConstraint
95
+ ? "validate"
96
+ : "comment";
97
+ return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
98
+ };
99
+
100
+ const seen = new Set<string>();
101
+ const reversedKept: Change[] = [];
102
+ let mutated = false;
103
+
104
+ // Walk backwards: the first encounter of each key corresponds to its LAST
105
+ // occurrence in the original order. `expandReplaceDependencies()` appends
106
+ // additions after the original changes, so "last wins" keeps the
107
+ // expansion's emission and drops the earlier diffTables duplicate.
108
+ for (let i = changes.length - 1; i >= 0; i--) {
109
+ const change = changes[i] as Change;
110
+ const key = keyFor(change);
111
+ if (key !== null) {
112
+ if (seen.has(key)) {
113
+ mutated = true;
114
+ continue;
115
+ }
116
+ seen.add(key);
117
+ }
118
+ reversedKept.push(change);
119
+ }
120
+
121
+ return mutated ? reversedKept.reverse() : changes;
122
+ }
123
+
55
124
  function collectExplicitConstraintDropIds(changes: Change[]) {
56
125
  const explicitConstraintDropIds = new Set<string>();
57
126
 
@@ -84,6 +153,13 @@ function hasSameEntries(
84
153
  * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
85
154
  * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
86
155
  * redundant and create an unbreakable drop-phase cycle, so we elide them.
156
+ * - When the same `DropTable+CreateTable` pair is present, the expansion
157
+ * also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
158
+ * / `CreateCommentOnConstraint` per branch constraint, which may collide
159
+ * with the same change already emitted by `diffTables()` (for example on a
160
+ * shape flip or a new constraint). We dedupe these keeping only the last
161
+ * occurrence so the expansion's emission survives and the diffTables
162
+ * duplicate is removed.
87
163
  * - If two dropped tables reference each other via FK, we insert dedicated
88
164
  * `AlterTableDropConstraint` changes and teach the paired `DropTable`
89
165
  * changes not to claim those FK stable IDs.
@@ -100,10 +176,15 @@ export function normalizePostDiffCycles({
100
176
  mainCatalog: Catalog;
101
177
  replacedTableIds?: ReadonlySet<string>;
102
178
  }): Change[] {
179
+ const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(
180
+ changes,
181
+ replacedTableIds,
182
+ );
183
+
103
184
  const structurallyNormalizedChanges =
104
185
  replacedTableIds.size === 0
105
- ? changes
106
- : changes.filter(
186
+ ? dedupedChanges
187
+ : dedupedChanges.filter(
107
188
  (change) => !isSupersededByTableReplacement(change, replacedTableIds),
108
189
  );
109
190