@supabase/pg-delta 1.0.0-alpha.21 → 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 (73) hide show
  1. package/dist/core/catalog.diff.js +4 -3
  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/rls-policy/rls-policy.diff.js +13 -1
  15. package/dist/core/objects/rule/rule.model.d.ts +2 -1
  16. package/dist/core/objects/rule/rule.model.js +20 -3
  17. package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
  18. package/dist/core/objects/sequence/sequence.diff.js +28 -4
  19. package/dist/core/objects/table/changes/table.alter.d.ts +12 -1
  20. package/dist/core/objects/table/changes/table.alter.js +20 -2
  21. package/dist/core/objects/table/table.diff.js +19 -15
  22. package/dist/core/objects/table/table.model.d.ts +6 -1
  23. package/dist/core/objects/table/table.model.js +40 -5
  24. package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
  25. package/dist/core/objects/trigger/trigger.model.js +20 -4
  26. package/dist/core/objects/utils.d.ts +1 -0
  27. package/dist/core/objects/utils.js +3 -0
  28. package/dist/core/objects/view/view.model.d.ts +2 -1
  29. package/dist/core/objects/view/view.model.js +20 -4
  30. package/dist/core/plan/create.js +3 -1
  31. package/dist/core/plan/types.d.ts +8 -0
  32. package/dist/core/{post-diff-cycle-breaking.d.ts → post-diff-normalization.d.ts} +8 -1
  33. package/dist/core/post-diff-normalization.js +202 -0
  34. package/dist/core/sort/cycle-breakers.js +1 -1
  35. package/dist/core/sort/utils.d.ts +10 -0
  36. package/dist/core/sort/utils.js +28 -0
  37. package/package.json +1 -1
  38. package/src/core/catalog.diff.ts +4 -2
  39. package/src/core/catalog.model.ts +20 -8
  40. package/src/core/expand-replace-dependencies.test.ts +131 -0
  41. package/src/core/expand-replace-dependencies.ts +24 -0
  42. package/src/core/objects/extract-with-retry.test.ts +143 -0
  43. package/src/core/objects/extract-with-retry.ts +87 -0
  44. package/src/core/objects/index/index.diff.ts +0 -1
  45. package/src/core/objects/index/index.model.test.ts +37 -1
  46. package/src/core/objects/index/index.model.ts +25 -6
  47. package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
  48. package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
  49. package/src/core/objects/procedure/procedure.model.test.ts +117 -0
  50. package/src/core/objects/procedure/procedure.model.ts +28 -5
  51. package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
  52. package/src/core/objects/rule/rule.model.test.ts +99 -0
  53. package/src/core/objects/rule/rule.model.ts +28 -4
  54. package/src/core/objects/sequence/sequence.diff.test.ts +87 -0
  55. package/src/core/objects/sequence/sequence.diff.ts +31 -6
  56. package/src/core/objects/table/changes/table.alter.test.ts +13 -21
  57. package/src/core/objects/table/changes/table.alter.ts +30 -3
  58. package/src/core/objects/table/table.diff.ts +24 -19
  59. package/src/core/objects/table/table.model.test.ts +209 -0
  60. package/src/core/objects/table/table.model.ts +52 -7
  61. package/src/core/objects/trigger/trigger.model.test.ts +113 -0
  62. package/src/core/objects/trigger/trigger.model.ts +28 -5
  63. package/src/core/objects/utils.ts +3 -0
  64. package/src/core/objects/view/view.model.test.ts +90 -0
  65. package/src/core/objects/view/view.model.ts +28 -5
  66. package/src/core/plan/create.ts +3 -1
  67. package/src/core/plan/types.ts +8 -0
  68. package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -4
  69. package/src/core/post-diff-normalization.ts +260 -0
  70. package/src/core/sort/cycle-breakers.ts +1 -1
  71. package/src/core/sort/utils.ts +38 -0
  72. package/dist/core/post-diff-cycle-breaking.js +0 -100
  73. package/src/core/post-diff-cycle-breaking.ts +0 -138
