@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
@@ -109,15 +109,20 @@ describe.concurrent("sequence.diff", () => {
109
109
  });
110
110
 
111
111
  test("replacing an owned sequence re-emits the owning column default", () => {
112
+ // Use `persistence` (UNLOGGED → LOGGED) to trigger the
113
+ // non-alterable replace path: it's the only field still in
114
+ // NON_ALTERABLE_FIELDS. `data_type` was previously in that list
115
+ // but is now alterable in place via ALTER SEQUENCE ... AS <type>.
112
116
  const main = new Sequence({
113
117
  ...base,
114
- data_type: "integer",
118
+ persistence: "u",
115
119
  owned_by_schema: "public",
116
120
  owned_by_table: "users",
117
121
  owned_by_column: "id",
118
122
  });
119
123
  const branch = new Sequence({
120
124
  ...base,
125
+ persistence: "p",
121
126
  owned_by_schema: "public",
122
127
  owned_by_table: "users",
123
128
  owned_by_column: "id",
@@ -322,6 +327,93 @@ describe.concurrent("sequence.diff", () => {
322
327
  expect(changes).toHaveLength(0);
323
328
  });
324
329
 
330
+ test("recreate same-name sequence when owning table is renamed away", () => {
331
+ // Reproduces issue #228 case 1: a SERIAL column's table is renamed
332
+ // (`old_table` → `new_table`). The sequence keeps the same name
333
+ // (`old_table_id_seq`) but its OWNED BY now points at `new_table.id`.
334
+ // PostgreSQL cascade-drops the sequence with the old table, so a later
335
+ // CREATE TABLE that references `old_table_id_seq` fails. The diff must
336
+ // emit CreateSequence (and skip the explicit DropSequence to avoid an
337
+ // unbreakable cycle with the DropTable).
338
+ const tableColumn = {
339
+ name: "id",
340
+ position: 1,
341
+ data_type: "integer",
342
+ data_type_str: "integer",
343
+ is_custom_type: false,
344
+ custom_type_type: null,
345
+ custom_type_category: null,
346
+ custom_type_schema: null,
347
+ custom_type_name: null,
348
+ not_null: true,
349
+ is_identity: false,
350
+ is_identity_always: false,
351
+ is_generated: false,
352
+ collation: null,
353
+ default: "nextval('public.old_table_id_seq'::regclass)",
354
+ comment: null,
355
+ };
356
+ const tableBaseProps = {
357
+ schema: "public",
358
+ persistence: "p" as const,
359
+ row_security: false,
360
+ force_row_security: false,
361
+ has_indexes: false,
362
+ has_rules: false,
363
+ has_triggers: false,
364
+ has_subclasses: false,
365
+ is_populated: true,
366
+ replica_identity: "d" as const,
367
+ is_partition: false,
368
+ options: null,
369
+ partition_bound: null,
370
+ partition_by: null,
371
+ owner: "test",
372
+ comment: null,
373
+ parent_schema: null,
374
+ parent_name: null,
375
+ privileges: [],
376
+ };
377
+ const oldTable = new Table({
378
+ ...tableBaseProps,
379
+ name: "old_table",
380
+ columns: [tableColumn],
381
+ });
382
+ const newTable = new Table({
383
+ ...tableBaseProps,
384
+ name: "new_table",
385
+ columns: [tableColumn],
386
+ });
387
+ const mainSequence = new Sequence({
388
+ ...base,
389
+ name: "old_table_id_seq",
390
+ owned_by_schema: "public",
391
+ owned_by_table: "old_table",
392
+ owned_by_column: "id",
393
+ });
394
+ const branchSequence = new Sequence({
395
+ ...base,
396
+ name: "old_table_id_seq",
397
+ owned_by_schema: "public",
398
+ owned_by_table: "new_table",
399
+ owned_by_column: "id",
400
+ });
401
+
402
+ const changes = diffSequences(
403
+ testContext,
404
+ { [mainSequence.stableId]: mainSequence },
405
+ { [branchSequence.stableId]: branchSequence },
406
+ { [newTable.stableId]: newTable },
407
+ { [oldTable.stableId]: oldTable },
408
+ );
409
+
410
+ expect(changes.some((c) => c instanceof DropSequence)).toBe(false);
411
+ expect(changes.some((c) => c instanceof CreateSequence)).toBe(true);
412
+ expect(changes.some((c) => c instanceof AlterSequenceSetOwnedBy)).toBe(
413
+ true,
414
+ );
415
+ });
416
+
325
417
  test("create with comment emits CreateCommentOnSequence", () => {
326
418
  const s = new Sequence({ ...base, comment: "my seq" });
327
419
  const changes = diffSequences(testContext, {}, { [s.stableId]: s });
@@ -36,6 +36,7 @@ type SequenceOrColumnSetDefaultChange =
36
36
  * @param main - The sequences in the main catalog.
37
37
  * @param branch - The sequences in the branch catalog.
38
38
  * @param branchTables - The tables in the branch catalog (used to check if owning tables are being dropped).
39
+ * @param mainTables - The tables in the main catalog (used to detect when a same-name sequence will be cascade-dropped because its main-side owning table is going away).
39
40
  * @returns A list of changes to apply to main to make it match branch.
40
41
  */
41
42
  export function diffSequences(
@@ -46,6 +47,7 @@ export function diffSequences(
46
47
  main: Record<string, Sequence>,
47
48
  branch: Record<string, Sequence>,
48
49
  branchTables: Record<string, Table> = {},
50
+ mainTables: Record<string, Table> = {},
49
51
  ): SequenceOrColumnSetDefaultChange[] {
50
52
  const { created, dropped, altered } = diffObjects(main, branch);
51
53
 
@@ -150,22 +152,42 @@ export function diffSequences(
150
152
 
151
153
  // Check if non-alterable properties have changed
152
154
  // These require dropping and recreating the sequence
153
- const NON_ALTERABLE_FIELDS: Array<keyof Sequence> = [
154
- "data_type",
155
- "persistence",
156
- ];
155
+ const NON_ALTERABLE_FIELDS: Array<keyof Sequence> = ["persistence"];
157
156
  const nonAlterablePropsChanged = hasNonAlterableChanges(
158
157
  mainSequence,
159
158
  branchSequence,
160
159
  NON_ALTERABLE_FIELDS,
161
160
  );
162
161
 
163
- if (nonAlterablePropsChanged) {
164
- // Replace the entire sequence (drop + create)
165
- changes.push(
166
- new DropSequence({ sequence: mainSequence }),
167
- new CreateSequence({ sequence: branchSequence }),
168
- );
162
+ // A sequence kept the same name (so it's "altered" in catalog terms),
163
+ // but its main-side owning table is going away from the plan (renamed
164
+ // away or simply dropped). PostgreSQL will cascade-drop the sequence
165
+ // alongside the table, leaving any later CREATE TABLE / column-default
166
+ // that depends on the sequence name pointing at nothing. Treat this
167
+ // like a non-alterable change so we recreate the sequence after the
168
+ // owning table is dropped.
169
+ const mainOwnedByTableId =
170
+ mainSequence.owned_by_schema && mainSequence.owned_by_table
171
+ ? `table:${mainSequence.owned_by_schema}.${mainSequence.owned_by_table}`
172
+ : null;
173
+ const cascadeOrphanedByOwningTable =
174
+ mainOwnedByTableId !== null &&
175
+ mainTables[mainOwnedByTableId] !== undefined &&
176
+ branchTables[mainOwnedByTableId] === undefined;
177
+
178
+ if (nonAlterablePropsChanged || cascadeOrphanedByOwningTable) {
179
+ // When the owning table is going away in this plan, PostgreSQL will
180
+ // cascade-drop the sequence as part of the DROP TABLE. Emitting an
181
+ // explicit DROP SEQUENCE here would (a) introduce an unbreakable
182
+ // DropSequence ↔ DropTable cycle on the catalog edges between the
183
+ // sequence and the dropped column, and (b) be redundant with the
184
+ // cascade. The CreateSequence below restores the sequence under its
185
+ // original name so any same-name reference in a later CREATE TABLE
186
+ // resolves correctly.
187
+ if (!cascadeOrphanedByOwningTable) {
188
+ changes.push(new DropSequence({ sequence: mainSequence }));
189
+ }
190
+ changes.push(new CreateSequence({ sequence: branchSequence }));
169
191
  // Re-apply OWNED BY if present on branch
170
192
  if (
171
193
  branchSequence.owned_by_schema !== null &&
@@ -215,6 +237,7 @@ export function diffSequences(
215
237
  } else {
216
238
  // Only alterable properties changed - emit ALTER for options/owner
217
239
  const optionsChanged =
240
+ mainSequence.data_type !== branchSequence.data_type ||
218
241
  mainSequence.increment !== branchSequence.increment ||
219
242
  mainSequence.minimum_value !== branchSequence.minimum_value ||
220
243
  mainSequence.maximum_value !== branchSequence.maximum_value ||
@@ -224,6 +247,16 @@ export function diffSequences(
224
247
 
225
248
  if (optionsChanged) {
226
249
  const options: string[] = [];
250
+ // `AS <type>` must come before any MIN/MAX/RESTART clauses per the
251
+ // PG ALTER SEQUENCE grammar. Valid types are smallint, integer,
252
+ // bigint — the same set CREATE SEQUENCE accepts — so the universe
253
+ // of legal transitions is closed. PG enforces last_value range at
254
+ // apply time when shrinking; that's the desired behavior because
255
+ // the previous Drop+Create path silently reset last_value to 1
256
+ // (data-loss bug, see Sentry SUPABASE-API-7RS).
257
+ if (mainSequence.data_type !== branchSequence.data_type) {
258
+ options.push("AS", branchSequence.data_type);
259
+ }
227
260
  if (mainSequence.increment !== branchSequence.increment) {
228
261
  options.push("INCREMENT BY", String(branchSequence.increment));
229
262
  }
@@ -343,7 +343,7 @@ describe.concurrent("table", () => {
343
343
  ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY FULL");
344
344
  });
345
345
 
346
- test("replica identity DEFAULT and INDEX fallback", async () => {
346
+ test("replica identity DEFAULT and USING INDEX", async () => {
347
347
  const baseProps: Omit<
348
348
  TableProps,
349
349
  "owner" | "options" | "replica_identity"
@@ -372,31 +372,23 @@ describe.concurrent("table", () => {
372
372
  options: null,
373
373
  replica_identity: "n",
374
374
  });
375
- const toDefault = new Table({
376
- ...baseProps,
377
- owner: "o1",
378
- options: null,
379
- replica_identity: "d",
380
- });
381
- const toIndex = new Table({
382
- ...baseProps,
383
- owner: "o1",
384
- options: null,
385
- replica_identity: "i",
386
- });
387
- expect(
388
- new AlterTableSetReplicaIdentity({
389
- table,
390
- mode: toDefault.replica_identity,
391
- }).serialize(),
392
- ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY DEFAULT");
393
- // AlterTableSetReplicaIdentity of type "i" will not be emitted in diff, it is handled by index changes, we fallback to DEFAULT here
394
375
  expect(
395
376
  new AlterTableSetReplicaIdentity({
396
377
  table,
397
- mode: toIndex.replica_identity,
378
+ mode: "d",
398
379
  }).serialize(),
399
380
  ).toBe("ALTER TABLE public.test_table REPLICA IDENTITY DEFAULT");
381
+ const usingIndex = new AlterTableSetReplicaIdentity({
382
+ table,
383
+ mode: "i",
384
+ indexName: "test_table_pkey",
385
+ });
386
+ expect(usingIndex.serialize()).toBe(
387
+ "ALTER TABLE public.test_table REPLICA IDENTITY USING INDEX test_table_pkey",
388
+ );
389
+ expect(usingIndex.requires).toContain(
390
+ "index:public.test_table.test_table_pkey",
391
+ );
400
392
  });
401
393
 
402
394
  test("columns add/drop/alter", async () => {
@@ -444,6 +436,11 @@ describe.concurrent("table", () => {
444
436
  data_type: "text",
445
437
  data_type_str: "text",
446
438
  };
439
+ const colTextBefore: ColumnProps = {
440
+ ...colText,
441
+ data_type: "integer",
442
+ data_type_str: "integer",
443
+ };
447
444
  const withCols = new Table({
448
445
  ...tableProps,
449
446
  owner: "o1",
@@ -477,10 +474,11 @@ describe.concurrent("table", () => {
477
474
  const changeType = new AlterTableAlterColumnType({
478
475
  table: withCols,
479
476
  column: colText,
477
+ previousColumn: colTextBefore,
480
478
  });
481
479
  await assertValidSql(changeType.serialize());
482
480
  expect(changeType.serialize()).toBe(
483
- "ALTER TABLE public.test_table ALTER COLUMN b TYPE text",
481
+ "ALTER TABLE public.test_table ALTER COLUMN b TYPE text USING b::text",
484
482
  );
485
483
 
486
484
  const changeSetDefault = new AlterTableAlterColumnSetDefault({
@@ -659,10 +657,15 @@ describe.concurrent("table", () => {
659
657
  const change = new AlterTableAlterColumnType({
660
658
  table: withCols,
661
659
  column: col,
660
+ previousColumn: {
661
+ ...col,
662
+ data_type: "integer",
663
+ data_type_str: "integer",
664
+ },
662
665
  });
663
666
  await assertValidSql(change.serialize());
664
667
  expect(change.serialize()).toBe(
665
- "ALTER TABLE public.test_table ALTER COLUMN b TYPE text COLLATE mycoll",
668
+ "ALTER TABLE public.test_table ALTER COLUMN b TYPE text COLLATE mycoll USING b::text",
666
669
  );
667
670
  });
668
671
 
@@ -462,20 +462,46 @@ export class AlterTableValidateConstraint extends AlterTableChange {
462
462
 
463
463
  /**
464
464
  * ALTER TABLE ... REPLICA IDENTITY ...
465
+ *
466
+ * When `mode === "i"` (USING INDEX), `indexName` is the name of the index to
467
+ * use. The extractor populates `Table.replica_identity_index` from
468
+ * `pg_index.indisreplident` whenever `Table.replica_identity` is `'i'`, so
469
+ * callers that source their props from a `Table` instance can rely on the
470
+ * pair being consistent. The non-null assertions in `requires` / `serialize`
471
+ * below are justified by that data invariant — the same pattern the FK
472
+ * branch of `AlterTableAddConstraint` uses for `foreign_key_columns!` /
473
+ * `foreign_key_table!` / `foreign_key_schema!`.
465
474
  */
466
475
  export class AlterTableSetReplicaIdentity extends AlterTableChange {
467
476
  public readonly table: Table;
468
477
  public readonly mode: "d" | "n" | "f" | "i";
478
+ public readonly indexName: string | null;
469
479
  public readonly scope = "object" as const;
470
480
 
471
- constructor(props: { table: Table; mode: "d" | "n" | "f" | "i" }) {
481
+ constructor(props: {
482
+ table: Table;
483
+ mode: "d" | "n" | "f" | "i";
484
+ indexName?: string | null;
485
+ }) {
472
486
  super();
473
487
  this.table = props.table;
474
488
  this.mode = props.mode;
489
+ this.indexName = props.indexName ?? null;
475
490
  }
476
491
 
477
492
  get requires() {
478
- return [this.table.stableId];
493
+ const reqs: string[] = [this.table.stableId];
494
+ if (this.mode === "i") {
495
+ reqs.push(
496
+ stableId.index(
497
+ this.table.schema,
498
+ this.table.name,
499
+ // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
500
+ this.indexName!,
501
+ ),
502
+ );
503
+ }
504
+ return reqs;
479
505
  }
480
506
 
481
507
  serialize(_options?: SerializeOptions): string {
@@ -486,7 +512,8 @@ export class AlterTableSetReplicaIdentity extends AlterTableChange {
486
512
  ? "NOTHING"
487
513
  : this.mode === "f"
488
514
  ? "FULL"
489
- : "DEFAULT"; // 'i' (USING INDEX) is handled via index changes; fallback to DEFAULT
515
+ : // biome-ignore lint/style/noNonNullAssertion: mode 'i' implies the extractor populated replica_identity_index
516
+ `USING INDEX ${this.indexName!}`;
490
517
  return [
491
518
  "ALTER TABLE",
492
519
  `${this.table.schema}.${this.table.name}`,
@@ -556,11 +583,21 @@ export class AlterTableDropColumn extends AlterTableChange {
556
583
  public readonly table: Table;
557
584
  public readonly column: ColumnProps;
558
585
  public readonly scope = "object" as const;
559
-
560
- constructor(props: { table: Table; column: ColumnProps }) {
586
+ // Drop the implicit `requires(table)` edge. Only set by the lazy
587
+ // cycle-breaker for the publication↔column case, where the table survives
588
+ // the migration and the edge is therefore artificial. See
589
+ // `sort/cycle-breakers.ts` for the full justification.
590
+ public readonly omitTableRequirement: boolean;
591
+
592
+ constructor(props: {
593
+ table: Table;
594
+ column: ColumnProps;
595
+ omitTableRequirement?: boolean;
596
+ }) {
561
597
  super();
562
598
  this.table = props.table;
563
599
  this.column = props.column;
600
+ this.omitTableRequirement = props.omitTableRequirement ?? false;
564
601
  }
565
602
 
566
603
  get drops() {
@@ -570,10 +607,12 @@ export class AlterTableDropColumn extends AlterTableChange {
570
607
  }
571
608
 
572
609
  get requires() {
573
- return [
574
- this.table.stableId,
575
- stableId.column(this.table.schema, this.table.name, this.column.name),
576
- ];
610
+ const colId = stableId.column(
611
+ this.table.schema,
612
+ this.table.name,
613
+ this.column.name,
614
+ );
615
+ return this.omitTableRequirement ? [colId] : [this.table.stableId, colId];
577
616
  }
578
617
 
579
618
  serialize(_options?: SerializeOptions): string {
@@ -592,12 +631,18 @@ export class AlterTableDropColumn extends AlterTableChange {
592
631
  export class AlterTableAlterColumnType extends AlterTableChange {
593
632
  public readonly table: Table;
594
633
  public readonly column: ColumnProps;
634
+ public readonly previousColumn?: ColumnProps;
595
635
  public readonly scope = "object" as const;
596
636
 
597
- constructor(props: { table: Table; column: ColumnProps }) {
637
+ constructor(props: {
638
+ table: Table;
639
+ column: ColumnProps;
640
+ previousColumn?: ColumnProps;
641
+ }) {
598
642
  super();
599
643
  this.table = props.table;
600
644
  this.column = props.column;
645
+ this.previousColumn = props.previousColumn;
601
646
  }
602
647
 
603
648
  get requires() {
@@ -607,6 +652,14 @@ export class AlterTableAlterColumnType extends AlterTableChange {
607
652
  }
608
653
 
609
654
  serialize(_options?: SerializeOptions): string {
655
+ // previousColumn is optional so direct serializer tests/fixtures can keep
656
+ // emitting canonical ALTER TYPE SQL without forcing a USING expression.
657
+ // When provided, we can detect true type changes and add USING for casts
658
+ // PostgreSQL cannot perform automatically.
659
+ const hasTypeChangedWithPreviousDefinition =
660
+ this.previousColumn?.data_type_str !== undefined &&
661
+ this.previousColumn.data_type_str !== this.column.data_type_str;
662
+
610
663
  const parts: string[] = [
611
664
  "ALTER TABLE",
612
665
  `${this.table.schema}.${this.table.name}`,
@@ -618,6 +671,9 @@ export class AlterTableAlterColumnType extends AlterTableChange {
618
671
  if (this.column.collation) {
619
672
  parts.push("COLLATE", this.column.collation);
620
673
  }
674
+ if (hasTypeChangedWithPreviousDefinition) {
675
+ parts.push("USING", `${this.column.name}::${this.column.data_type_str}`);
676
+ }
621
677
  return parts.join(" ");
622
678
  }
623
679
  }
@@ -767,6 +767,9 @@ describe.concurrent("table.diff", () => {
767
767
  expect(
768
768
  typeChanges.some((c) => c instanceof AlterTableAlterColumnType),
769
769
  ).toBe(true);
770
+ expect(typeChanges.map((c) => c.serialize())).toContain(
771
+ "ALTER TABLE public.t2 ALTER COLUMN a TYPE text USING a::text",
772
+ );
770
773
 
771
774
  const defaultAdded = new Table({
772
775
  ...base,
@@ -817,6 +820,46 @@ describe.concurrent("table.diff", () => {
817
820
  expect(
818
821
  notNullDropped.some((c) => c instanceof AlterTableAlterColumnDropNotNull),
819
822
  ).toBe(true);
823
+
824
+ const withDefault = new Table({
825
+ ...base,
826
+ name: "t2",
827
+ columns: [
828
+ {
829
+ ...withCol.columns[0],
830
+ data_type: "text",
831
+ data_type_str: "text",
832
+ default: "'active'",
833
+ },
834
+ ],
835
+ });
836
+ const typeChangedWithDefault = new Table({
837
+ ...base,
838
+ name: "t2",
839
+ columns: [
840
+ {
841
+ ...withDefault.columns[0],
842
+ data_type: "USER-DEFINED",
843
+ data_type_str: "test_schema.status",
844
+ is_custom_type: true,
845
+ custom_type_type: "e",
846
+ custom_type_category: "E",
847
+ custom_type_schema: "test_schema",
848
+ custom_type_name: "status",
849
+ default: "'active'::test_schema.status",
850
+ },
851
+ ],
852
+ });
853
+ const typeChangesWithDefault = diffTables(
854
+ testContext,
855
+ { [withDefault.stableId]: withDefault },
856
+ { [typeChangedWithDefault.stableId]: typeChangedWithDefault },
857
+ );
858
+ expect(typeChangesWithDefault.map((c) => c.serialize())).toEqual([
859
+ "ALTER TABLE public.t2 ALTER COLUMN a DROP DEFAULT",
860
+ "ALTER TABLE public.t2 ALTER COLUMN a TYPE test_schema.status USING a::test_schema.status",
861
+ "ALTER TABLE public.t2 ALTER COLUMN a SET DEFAULT 'active'::test_schema.status",
862
+ ]);
820
863
  });
821
864
 
822
865
  test("identity transitions emit drop/add/set-generated changes", () => {
@@ -245,15 +245,13 @@ export function diffTables(
245
245
 
246
246
  // REPLICA IDENTITY: If non-default, emit ALTER TABLE ... REPLICA IDENTITY
247
247
  if (branchTable.replica_identity !== "d") {
248
- // Skip 'i' (USING INDEX) — handled by index changes
249
- if (branchTable.replica_identity !== "i") {
250
- changes.push(
251
- new AlterTableSetReplicaIdentity({
252
- table: branchTable,
253
- mode: branchTable.replica_identity,
254
- }),
255
- );
256
- }
248
+ changes.push(
249
+ new AlterTableSetReplicaIdentity({
250
+ table: branchTable,
251
+ mode: branchTable.replica_identity,
252
+ indexName: branchTable.replica_identity_index,
253
+ }),
254
+ );
257
255
  }
258
256
 
259
257
  changes.push(
@@ -404,16 +402,23 @@ export function diffTables(
404
402
  }
405
403
 
406
404
  // REPLICA IDENTITY
407
- if (mainTable.replica_identity !== branchTable.replica_identity) {
408
- // Skip when target is 'i' (USING INDEX) handled by index changes
409
- if (branchTable.replica_identity !== "i") {
410
- changes.push(
411
- new AlterTableSetReplicaIdentity({
412
- table: mainTable,
413
- mode: branchTable.replica_identity,
414
- }),
415
- );
416
- }
405
+ // Re-emit when the mode changes, or when staying in 'i' mode but pointing
406
+ // at a different index. The index named on the branch must already exist
407
+ // before this ALTER runs; AlterTableSetReplicaIdentity declares that
408
+ // dependency in its `requires`.
409
+ const replicaIdentityChanged =
410
+ mainTable.replica_identity !== branchTable.replica_identity ||
411
+ (branchTable.replica_identity === "i" &&
412
+ mainTable.replica_identity_index !==
413
+ branchTable.replica_identity_index);
414
+ if (replicaIdentityChanged) {
415
+ changes.push(
416
+ new AlterTableSetReplicaIdentity({
417
+ table: mainTable,
418
+ mode: branchTable.replica_identity,
419
+ indexName: branchTable.replica_identity_index,
420
+ }),
421
+ );
417
422
  }
418
423
 
419
424
  // OWNER
@@ -701,19 +706,39 @@ export function diffTables(
701
706
  const branchCol = branchCols.get(name);
702
707
  if (!branchCol) continue;
703
708
 
709
+ const columnTypeChanged =
710
+ mainCol.data_type_str !== branchCol.data_type_str;
711
+ const columnCollationChanged = mainCol.collation !== branchCol.collation;
712
+ const needsDefaultSafeFlow =
713
+ columnTypeChanged && mainCol.default !== null;
714
+
704
715
  // TYPE or COLLATION change
705
- if (
706
- mainCol.data_type_str !== branchCol.data_type_str ||
707
- mainCol.collation !== branchCol.collation
708
- ) {
716
+ if (columnTypeChanged || columnCollationChanged) {
709
717
  // Skip if parent has the same type/collation change
710
718
  if (!parentHasSameColumnPropertyChange(name, "type")) {
719
+ if (needsDefaultSafeFlow) {
720
+ changes.push(
721
+ new AlterTableAlterColumnDropDefault({
722
+ table: branchTable,
723
+ column: branchCol,
724
+ }),
725
+ );
726
+ }
711
727
  changes.push(
712
728
  new AlterTableAlterColumnType({
713
729
  table: branchTable,
714
730
  column: branchCol,
731
+ previousColumn: mainCol,
715
732
  }),
716
733
  );
734
+ if (needsDefaultSafeFlow && branchCol.default !== null) {
735
+ changes.push(
736
+ new AlterTableAlterColumnSetDefault({
737
+ table: branchTable,
738
+ column: branchCol,
739
+ }),
740
+ );
741
+ }
717
742
  }
718
743
  }
719
744
 
@@ -734,6 +759,10 @@ export function diffTables(
734
759
  if (mainCol.default !== branchCol.default) {
735
760
  // Skip if parent has the same default change
736
761
  if (!parentHasSameColumnPropertyChange(name, "default")) {
762
+ if (needsDefaultSafeFlow) {
763
+ // Defaults were already dropped/re-set in the type-change flow above.
764
+ continue;
765
+ }
737
766
  if (branchCol.default === null) {
738
767
  // Drop default value
739
768
  changes.push(