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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/core/catalog.diff.js +4 -4
  2. package/dist/core/catalog.model.d.ts +8 -1
  3. package/dist/core/catalog.model.js +9 -8
  4. package/dist/core/expand-replace-dependencies.js +23 -0
  5. package/dist/core/objects/extract-with-retry.d.ts +36 -0
  6. package/dist/core/objects/extract-with-retry.js +51 -0
  7. package/dist/core/objects/index/index.diff.js +0 -1
  8. package/dist/core/objects/index/index.model.d.ts +2 -3
  9. package/dist/core/objects/index/index.model.js +17 -6
  10. package/dist/core/objects/materialized-view/materialized-view.model.d.ts +2 -1
  11. package/dist/core/objects/materialized-view/materialized-view.model.js +20 -4
  12. package/dist/core/objects/procedure/procedure.model.d.ts +2 -1
  13. package/dist/core/objects/procedure/procedure.model.js +20 -4
  14. package/dist/core/objects/publication/changes/publication.alter.d.ts +1 -1
  15. package/dist/core/objects/rls-policy/rls-policy.diff.js +13 -1
  16. package/dist/core/objects/rule/rule.model.d.ts +2 -1
  17. package/dist/core/objects/rule/rule.model.js +20 -3
  18. package/dist/core/objects/sequence/sequence.diff.d.ts +2 -1
  19. package/dist/core/objects/sequence/sequence.diff.js +41 -9
  20. package/dist/core/objects/table/changes/table.alter.d.ts +16 -1
  21. package/dist/core/objects/table/changes/table.alter.js +39 -6
  22. package/dist/core/objects/table/table.diff.js +40 -17
  23. package/dist/core/objects/table/table.model.d.ts +6 -1
  24. package/dist/core/objects/table/table.model.js +50 -12
  25. package/dist/core/objects/trigger/trigger.model.d.ts +2 -1
  26. package/dist/core/objects/trigger/trigger.model.js +20 -4
  27. package/dist/core/objects/utils.d.ts +1 -0
  28. package/dist/core/objects/utils.js +3 -0
  29. package/dist/core/objects/view/view.model.d.ts +2 -1
  30. package/dist/core/objects/view/view.model.js +20 -4
  31. package/dist/core/plan/create.js +3 -1
  32. package/dist/core/plan/types.d.ts +8 -0
  33. package/dist/core/post-diff-normalization.d.ts +36 -0
  34. package/dist/core/post-diff-normalization.js +202 -0
  35. package/dist/core/sort/cycle-breakers.d.ts +15 -0
  36. package/dist/core/sort/cycle-breakers.js +269 -0
  37. package/dist/core/sort/sort-changes.js +97 -43
  38. package/dist/core/sort/utils.d.ts +10 -0
  39. package/dist/core/sort/utils.js +28 -0
  40. package/package.json +1 -1
  41. package/src/core/catalog.diff.ts +4 -3
  42. package/src/core/catalog.model.ts +20 -8
  43. package/src/core/expand-replace-dependencies.test.ts +139 -5
  44. package/src/core/expand-replace-dependencies.ts +24 -0
  45. package/src/core/objects/extract-with-retry.test.ts +143 -0
  46. package/src/core/objects/extract-with-retry.ts +87 -0
  47. package/src/core/objects/index/index.diff.ts +0 -1
  48. package/src/core/objects/index/index.model.test.ts +37 -1
  49. package/src/core/objects/index/index.model.ts +25 -6
  50. package/src/core/objects/materialized-view/materialized-view.model.test.ts +93 -0
  51. package/src/core/objects/materialized-view/materialized-view.model.ts +27 -4
  52. package/src/core/objects/procedure/procedure.model.test.ts +117 -0
  53. package/src/core/objects/procedure/procedure.model.ts +28 -5
  54. package/src/core/objects/publication/changes/publication.alter.ts +1 -1
  55. package/src/core/objects/rls-policy/rls-policy.diff.ts +19 -1
  56. package/src/core/objects/rule/rule.model.test.ts +99 -0
  57. package/src/core/objects/rule/rule.model.ts +28 -4
  58. package/src/core/objects/sequence/sequence.diff.test.ts +93 -1
  59. package/src/core/objects/sequence/sequence.diff.ts +43 -10
  60. package/src/core/objects/table/changes/table.alter.test.ts +26 -23
  61. package/src/core/objects/table/changes/table.alter.ts +66 -10
  62. package/src/core/objects/table/table.diff.test.ts +43 -0
  63. package/src/core/objects/table/table.diff.ts +52 -23
  64. package/src/core/objects/table/table.model.test.ts +209 -0
  65. package/src/core/objects/table/table.model.ts +62 -14
  66. package/src/core/objects/trigger/trigger.model.test.ts +113 -0
  67. package/src/core/objects/trigger/trigger.model.ts +28 -5
  68. package/src/core/objects/utils.ts +3 -0
  69. package/src/core/objects/view/view.model.test.ts +90 -0
  70. package/src/core/objects/view/view.model.ts +28 -5
  71. package/src/core/plan/create.ts +3 -1
  72. package/src/core/plan/types.ts +8 -0
  73. package/src/core/{post-diff-cycle-breaking.test.ts → post-diff-normalization.test.ts} +168 -160
  74. package/src/core/post-diff-normalization.ts +260 -0
  75. package/src/core/sort/cycle-breakers.test.ts +476 -0
  76. package/src/core/sort/cycle-breakers.ts +311 -0
  77. package/src/core/sort/sort-changes.ts +135 -50
  78. package/src/core/sort/utils.ts +38 -0
  79. package/dist/core/post-diff-cycle-breaking.d.ts +0 -29
  80. package/dist/core/post-diff-cycle-breaking.js +0 -209
  81. package/src/core/post-diff-cycle-breaking.ts +0 -317