@@ -152,13 +152,11 @@ export function diffTables(ctx, main, branch) {
152
152
  }
153
153
  // REPLICA IDENTITY: If non-default, emit ALTER TABLE ... REPLICA IDENTITY
154
154
  if (branchTable.replica_identity !== "d") {
155
- // Skip 'i' (USING INDEX) — handled by index changes
156
- if (branchTable.replica_identity !== "i") {
157
- changes.push(new AlterTableSetReplicaIdentity({
158
- table: branchTable,
159
- mode: branchTable.replica_identity,
160
- }));
161
- }
155
+ changes.push(new AlterTableSetReplicaIdentity({
156
+ table: branchTable,
157
+ mode: branchTable.replica_identity,
158
+ indexName: branchTable.replica_identity_index,
159
+ }));
162
160
  }
163
161
  changes.push(...createAlterConstraintChange(
164
162
  // Create a dummy table with no constraints do diff constraints against
@@ -261,14 +259,20 @@ export function diffTables(ctx, main, branch) {
261
259
  }
262
260
  }
263
261
  // REPLICA IDENTITY
264
- if (mainTable.replica_identity !== branchTable.replica_identity) {
265
- // Skip when target is 'i' (USING INDEX) handled by index changes
266
- if (branchTable.replica_identity !== "i") {
267
- changes.push(new AlterTableSetReplicaIdentity({
268
- table: mainTable,
269
- mode: branchTable.replica_identity,
270
- }));
271
- }
262
+ // Re-emit when the mode changes, or when staying in 'i' mode but pointing
263
+ // at a different index. The index named on the branch must already exist
264
+ // before this ALTER runs; AlterTableSetReplicaIdentity declares that
265
+ // dependency in its `requires`.
266
+ const replicaIdentityChanged = mainTable.replica_identity !== branchTable.replica_identity ||
267
+ (branchTable.replica_identity === "i" &&
268
+ mainTable.replica_identity_index !==
269
+ branchTable.replica_identity_index);
270
+ if (replicaIdentityChanged) {
271
+ changes.push(new AlterTableSetReplicaIdentity({
272
+ table: mainTable,
273
+ mode: branchTable.replica_identity,
274
+ indexName: branchTable.replica_identity_index,
275
+ }));
272
276
  }
273
277
  // OWNER
274
278
  if (mainTable.owner !== branchTable.owner) {
@@ -2,6 +2,7 @@ import type { Pool } from "pg";
2
2
  import z from "zod";
3
3
  import { BasePgModel, type TableLikeObject } from "../base.model.ts";
4
4
  import { type PrivilegeProps } from "../base.privilege-diff.ts";
5
+ import { type ExtractRetryOptions } from "../extract-with-retry.ts";
5
6
  export declare const ReplicaIdentitySchema: z.ZodEnum<{
6
7
  n: "n";
7
8
  i: "i";
@@ -85,6 +86,7 @@ declare const tablePropsSchema: z.ZodObject<{
85
86
  d: "d";
86
87
  f: "f";
87
88
  }>;
89
+ replica_identity_index: z.ZodOptional<z.ZodNullable<z.ZodString>>;
88
90
  is_partition: z.ZodBoolean;
89
91
  options: z.ZodNullable<z.ZodArray<z.ZodString>>;
90
92
  partition_bound: z.ZodNullable<z.ZodString>;
@@ -187,6 +189,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
187
189
  readonly has_subclasses: TableProps["has_subclasses"];
188
190
  readonly is_populated: TableProps["is_populated"];
189
191
  readonly replica_identity: TableProps["replica_identity"];
192
+ readonly replica_identity_index: TableProps["replica_identity_index"];
190
193
  readonly is_partition: TableProps["is_partition"];
191
194
  readonly options: TableProps["options"];
192
195
  readonly partition_bound: TableProps["partition_bound"];
@@ -209,6 +212,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
209
212
  row_security: boolean;
210
213
  force_row_security: boolean;
211
214
  replica_identity: "n" | "i" | "d" | "f";
215
+ replica_identity_index: string | null | undefined;
212
216
  options: string[] | null;
213
217
  parent_schema: string | null;
214
218
  parent_name: string | null;
@@ -336,6 +340,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
336
340
  row_security: boolean;
337
341
  force_row_security: boolean;
338
342
  replica_identity: "n" | "i" | "d" | "f";
343
+ replica_identity_index: string | null | undefined;
339
344
  parent_schema: string | null;
340
345
  parent_name: string | null;
341
346
  partition_bound: string | null;
@@ -344,5 +349,5 @@ export declare class Table extends BasePgModel implements TableLikeObject {
344
349
  };
345
350
  };
346
351
  }
347
- export declare function extractTables(pool: Pool): Promise<Table[]>;
352
+ export declare function extractTables(pool: Pool, options?: ExtractRetryOptions): Promise<Table[]>;
348
353
  export {};
@@ -3,6 +3,7 @@ import z from "zod";
3
3
  import { BasePgModel, columnPropsSchema, normalizeColumns, } from "../base.model.js";
4
4
  import { normalizePrivileges } from "../base.privilege.js";
5
5
  import { privilegePropsSchema, } from "../base.privilege-diff.js";
6
+ import { extractWithDefinitionRetry, } from "../extract-with-retry.js";
6
7
  const RelationPersistenceSchema = z.enum([
7
8
  "p", // permanent
8
9
  "u", // unlogged
@@ -65,6 +66,14 @@ const tableConstraintPropsSchema = z.object({
65
66
  definition: z.string(),
66
67
  comment: z.string().nullable().optional(),
67
68
  });
69
+ // pg_get_constraintdef(oid, pretty) can return NULL under the same conditions
70
+ // as pg_get_indexdef: races with concurrent DDL, transient catalog
71
+ // inconsistencies, recovery edges. An unreadable constraint cannot be diffed,
72
+ // so we accept NULL here and filter the constraint out at extraction time
73
+ // rather than crashing the whole catalog parse with a ZodError.
74
+ const tableConstraintRowSchema = tableConstraintPropsSchema.extend({
75
+ definition: z.string().nullable(),
76
+ });
68
77
  const tablePropsSchema = z.object({
69
78
  schema: z.string(),
70
79
  name: z.string(),
@@ -77,6 +86,7 @@ const tablePropsSchema = z.object({
77
86
  has_subclasses: z.boolean(),
78
87
  is_populated: z.boolean(),
79
88
  replica_identity: ReplicaIdentitySchema,
89
+ replica_identity_index: z.string().nullable().optional(),
80
90
  is_partition: z.boolean(),
81
91
  options: z.array(z.string()).nullable(),
82
92
  partition_bound: z.string().nullable(),
@@ -89,6 +99,9 @@ const tablePropsSchema = z.object({
89
99
  constraints: z.array(tableConstraintPropsSchema).optional(),
90
100
  privileges: z.array(privilegePropsSchema),
91
101
  });
102
+ const tableRowSchema = tablePropsSchema.extend({
103
+ constraints: z.array(tableConstraintRowSchema).optional(),
104
+ });
92
105
  export class Table extends BasePgModel {
93
106
  schema;
94
107
  name;
@@ -101,6 +114,7 @@ export class Table extends BasePgModel {
101
114
  has_subclasses;
102
115
  is_populated;
103
116
  replica_identity;
117
+ replica_identity_index;
104
118
  is_partition;
105
119
  options;
106
120
  partition_bound;
@@ -127,6 +141,7 @@ export class Table extends BasePgModel {
127
141
  this.has_subclasses = props.has_subclasses;
128
142
  this.is_populated = props.is_populated;
129
143
  this.replica_identity = props.replica_identity;
144
+ this.replica_identity_index = props.replica_identity_index ?? null;
130
145
  this.is_partition = props.is_partition;
131
146
  this.options = props.options;
132
147
  this.partition_bound = props.partition_bound;
@@ -155,6 +170,7 @@ export class Table extends BasePgModel {
155
170
  row_security: this.row_security,
156
171
  force_row_security: this.force_row_security,
157
172
  replica_identity: this.replica_identity,
173
+ replica_identity_index: this.replica_identity_index,
158
174
  options: this.options,
159
175
  // Partition membership can be altered via ATTACH/DETACH
160
176
  parent_schema: this.parent_schema,
@@ -185,8 +201,13 @@ export class Table extends BasePgModel {
185
201
  };
186
202
  }
187
203
  }
188
- export async function extractTables(pool) {
189
- const { rows: tableRows } = await pool.query(sql `
204
+ export async function extractTables(pool, options) {
205
+ const tableRows = await extractWithDefinitionRetry({
206
+ label: "table constraints",
207
+ options,
208
+ hasNullDefinition: (row) => row.constraints?.some((c) => c.definition === null) ?? false,
209
+ query: async () => {
210
+ const result = await pool.query(sql `
190
211
  with extension_oids as (
191
212
  select objid
192
213
  from pg_depend d
@@ -205,6 +226,14 @@ with extension_oids as (
205
226
  c.relhassubclass as has_subclasses,
206
227
  c.relispopulated as is_populated,
207
228
  c.relreplident as replica_identity,
229
+ (
230
+ select quote_ident(ri_class.relname)
231
+ from pg_index ri
232
+ join pg_class ri_class on ri_class.oid = ri.indexrelid
233
+ where ri.indrelid = c.oid
234
+ and ri.indisreplident is true
235
+ limit 1
236
+ ) as replica_identity_index,
208
237
  c.relispartition as is_partition,
209
238
  c.reloptions as options,
210
239
  pg_get_expr(c.relpartbound, c.oid) as partition_bound,
@@ -235,6 +264,7 @@ select
235
264
  t.has_subclasses,
236
265
  t.is_populated,
237
266
  t.replica_identity,
267
+ t.replica_identity_index,
238
268
  t.is_partition,
239
269
  t.options,
240
270
  t.partition_bound,
@@ -422,11 +452,16 @@ from
422
452
  left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum
423
453
  left join pg_type ty on ty.oid = a.atttypid
424
454
  group by
425
- t.oid, t.schema, t.name, t.persistence, t.row_security, t.force_row_security, t.has_indexes, t.has_rules, t.has_triggers, t.has_subclasses, t.is_populated, t.replica_identity, t.is_partition, t.options, t.partition_bound, t.partition_by, t.owner, t.parent_schema, t.parent_name
455
+ t.oid, t.schema, t.name, t.persistence, t.row_security, t.force_row_security, t.has_indexes, t.has_rules, t.has_triggers, t.has_subclasses, t.is_populated, t.replica_identity, t.replica_identity_index, t.is_partition, t.options, t.partition_bound, t.partition_by, t.owner, t.parent_schema, t.parent_name
426
456
  order by
427
457
  t.schema, t.name
428
458
  `);
429
- // Validate and parse each row using the Zod schema
430
- const validatedRows = tableRows.map((row) => tablePropsSchema.parse(row));
459
+ return result.rows.map((row) => tableRowSchema.parse(row));
460
+ },
461
+ });
462
+ const validatedRows = tableRows.map((row) => {
463
+ const filteredConstraints = row.constraints?.filter((c) => c.definition !== null);
464
+ return { ...row, constraints: filteredConstraints };
465
+ });
431
466
  return validatedRows.map((row) => new Table(row));
432
467
  }
@@ -1,6 +1,7 @@
1
1
  import type { Pool } from "pg";
2
2
  import z from "zod";
3
3
  import { BasePgModel } from "../base.model.ts";
4
+ import { type ExtractRetryOptions } from "../extract-with-retry.ts";
4
5
  declare const triggerPropsSchema: z.ZodObject<{
5
6
  schema: z.ZodString;
6
7
  name: z.ZodString;
@@ -97,5 +98,5 @@ export declare class Trigger extends BasePgModel {
97
98
  comment: string | null;
98
99
  };
99
100
  }
100
- export declare function extractTriggers(pool: Pool): Promise<Trigger[]>;
101
+ export declare function extractTriggers(pool: Pool, options?: ExtractRetryOptions): Promise<Trigger[]>;
101
102
  export {};
@@ -1,6 +1,7 @@
1
1
  import { sql } from "@ts-safeql/sql-tag";
2
2
  import z from "zod";
3
3
  import { BasePgModel } from "../base.model.js";
4
+ import { extractWithDefinitionRetry, } from "../extract-with-retry.js";
4
5
  const TriggerEnabledSchema = z.enum([
5
6
  "O", // ORIGIN - trigger fires in "origin" and "local" replica modes
6
7
  "D", // DISABLED - trigger is disabled
@@ -41,6 +42,14 @@ const triggerPropsSchema = z.object({
41
42
  definition: z.string(),
42
43
  comment: z.string().nullable(),
43
44
  });
45
+ // pg_get_triggerdef(oid, pretty) can return NULL when the trigger (its
46
+ // pg_trigger row) is dropped between catalog scan and resolution, or under
47
+ // transient catalog state. An unreadable trigger cannot be diffed, so we
48
+ // accept NULL here and filter the row out at extraction time rather than
49
+ // crashing the whole catalog parse with a ZodError.
50
+ const triggerRowSchema = triggerPropsSchema.extend({
51
+ definition: z.string().nullable(),
52
+ });
44
53
  export class Trigger extends BasePgModel {
45
54
  schema;
46
55
  name;
@@ -139,8 +148,13 @@ export class Trigger extends BasePgModel {
139
148
  };
140
149
  }
141
150
  }
142
- export async function extractTriggers(pool) {
143
- const { rows: triggerRows } = await pool.query(sql `
151
+ export async function extractTriggers(pool, options) {
152
+ const triggerRows = await extractWithDefinitionRetry({
153
+ label: "triggers",
154
+ options,
155
+ hasNullDefinition: (row) => row.definition === null,
156
+ query: async () => {
157
+ const result = await pool.query(sql `
144
158
  with extension_trigger_oids as (
145
159
  select objid
146
160
  from pg_depend d
@@ -245,7 +259,9 @@ export async function extractTriggers(pool) {
245
259
 
246
260
  order by 1, 2
247
261
  `);
248
- // Validate and parse each row using the Zod schema
249
- const validatedRows = triggerRows.map((row) => triggerPropsSchema.parse(row));
262
+ return result.rows.map((row) => triggerRowSchema.parse(row));
263
+ },
264
+ });
265
+ const validatedRows = triggerRows.filter((row) => row.definition !== null);
250
266
  return validatedRows.map((row) => new Trigger(row));
251
267
  }
@@ -20,6 +20,7 @@ export declare const stableId: {
20
20
  defacl(grantor: string, objtype: string, schema: string | null, grantee: string): `defacl:${string}:${string}:${string}:grantee:${string}`;
21
21
  column(schema: string, table: string, column: string): `column:${string}.${string}.${string}`;
22
22
  constraint(schema: string, table: string, constraint: string): `constraint:${string}.${string}.${string}`;
23
+ index(schema: string, table: string, indexName: string): `index:${string}.${string}.${string}`;
23
24
  comment(objectStableId: string): `comment:${string}`;
24
25
  role(role: string): `role:${string}`;
25
26
  type(schema: string, name: string): `type:${string}.${string}`;
@@ -49,6 +49,9 @@ export const stableId = {
49
49
  constraint(schema, table, constraint) {
50
50
  return `constraint:${schema}.${table}.${constraint}`;
51
51
  },
52
+ index(schema, table, indexName) {
53
+ return `index:${schema}.${table}.${indexName}`;
54
+ },
52
55
  comment(objectStableId) {
53
56
  return `comment:${objectStableId}`;
54
57
  },
@@ -2,6 +2,7 @@ import type { Pool } from "pg";
2
2
  import z from "zod";
3
3
  import { BasePgModel, type TableLikeObject } from "../base.model.ts";
4
4
  import { type PrivilegeProps } from "../base.privilege-diff.ts";
5
+ import { type ExtractRetryOptions } from "../extract-with-retry.ts";
5
6
  declare const viewPropsSchema: z.ZodObject<{
6
7
  schema: z.ZodString;
7
8
  name: z.ZodString;
@@ -162,5 +163,5 @@ export declare class View extends BasePgModel implements TableLikeObject {
162
163
  };
163
164
  };
164
165
  }
165
- export declare function extractViews(pool: Pool): Promise<View[]>;
166
+ export declare function extractViews(pool: Pool, options?: ExtractRetryOptions): Promise<View[]>;
166
167
  export {};
@@ -2,6 +2,7 @@ import { sql } from "@ts-safeql/sql-tag";
2
2
  import z from "zod";
3
3
  import { BasePgModel, columnPropsSchema, normalizeColumns, } from "../base.model.js";
4
4
  import { privilegePropsSchema, } from "../base.privilege-diff.js";
5
+ import { extractWithDefinitionRetry, } from "../extract-with-retry.js";
5
6
  import { ReplicaIdentitySchema } from "../table/table.model.js";
6
7
  const viewPropsSchema = z.object({
7
8
  schema: z.string(),
@@ -23,6 +24,14 @@ const viewPropsSchema = z.object({
23
24
  columns: z.array(columnPropsSchema),
24
25
  privileges: z.array(privilegePropsSchema),
25
26
  });
27
+ // pg_get_viewdef(oid) can return NULL when the underlying view (or its
28
+ // pg_rewrite row) is dropped between catalog scan and resolution, or under
29
+ // transient catalog state during recovery. An unreadable view cannot be
30
+ // diffed, so we accept NULL here and filter the row out at extraction time
31
+ // rather than crashing the whole catalog parse with a ZodError.
32
+ const viewRowSchema = viewPropsSchema.extend({
33
+ definition: z.string().nullable(),
34
+ });
26
35
  export class View extends BasePgModel {
27
36
  schema;
28
37
  name;
@@ -104,8 +113,13 @@ export class View extends BasePgModel {
104
113
  };
105
114
  }
106
115
  }
107
- export async function extractViews(pool) {
108
- const { rows: viewRows } = await pool.query(sql `
116
+ export async function extractViews(pool, options) {
117
+ const viewRows = await extractWithDefinitionRetry({
118
+ label: "views",
119
+ options,
120
+ hasNullDefinition: (row) => row.definition === null,
121
+ query: async () => {
122
+ const result = await pool.query(sql `
109
123
  with extension_oids as (
110
124
  select
111
125
  objid
@@ -232,7 +246,9 @@ group by
232
246
  order by
233
247
  v.schema, v.name
234
248
  `);
235
- // Validate and parse each row using the Zod schema
236
- const validatedRows = viewRows.map((row) => viewPropsSchema.parse(row));
249
+ return result.rows.map((row) => viewRowSchema.parse(row));
250
+ },
251
+ });
252
+ const validatedRows = viewRows.filter((row) => row.definition !== null);
237
253
  return validatedRows.map((row) => new View(row));
238
254
  }
@@ -62,7 +62,9 @@ export async function createPlan(source, target, options = {}) {
62
62
  }
63
63
  const resolved = await resolvePool(input, label);
64
64
  pools.push(resolved);
65
- return extractCatalog(resolved.pool);
65
+ return extractCatalog(resolved.pool, {
66
+ extractRetries: options.extractRetries,
67
+ });
66
68
  };
67
69
  const pools = [];
68
70
  try {
@@ -141,5 +141,13 @@ export interface CreatePlanOptions {
141
141
  * the output must be self-contained and not rely on statement execution order.
142
142
  */
143
143
  skipDefaultPrivilegeSubtraction?: boolean;
144
+ /**
145
+ * Number of retry attempts for catalog extractors when `pg_get_*def()`
146
+ * returns NULL for at least one row (a transient race with concurrent DDL).
147
+ * Total attempts is `extractRetries + 1`. When undefined, the value is read
148
+ * from the `PGDELTA_EXTRACT_RETRIES` environment variable, falling back to
149
+ * a default of 1 (i.e. the first attempt plus one retry, 2 attempts total).
150
+ */
151
+ extractRetries?: number;
144
152
  }
145
153
  export {};
@@ -1,4 +1,5 @@
1
1
  import type { Change } from "./change.types.ts";
2
+ import type { Table } from "./objects/table/table.model.ts";
2
3
  /**
3
4
  * Apply structural rewrites to the change list that are only obvious once
4
5
  * every object diff has been collected. This pass does NOT prevent dependency
@@ -19,11 +20,17 @@ import type { Change } from "./change.types.ts";
19
20
  * produced when `diffTables()` and `expandReplaceDependencies()` both
20
21
  * emit the same constraint operation for a replaced table. Last write
21
22
  * wins so the expansion's emission survives.
23
+ * - Re-emits `ALTER TABLE ... REPLICA IDENTITY USING INDEX <idx>` after any
24
+ * `DropIndex(idx) + CreateIndex(idx)` pair where `idx` is the replica
25
+ * identity index of a branch table — Postgres silently clears the marker
26
+ * when the underlying index is dropped, and `CREATE INDEX` cannot restore
27
+ * it.
22
28
  *
23
29
  * Object-local PostgreSQL semantics (for example owned-sequence cascades)
24
30
  * stay in the corresponding `diff*` function instead of this pass.
25
31
  */
26
- export declare function normalizePostDiffCycles({ changes, replacedTableIds, }: {
32
+ export declare function normalizePostDiffChanges({ changes, replacedTableIds, branchTables, }: {
27
33
  changes: Change[];
28
34
  replacedTableIds?: ReadonlySet<string>;
35
+ branchTables?: Record<string, Table>;
29
36
  }): Change[];
@@ -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
+ }
@@ -245,7 +245,7 @@ function tryBreakPublicationColumnCycle(cycleNodeIndexes, phaseChanges) {
245
245
  return null;
246
246
  // Verify the table is NOT itself being dropped. If `DropTable(T)` is in
247
247
  // the same phase, the existing structural rewrites in
248
- // `post-diff-cycle-breaking.ts` (replace-expansion superseded filter)
248
+ // `post-diff-normalization.ts` (replace-expansion superseded filter)
249
249
  // already prune the redundant `AlterTableDropColumn`, so we should not
250
250
  // see this combination here. Be defensive and bail anyway — flipping
251
251
  // `omitTableRequirement` when T is being dropped would let the column
@@ -19,5 +19,15 @@ export declare function isMetadataStableId(stableId: string): boolean;
19
19
  * - ALTER operations with scope="privilege" → create_alter_object phase (metadata changes)
20
20
  * - ALTER operations that drop actual objects → drop phase (destructive ALTER)
21
21
  * - ALTER operations that don't drop objects → create_alter_object phase (non-destructive ALTER)
22
+ *
23
+ * Dependency-breaking ALTERs that remove a `pg_depend` edge to another
24
+ * object that may be dropped in the same plan (for example
25
+ * `ALTER COLUMN ... DROP DEFAULT` releasing a sequence reference, or
26
+ * `ALTER COLUMN ... TYPE <built-in>` releasing a user-defined type
27
+ * reference) are routed to the drop phase. The drop phase sorts in reverse
28
+ * dependency order using the main catalog, so the catalog edges already
29
+ * in `pg_depend` order the ALTER before any dependent `DROP TYPE` /
30
+ * `DROP SEQUENCE` / `DROP FUNCTION` and PostgreSQL no longer rejects the
31
+ * drop with error 2BP01.
22
32
  */
23
33
  export declare function getExecutionPhase(change: Change): Phase;