@@ -216,16 +216,27 @@ export declare class AlterTableValidateConstraint extends AlterTableChange {
216
216
  }
217
217
  /**
218
218
  * ALTER TABLE ... REPLICA IDENTITY ...
219
+ *
220
+ * When `mode === "i"` (USING INDEX), `indexName` is the name of the index to
221
+ * use. The extractor populates `Table.replica_identity_index` from
222
+ * `pg_index.indisreplident` whenever `Table.replica_identity` is `'i'`, so
223
+ * callers that source their props from a `Table` instance can rely on the
224
+ * pair being consistent. The non-null assertions in `requires` / `serialize`
225
+ * below are justified by that data invariant — the same pattern the FK
226
+ * branch of `AlterTableAddConstraint` uses for `foreign_key_columns!` /
227
+ * `foreign_key_table!` / `foreign_key_schema!`.
219
228
  */
220
229
  export declare class AlterTableSetReplicaIdentity extends AlterTableChange {
221
230
  readonly table: Table;
222
231
  readonly mode: "d" | "n" | "f" | "i";
232
+ readonly indexName: string | null;
223
233
  readonly scope: "object";
224
234
  constructor(props: {
225
235
  table: Table;
226
236
  mode: "d" | "n" | "f" | "i";
237
+ indexName?: string | null;
227
238
  });
228
- get requires(): `table:${string}`[];
239
+ get requires(): string[];
229
240
  serialize(_options?: SerializeOptions): string;
230
241
  }
231
242
  /**
@@ -250,9 +261,11 @@ export declare class AlterTableDropColumn extends AlterTableChange {
250
261
  readonly table: Table;
251
262
  readonly column: ColumnProps;
252
263
  readonly scope: "object";
264
+ readonly omitTableRequirement: boolean;
253
265
  constructor(props: {
254
266
  table: Table;
255
267
  column: ColumnProps;
268
+ omitTableRequirement?: boolean;
256
269
  });
257
270
  get drops(): `column:${string}.${string}.${string}`[];
258
271
  get requires(): (`column:${string}.${string}.${string}` | `table:${string}`)[];
@@ -264,10 +277,12 @@ export declare class AlterTableDropColumn extends AlterTableChange {
264
277
  export declare class AlterTableAlterColumnType extends AlterTableChange {
265
278
  readonly table: Table;
266
279
  readonly column: ColumnProps;
280
+ readonly previousColumn?: ColumnProps;
267
281
  readonly scope: "object";
268
282
  constructor(props: {
269
283
  table: Table;
270
284
  column: ColumnProps;
285
+ previousColumn?: ColumnProps;
271
286
  });
272
287
  get requires(): `column:${string}.${string}.${string}`[];
273
288
  serialize(_options?: SerializeOptions): string;
@@ -302,18 +302,35 @@ export class AlterTableValidateConstraint extends AlterTableChange {
302
302
  }
303
303
  /**
304
304
  * ALTER TABLE ... REPLICA IDENTITY ...
305
+ *
306
+ * When `mode === "i"` (USING INDEX), `indexName` is the name of the index to
307
+ * use. The extractor populates `Table.replica_identity_index` from
308
+ * `pg_index.indisreplident` whenever `Table.replica_identity` is `'i'`, so
309
+ * callers that source their props from a `Table` instance can rely on the
310
+ * pair being consistent. The non-null assertions in `requires` / `serialize`
311
+ * below are justified by that data invariant — the same pattern the FK
312
+ * branch of `AlterTableAddConstraint` uses for `foreign_key_columns!` /
313
+ * `foreign_key_table!` / `foreign_key_schema!`.
305
314
  */
306
315
  export class AlterTableSetReplicaIdentity extends AlterTableChange {
307
316
  table;
308
317
  mode;
318
+ indexName;
309
319
  scope = "object";
310
320
  constructor(props) {
311
321
  super();
312
322
  this.table = props.table;
313
323
  this.mode = props.mode;
324
+ this.indexName = props.indexName ?? null;
314
325
  }
315
326
  get requires() {
316
- return [this.table.stableId];
327
+ const reqs = [this.table.stableId];
328
+ if (this.mode === "i") {
329
+ reqs.push(stableId.index(this.table.schema, this.table.name,
330
+ // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
331
+ this.indexName));
332
+ }
333
+ return reqs;
317
334
  }
318
335
  serialize(_options) {
319
336
  const clause = this.mode === "d"
@@ -322,7 +339,8 @@ export class AlterTableSetReplicaIdentity extends AlterTableChange {
322
339
  ? "NOTHING"
323
340
  : this.mode === "f"
324
341
  ? "FULL"
325
- : "DEFAULT"; // 'i' (USING INDEX) is handled via index changes; fallback to DEFAULT
342
+ : // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
343
+ `USING INDEX ${this.indexName}`;
326
344
  return [
327
345
  "ALTER TABLE",
328
346
  `${this.table.schema}.${this.table.name}`,
@@ -386,10 +404,16 @@ export class AlterTableDropColumn extends AlterTableChange {
386
404
  table;
387
405
  column;
388
406
  scope = "object";
407
+ // Drop the implicit `requires(table)` edge. Only set by the lazy
408
+ // cycle-breaker for the publication↔column case, where the table survives
409
+ // the migration and the edge is therefore artificial. See
410
+ // `sort/cycle-breakers.ts` for the full justification.
411
+ omitTableRequirement;
389
412
  constructor(props) {
390
413
  super();
391
414
  this.table = props.table;
392
415
  this.column = props.column;
416
+ this.omitTableRequirement = props.omitTableRequirement ?? false;
393
417
  }
394
418
  get drops() {
395
419
  return [
@@ -397,10 +421,8 @@ export class AlterTableDropColumn extends AlterTableChange {
397
421
  ];
398
422
  }
399
423
  get requires() {
400
- return [
401
- this.table.stableId,
402
- stableId.column(this.table.schema, this.table.name, this.column.name),
403
- ];
424
+ const colId = stableId.column(this.table.schema, this.table.name, this.column.name);
425
+ return this.omitTableRequirement ? [colId] : [this.table.stableId, colId];
404
426
  }
405
427
  serialize(_options) {
406
428
  return [
@@ -417,11 +439,13 @@ export class AlterTableDropColumn extends AlterTableChange {
417
439
  export class AlterTableAlterColumnType extends AlterTableChange {
418
440
  table;
419
441
  column;
442
+ previousColumn;
420
443
  scope = "object";
421
444
  constructor(props) {
422
445
  super();
423
446
  this.table = props.table;
424
447
  this.column = props.column;
448
+ this.previousColumn = props.previousColumn;
425
449
  }
426
450
  get requires() {
427
451
  return [
@@ -429,6 +453,12 @@ export class AlterTableAlterColumnType extends AlterTableChange {
429
453
  ];
430
454
  }
431
455
  serialize(_options) {
456
+ // previousColumn is optional so direct serializer tests/fixtures can keep
457
+ // emitting canonical ALTER TYPE SQL without forcing a USING expression.
458
+ // When provided, we can detect true type changes and add USING for casts
459
+ // PostgreSQL cannot perform automatically.
460
+ const hasTypeChangedWithPreviousDefinition = this.previousColumn?.data_type_str !== undefined &&
461
+ this.previousColumn.data_type_str !== this.column.data_type_str;
432
462
  const parts = [
433
463
  "ALTER TABLE",
434
464
  `${this.table.schema}.${this.table.name}`,
@@ -440,6 +470,9 @@ export class AlterTableAlterColumnType extends AlterTableChange {
440
470
  if (this.column.collation) {
441
471
  parts.push("COLLATE", this.column.collation);
442
472
  }
473
+ if (hasTypeChangedWithPreviousDefinition) {
474
+ parts.push("USING", `${this.column.name}::${this.column.data_type_str}`);
475
+ }
443
476
  return parts.join(" ");
444
477
  }
445
478
  }
@@ -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) {
@@ -451,15 +455,30 @@ export function diffTables(ctx, main, branch) {
451
455
  const branchCol = branchCols.get(name);
452
456
  if (!branchCol)
453
457
  continue;
458
+ const columnTypeChanged = mainCol.data_type_str !== branchCol.data_type_str;
459
+ const columnCollationChanged = mainCol.collation !== branchCol.collation;
460
+ const needsDefaultSafeFlow = columnTypeChanged && mainCol.default !== null;
454
461
  // TYPE or COLLATION change
455
- if (mainCol.data_type_str !== branchCol.data_type_str ||
456
- mainCol.collation !== branchCol.collation) {
462
+ if (columnTypeChanged || columnCollationChanged) {
457
463
  // Skip if parent has the same type/collation change
458
464
  if (!parentHasSameColumnPropertyChange(name, "type")) {
465
+ if (needsDefaultSafeFlow) {
466
+ changes.push(new AlterTableAlterColumnDropDefault({
467
+ table: branchTable,
468
+ column: branchCol,
469
+ }));
470
+ }
459
471
  changes.push(new AlterTableAlterColumnType({
460
472
  table: branchTable,
461
473
  column: branchCol,
474
+ previousColumn: mainCol,
462
475
  }));
476
+ if (needsDefaultSafeFlow && branchCol.default !== null) {
477
+ changes.push(new AlterTableAlterColumnSetDefault({
478
+ table: branchTable,
479
+ column: branchCol,
480
+ }));
481
+ }
463
482
  }
464
483
  }
465
484
  // PostgreSQL rejects SET DEFAULT while the column still has identity metadata,
@@ -476,6 +495,10 @@ export function diffTables(ctx, main, branch) {
476
495
  if (mainCol.default !== branchCol.default) {
477
496
  // Skip if parent has the same default change
478
497
  if (!parentHasSameColumnPropertyChange(name, "default")) {
498
+ if (needsDefaultSafeFlow) {
499
+ // Defaults were already dropped/re-set in the type-change flow above.
500
+ continue;
501
+ }
479
502
  if (branchCol.default === null) {
480
503
  // Drop default value
481
504
  changes.push(new AlterTableAlterColumnDropDefault({
@@ -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,
@@ -265,13 +295,16 @@ select
265
295
 
266
296
  'key_columns',
267
297
  case
268
- when c.conkey is not null then (
269
- select json_agg(quote_ident(att.attname) order by pk.ordinality)
270
- from unnest(c.conkey) with ordinality as pk(attnum, ordinality)
271
- join pg_attribute att
272
- on att.attrelid = c.conrelid
273
- and att.attnum = pk.attnum
274
- and att.attisdropped = false
298
+ when c.conkey is not null then coalesce(
299
+ (
300
+ select json_agg(quote_ident(att.attname) order by pk.ordinality)
301
+ from unnest(c.conkey) with ordinality as pk(attnum, ordinality)
302
+ join pg_attribute att
303
+ on att.attrelid = c.conrelid
304
+ and att.attnum = pk.attnum
305
+ and att.attisdropped = false
306
+ ),
307
+ '[]'::json
275
308
  )
276
309
  else '[]'::json
277
310
  end,
@@ -419,11 +452,16 @@ from
419
452
  left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum
420
453
  left join pg_type ty on ty.oid = a.atttypid
421
454
  group by
422
- 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
423
456
  order by
424
457
  t.schema, t.name
425
458
  `);
426
- // Validate and parse each row using the Zod schema
427
- 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
+ });
428
466
  return validatedRows.map((row) => new Table(row));
429
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 {};
@@ -0,0 +1,36 @@
1
+ import type { Change } from "./change.types.ts";
2
+ import type { Table } from "./objects/table/table.model.ts";
3
+ /**
4
+ * Apply structural rewrites to the change list that are only obvious once
5
+ * every object diff has been collected. This pass does NOT prevent dependency
6
+ * cycles — that responsibility now lives in the sort phase, where
7
+ * `sortPhaseChanges` invokes `tryBreakCycleByChangeInjection` lazily on cycles
8
+ * that edge filtering can't break (FK SCC of dropped tables,
9
+ * AlterPublicationDropTables ↔ AlterTableDropColumn, …).
10
+ *
11
+ * Concretely, this pass:
12
+ *
13
+ * - Prunes `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)`
14
+ * changes that are made redundant by an expansion-emitted
15
+ * `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
16
+ * would try to drop a column that no longer exists in the freshly
17
+ * recreated table.
18
+ * - Dedupes duplicate `AlterTableAddConstraint` /
19
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
20
+ * produced when `diffTables()` and `expandReplaceDependencies()` both
21
+ * emit the same constraint operation for a replaced table. Last write
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.
28
+ *
29
+ * Object-local PostgreSQL semantics (for example owned-sequence cascades)
30
+ * stay in the corresponding `diff*` function instead of this pass.
31
+ */
32
+ export declare function normalizePostDiffChanges({ changes, replacedTableIds, branchTables, }: {
33
+ changes: Change[];
34
+ replacedTableIds?: ReadonlySet<string>;
35
+ branchTables?: Record<string, Table>;
36
+ }): Change[];