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

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 (33) hide show
  1. package/dist/core/expand-replace-dependencies.js +69 -0
  2. package/dist/core/objects/index/index.model.js +12 -2
  3. package/dist/core/objects/procedure/procedure.diff.js +33 -20
  4. package/dist/core/objects/rls-policy/changes/rls-policy.create.js +23 -0
  5. package/dist/core/objects/rls-policy/rls-policy.model.d.ts +49 -0
  6. package/dist/core/objects/rls-policy/rls-policy.model.js +122 -1
  7. package/dist/core/objects/table/table.diff.js +1 -0
  8. package/dist/core/objects/table/table.model.d.ts +4 -0
  9. package/dist/core/objects/table/table.model.js +2 -0
  10. package/dist/core/plan/sql-format/fixtures.js +8 -0
  11. package/dist/core/post-diff-cycle-breaking.d.ts +7 -0
  12. package/dist/core/post-diff-cycle-breaking.js +69 -3
  13. package/package.json +1 -1
  14. package/src/core/catalog.snapshot.test.ts +2 -0
  15. package/src/core/expand-replace-dependencies.test.ts +118 -0
  16. package/src/core/expand-replace-dependencies.ts +78 -0
  17. package/src/core/objects/index/index.model.test.ts +83 -0
  18. package/src/core/objects/index/index.model.ts +13 -4
  19. package/src/core/objects/procedure/procedure.diff.test.ts +100 -2
  20. package/src/core/objects/procedure/procedure.diff.ts +39 -21
  21. package/src/core/objects/rls-policy/changes/rls-policy.alter.test.ts +16 -0
  22. package/src/core/objects/rls-policy/changes/rls-policy.create.test.ts +128 -0
  23. package/src/core/objects/rls-policy/changes/rls-policy.create.ts +27 -0
  24. package/src/core/objects/rls-policy/changes/rls-policy.drop.test.ts +2 -0
  25. package/src/core/objects/rls-policy/rls-policy.diff.test.ts +2 -0
  26. package/src/core/objects/rls-policy/rls-policy.model.ts +134 -1
  27. package/src/core/objects/table/changes/table.alter.test.ts +1 -0
  28. package/src/core/objects/table/table.diff.test.ts +102 -0
  29. package/src/core/objects/table/table.diff.ts +1 -0
  30. package/src/core/objects/table/table.model.ts +2 -0
  31. package/src/core/plan/sql-format/fixtures.ts +8 -0
  32. package/src/core/post-diff-cycle-breaking.test.ts +142 -0
  33. package/src/core/post-diff-cycle-breaking.ts +83 -2
@@ -1,4 +1,5 @@
1
- import { AlterTableDropColumn, AlterTableDropConstraint, } from "./objects/table/changes/table.alter.js";
1
+ import { AlterTableAddConstraint, AlterTableDropColumn, AlterTableDropConstraint, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.js";
2
+ import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
2
3
  import { DropTable } from "./objects/table/changes/table.drop.js";
3
4
  import { stableId } from "./objects/utils.js";
4
5
  function constraintStableId(table, constraintName) {
@@ -33,6 +34,63 @@ function isSupersededByTableReplacement(change, replacedTableIds) {
33
34
  }
34
35
  return replacedTableIds.has(change.table.stableId);
35
36
  }
37
+ /**
38
+ * Drop earlier duplicates of `AlterTableAddConstraint` /
39
+ * `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
40
+ * replaced tables, keeping only the last occurrence of each
41
+ * `(changeType, table.stableId, constraint.name)`.
42
+ *
43
+ * When `expandReplaceDependencies()` promotes a table to a full
44
+ * `DropTable + CreateTable` pair, it also emits one
45
+ * `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
46
+ * `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
47
+ * emitted the same change for a shape flip or a new constraint on that
48
+ * table, the plan ends up with two identical `ALTER TABLE ... ADD
49
+ * CONSTRAINT ...` statements and PostgreSQL fails at apply time with
50
+ * `constraint "..." for relation "..." already exists`. Because
51
+ * `expandReplaceDependencies()` appends its additions after the original
52
+ * `diffTables()` output, the last occurrence is the expansion's emission —
53
+ * keeping it preserves correctness while removing the duplicate.
54
+ */
55
+ function dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds) {
56
+ if (replacedTableIds.size === 0)
57
+ return changes;
58
+ const keyFor = (change) => {
59
+ if (!(change instanceof AlterTableAddConstraint) &&
60
+ !(change instanceof AlterTableValidateConstraint) &&
61
+ !(change instanceof CreateCommentOnConstraint)) {
62
+ return null;
63
+ }
64
+ if (!replacedTableIds.has(change.table.stableId))
65
+ return null;
66
+ const tag = change instanceof AlterTableAddConstraint
67
+ ? "add"
68
+ : change instanceof AlterTableValidateConstraint
69
+ ? "validate"
70
+ : "comment";
71
+ return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
72
+ };
73
+ const seen = new Set();
74
+ const reversedKept = [];
75
+ let mutated = false;
76
+ // Walk backwards: the first encounter of each key corresponds to its LAST
77
+ // occurrence in the original order. `expandReplaceDependencies()` appends
78
+ // additions after the original changes, so "last wins" keeps the
79
+ // expansion's emission and drops the earlier diffTables duplicate.
80
+ for (let i = changes.length - 1; i >= 0; i--) {
81
+ const change = changes[i];
82
+ const key = keyFor(change);
83
+ if (key !== null) {
84
+ if (seen.has(key)) {
85
+ mutated = true;
86
+ continue;
87
+ }
88
+ seen.add(key);
89
+ }
90
+ reversedKept.push(change);
91
+ }
92
+ return mutated ? reversedKept.reverse() : changes;
93
+ }
36
94
  function collectExplicitConstraintDropIds(changes) {
37
95
  const explicitConstraintDropIds = new Set();
38
96
  for (const change of changes) {
@@ -59,6 +117,13 @@ function hasSameEntries(left, right) {
59
117
  * - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
60
118
  * `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
61
119
  * redundant and create an unbreakable drop-phase cycle, so we elide them.
120
+ * - When the same `DropTable+CreateTable` pair is present, the expansion
121
+ * also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
122
+ * / `CreateCommentOnConstraint` per branch constraint, which may collide
123
+ * with the same change already emitted by `diffTables()` (for example on a
124
+ * shape flip or a new constraint). We dedupe these keeping only the last
125
+ * occurrence so the expansion's emission survives and the diffTables
126
+ * duplicate is removed.
62
127
  * - If two dropped tables reference each other via FK, we insert dedicated
63
128
  * `AlterTableDropConstraint` changes and teach the paired `DropTable`
64
129
  * changes not to claim those FK stable IDs.
@@ -67,9 +132,10 @@ function hasSameEntries(left, right) {
67
132
  * in the corresponding `diff*` function instead of this pass.
68
133
  */
69
134
  export function normalizePostDiffCycles({ changes, mainCatalog, replacedTableIds = new Set(), }) {
135
+ const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds);
70
136
  const structurallyNormalizedChanges = replacedTableIds.size === 0
71
- ? changes
72
- : changes.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
137
+ ? dedupedChanges
138
+ : dedupedChanges.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
73
139
  const dropTableChanges = structurallyNormalizedChanges.filter((change) => change instanceof DropTable);
74
140
  if (dropTableChanges.length < 2) {
75
141
  return structurallyNormalizedChanges;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.19",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -428,6 +428,8 @@ describe("catalog snapshot serde", () => {
428
428
  with_check_expression: null,
429
429
  owner: "postgres",
430
430
  comment: null,
431
+ referenced_relations: [],
432
+ referenced_procedures: [],
431
433
  });
432
434
 
433
435
  const target = new Catalog({
@@ -3,6 +3,9 @@ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
3
3
  import type { Change } from "./change.types.ts";
4
4
  import { expandReplaceDependencies } from "./expand-replace-dependencies.ts";
5
5
  import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts";
6
+ import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
7
+ import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts";
8
+ import { Procedure } from "./objects/procedure/procedure.model.ts";
6
9
  import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
7
10
  import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
8
11
  import { diffSequences } from "./objects/sequence/sequence.diff.ts";
@@ -22,6 +25,9 @@ import { Table } from "./objects/table/table.model.ts";
22
25
  import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts";
23
26
  import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts";
24
27
  import { Enum } from "./objects/type/enum/enum.model.ts";
28
+ import { CreateView } from "./objects/view/changes/view.create.ts";
29
+ import { DropView } from "./objects/view/changes/view.drop.ts";
30
+ import { View } from "./objects/view/view.model.ts";
25
31
 
26
32
  function mockChange(overrides: {
27
33
  creates?: string[];
@@ -330,6 +336,7 @@ describe("expandReplaceDependencies", () => {
330
336
  validated: true,
331
337
  is_local: true,
332
338
  no_inherit: false,
339
+ is_temporal: false,
333
340
  is_partition_clone: false,
334
341
  parent_constraint_schema: null,
335
342
  parent_constraint_name: null,
@@ -431,4 +438,115 @@ describe("expandReplaceDependencies", () => {
431
438
  expect(expanded.changes).toContain(preExistingGrant);
432
439
  expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false);
433
440
  });
441
+
442
+ test("promotes dependent view when a procedure's parameter types change", async () => {
443
+ // Procedure stableIds are signature-qualified, so a parameter-type change
444
+ // produces different stableIds in `createdIds` and `droppedIds`. The
445
+ // expander must still treat the (schema, name)-matched pair as a replace
446
+ // root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to
447
+ // `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with
448
+ // "cannot drop function because other objects depend on it").
449
+ const baseline = await createEmptyCatalog(170000, "postgres");
450
+ const procedureBase = {
451
+ schema: "public",
452
+ name: "format_id",
453
+ kind: "f" as const,
454
+ return_type: "text",
455
+ return_type_schema: "pg_catalog",
456
+ language: "sql",
457
+ security_definer: false,
458
+ volatility: "i" as const,
459
+ parallel_safety: "u" as const,
460
+ execution_cost: 100,
461
+ result_rows: 0,
462
+ is_strict: false,
463
+ leakproof: false,
464
+ returns_set: false,
465
+ argument_count: 1,
466
+ argument_default_count: 0,
467
+ argument_names: ["id"],
468
+ all_argument_types: null,
469
+ argument_modes: null,
470
+ argument_defaults: null,
471
+ source_code: "SELECT 'id:' || id::text",
472
+ binary_path: null,
473
+ sql_body: null,
474
+ config: null,
475
+ owner: "postgres",
476
+ comment: null,
477
+ privileges: [],
478
+ };
479
+ const mainProcedure = new Procedure({
480
+ ...procedureBase,
481
+ argument_types: ["int4"],
482
+ definition: "CREATE FUNCTION public.format_id(id integer) ...",
483
+ });
484
+ const branchProcedure = new Procedure({
485
+ ...procedureBase,
486
+ argument_types: ["int8"],
487
+ definition: "CREATE FUNCTION public.format_id(id bigint) ...",
488
+ });
489
+ const viewBase = {
490
+ schema: "public",
491
+ name: "items_formatted",
492
+ row_security: false,
493
+ force_row_security: false,
494
+ has_indexes: false,
495
+ has_rules: false,
496
+ has_triggers: false,
497
+ has_subclasses: false,
498
+ is_populated: true,
499
+ replica_identity: "d" as const,
500
+ is_partition: false,
501
+ options: null,
502
+ partition_bound: null,
503
+ owner: "postgres",
504
+ comment: null,
505
+ columns: [],
506
+ privileges: [],
507
+ };
508
+ const mainView = new View({
509
+ ...viewBase,
510
+ definition: "SELECT public.format_id(id) FROM public.items",
511
+ });
512
+ const branchView = new View({
513
+ ...viewBase,
514
+ definition: "SELECT public.format_id(id::bigint) FROM public.items",
515
+ });
516
+
517
+ const changes: Change[] = [
518
+ new DropProcedure({ procedure: mainProcedure }),
519
+ new CreateProcedure({ procedure: branchProcedure }),
520
+ // view.diff emits this because pg_get_viewdef text differs after the
521
+ // underlying function signature changes.
522
+ new CreateView({ view: branchView, orReplace: true }),
523
+ ];
524
+
525
+ const mainCatalog = new Catalog({
526
+ ...baseline,
527
+ procedures: { [mainProcedure.stableId]: mainProcedure },
528
+ views: { [mainView.stableId]: mainView },
529
+ depends: [
530
+ {
531
+ dependent_stable_id: mainView.stableId,
532
+ referenced_stable_id: mainProcedure.stableId,
533
+ deptype: "n",
534
+ },
535
+ ],
536
+ });
537
+ const branchCatalog = new Catalog({
538
+ ...baseline,
539
+ procedures: { [branchProcedure.stableId]: branchProcedure },
540
+ views: { [branchView.stableId]: branchView },
541
+ depends: [],
542
+ });
543
+
544
+ const expanded = expandReplaceDependencies({
545
+ changes,
546
+ mainCatalog,
547
+ branchCatalog,
548
+ });
549
+
550
+ expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true);
551
+ });
434
552
  });
@@ -2,6 +2,8 @@ import type { Catalog } from "./catalog.model.ts";
2
2
  import type { Change } from "./change.types.ts";
3
3
  import { CreateDomain } from "./objects/domain/changes/domain.create.ts";
4
4
  import { DropDomain } from "./objects/domain/changes/domain.drop.ts";
5
+ import { CreateIndex } from "./objects/index/changes/index.create.ts";
6
+ import { DropIndex } from "./objects/index/changes/index.drop.ts";
5
7
  import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts";
6
8
  import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts";
7
9
  import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
@@ -34,6 +36,12 @@ type ResolvedObject =
34
36
  main: Catalog["views"][string];
35
37
  branch: Catalog["views"][string];
36
38
  }
39
+ | {
40
+ kind: "index";
41
+ main: Catalog["indexes"][string];
42
+ branch: Catalog["indexes"][string];
43
+ branchIndexableObject: Catalog["indexableObjects"][string] | undefined;
44
+ }
37
45
  | {
38
46
  kind: "materialized_view";
39
47
  main: Catalog["materializedViews"][string];
@@ -102,6 +110,25 @@ export function expandReplaceDependencies({
102
110
  }
103
111
  }
104
112
 
113
+ // Procedure stableIds are signature-qualified
114
+ // (`procedure:schema.name(argtypes)`), so a function whose parameter types
115
+ // change has different ids in `createdIds` and `droppedIds` and would not
116
+ // appear in the intersection above. Treat any dropped procedure whose
117
+ // `(schema, name)` matches a created procedure as a replace root so
118
+ // dependents referencing the old signature via pg_depend get promoted to
119
+ // DROP+CREATE.
120
+ const createdProcedureNames = new Set<string>();
121
+ for (const id of createdIds) {
122
+ const key = parseProcedureSchemaName(id);
123
+ if (key) createdProcedureNames.add(key);
124
+ }
125
+ for (const id of droppedIds) {
126
+ const key = parseProcedureSchemaName(id);
127
+ if (key && createdProcedureNames.has(key)) {
128
+ replaceRoots.add(id);
129
+ }
130
+ }
131
+
105
132
  if (replaceRoots.size === 0) {
106
133
  return {
107
134
  changes,
@@ -259,6 +286,13 @@ function isOwnedSequenceColumnDependency(
259
286
  );
260
287
  }
261
288
 
289
+ function parseProcedureSchemaName(stableId: string): string | null {
290
+ if (!stableId.startsWith("procedure:")) return null;
291
+ const paren = stableId.indexOf("(");
292
+ if (paren === -1) return null;
293
+ return stableId.slice("procedure:".length, paren);
294
+ }
295
+
262
296
  function normalizeDependentId(dependentId: string): string | null {
263
297
  let id = dependentId;
264
298
 
@@ -320,6 +354,20 @@ function resolveObjectForStableId(
320
354
  return main && branch ? { kind: "materialized_view", main, branch } : null;
321
355
  }
322
356
 
357
+ if (stableId.startsWith("index:")) {
358
+ const main = mainCatalog.indexes[stableId];
359
+ const branch = branchCatalog.indexes[stableId];
360
+ return main && branch
361
+ ? {
362
+ kind: "index",
363
+ main,
364
+ branch,
365
+ branchIndexableObject:
366
+ branchCatalog.indexableObjects[branch.tableStableId],
367
+ }
368
+ : null;
369
+ }
370
+
323
371
  if (stableId.startsWith("procedure:")) {
324
372
  const main = mainCatalog.procedures[stableId];
325
373
  const branch = branchCatalog.procedures[stableId];
@@ -421,6 +469,36 @@ function buildReplaceChanges(
421
469
  ? [new CreateMaterializedView({ materializedView: resolved.branch })]
422
470
  : []),
423
471
  ];
472
+ case "index":
473
+ // Constraint-owned, primary, and partition-attached indexes are managed
474
+ // by the owning constraint or parent-index DDL, not standalone
475
+ // CREATE INDEX / DROP INDEX. The `case "table":` branch above already
476
+ // recreates constraints via AlterTableAddConstraint; emitting a
477
+ // standalone drop/create here would fail in PostgreSQL
478
+ // ("cannot drop index ... because constraint ... requires it") or
479
+ // duplicate the index the constraint recreates. Skip matches
480
+ // diffIndexes (packages/pg-delta/src/core/objects/index/index.diff.ts).
481
+ if (
482
+ resolved.main.is_owned_by_constraint ||
483
+ resolved.main.is_primary ||
484
+ resolved.main.is_index_partition ||
485
+ resolved.branch.is_owned_by_constraint ||
486
+ resolved.branch.is_primary ||
487
+ resolved.branch.is_index_partition
488
+ ) {
489
+ return null;
490
+ }
491
+ return [
492
+ ...(addDrop ? [new DropIndex({ index: resolved.main })] : []),
493
+ ...(addCreate
494
+ ? [
495
+ new CreateIndex({
496
+ index: resolved.branch,
497
+ indexableObject: resolved.branchIndexableObject,
498
+ }),
499
+ ]
500
+ : []),
501
+ ];
424
502
  case "procedure":
425
503
  return [
426
504
  ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []),
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Pool } from "pg";
3
+ import { extractIndexes, Index } from "./index.model.ts";
4
+
5
+ // Minimal fields required by indexPropsSchema; individual tests override the
6
+ // fields relevant to each scenario.
7
+ const baseRow = {
8
+ schema: "public",
9
+ table_name: '"users"',
10
+ storage_params: [] as string[],
11
+ statistics_target: [] as number[],
12
+ index_type: "btree",
13
+ tablespace: null,
14
+ is_unique: false,
15
+ is_primary: false,
16
+ is_exclusion: false,
17
+ nulls_not_distinct: false,
18
+ immediate: true,
19
+ is_clustered: false,
20
+ is_replica_identity: false,
21
+ key_columns: [1],
22
+ column_collations: [null],
23
+ operator_classes: ["default"],
24
+ column_options: [0],
25
+ index_expressions: null,
26
+ partial_predicate: null,
27
+ is_owned_by_constraint: false,
28
+ table_relkind: "r" as const,
29
+ is_partitioned_index: false,
30
+ is_index_partition: false,
31
+ parent_index_name: null,
32
+ comment: null,
33
+ owner: "postgres",
34
+ };
35
+
36
+ const mockPool = (rows: unknown[]): Pool =>
37
+ ({ query: async () => ({ rows }) }) as unknown as Pool;
38
+
39
+ describe("extractIndexes", () => {
40
+ test("skips rows where pg_get_indexdef returned NULL", async () => {
41
+ const indexes = await extractIndexes(
42
+ mockPool([
43
+ {
44
+ ...baseRow,
45
+ name: '"good_idx"',
46
+ definition: "CREATE INDEX good_idx ON users (id)",
47
+ },
48
+ { ...baseRow, name: '"orphan_idx"', definition: null },
49
+ ]),
50
+ );
51
+
52
+ expect(indexes).toHaveLength(1);
53
+ expect(indexes[0]).toBeInstanceOf(Index);
54
+ expect(indexes[0]?.name).toBe('"good_idx"');
55
+ expect(indexes[0]?.definition).toBe("CREATE INDEX good_idx ON users (id)");
56
+ });
57
+
58
+ test("does not throw ZodError when the only row has a null definition", async () => {
59
+ await expect(
60
+ extractIndexes(
61
+ mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
62
+ ),
63
+ ).resolves.toEqual([]);
64
+ });
65
+
66
+ test("returns all indexes when every row has a valid definition", async () => {
67
+ const indexes = await extractIndexes(
68
+ mockPool([
69
+ {
70
+ ...baseRow,
71
+ name: '"a"',
72
+ definition: "CREATE INDEX a ON users (id)",
73
+ },
74
+ {
75
+ ...baseRow,
76
+ name: '"b"',
77
+ definition: "CREATE INDEX b ON users (id)",
78
+ },
79
+ ]),
80
+ );
81
+ expect(indexes.map((i) => i.name)).toEqual(['"a"', '"b"']);
82
+ });
83
+ });
@@ -40,6 +40,16 @@ const indexPropsSchema = z.object({
40
40
  owner: z.string(),
41
41
  });
42
42
 
43
+ // pg_get_indexdef(oid, colno, pretty) invokes pg_get_indexdef_worker with
44
+ // missing_ok = true, so it can return NULL when any internal system-cache lookup
45
+ // fails (race with concurrent DROP, role visibility edge cases, orphaned index
46
+ // metadata, recovery transients). An unreadable index cannot be diffed, so we
47
+ // accept NULL here and filter the row out with a debug log instead of crashing
48
+ // the whole catalog extraction.
49
+ const indexRowSchema = indexPropsSchema.extend({
50
+ definition: z.string().nullable(),
51
+ });
52
+
43
53
  /**
44
54
  * All properties exposed by CREATE INDEX statement are included in diff output.
45
55
  * https://www.postgresql.org/docs/current/sql-createindex.html
@@ -362,9 +372,8 @@ export async function extractIndexes(pool: Pool): Promise<Index[]> {
362
372
 
363
373
  order by 1, 2
364
374
  `);
365
- // Validate and parse each row using the Zod schema
366
- const validatedRows = indexRows.map((row: unknown) =>
367
- indexPropsSchema.parse(row),
368
- );
375
+ const validatedRows = indexRows
376
+ .map((row: unknown) => indexRowSchema.parse(row))
377
+ .filter((row): row is IndexProps => row.definition !== null);
369
378
  return validatedRows.map((row: IndexProps) => new Index(row));
370
379
  }
@@ -144,12 +144,13 @@ describe.concurrent("procedure.diff", () => {
144
144
  expect(changes[0]).toBeInstanceOf(AlterProcedureSetParallel);
145
145
  });
146
146
 
147
- test("create or replace when non-alterable property changes", () => {
147
+ test("create or replace when body-only non-alterable property changes", () => {
148
+ // Changing only the language (or source) is OR-REPLACE-safe: no DROP needed.
148
149
  const main = new Procedure(base);
149
150
  const branch = new Procedure({
150
151
  ...base,
151
- return_type: "text",
152
152
  language: "plpgsql",
153
+ source_code: "BEGIN RETURN 1; END",
153
154
  });
154
155
  const changes = diffProcedures(
155
156
  testContext,
@@ -158,6 +159,82 @@ describe.concurrent("procedure.diff", () => {
158
159
  );
159
160
  expect(changes).toHaveLength(1);
160
161
  expect(changes[0]).toBeInstanceOf(CreateProcedure);
162
+ expect((changes[0] as CreateProcedure).orReplace).toBe(true);
163
+ });
164
+
165
+ test("drop + create when return type changes", () => {
166
+ // `CREATE OR REPLACE FUNCTION` cannot change the return type.
167
+ const main = new Procedure(base);
168
+ const branch = new Procedure({ ...base, return_type: "text" });
169
+ const changes = diffProcedures(
170
+ testContext,
171
+ { [main.stableId]: main },
172
+ { [branch.stableId]: branch },
173
+ );
174
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
175
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
176
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
177
+ });
178
+
179
+ test("drop + create when parameter name changes but type is identical", () => {
180
+ // Same argument_types means same stableId (altered path), but
181
+ // `CREATE OR REPLACE` rejects changing an IN parameter's name.
182
+ const main = new Procedure({
183
+ ...base,
184
+ argument_count: 1,
185
+ argument_names: ["p"],
186
+ argument_types: ["int4"],
187
+ all_argument_types: ["int4"],
188
+ argument_modes: ["i"],
189
+ });
190
+ const branch = new Procedure({
191
+ ...base,
192
+ argument_count: 1,
193
+ argument_names: ["renamed"],
194
+ argument_types: ["int4"],
195
+ all_argument_types: ["int4"],
196
+ argument_modes: ["i"],
197
+ });
198
+ const changes = diffProcedures(
199
+ testContext,
200
+ { [main.stableId]: main },
201
+ { [branch.stableId]: branch },
202
+ );
203
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
204
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
205
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
206
+ });
207
+
208
+ test("drop + create when a parameter default is removed", () => {
209
+ // `CREATE OR REPLACE` rejects removing parameter defaults.
210
+ const main = new Procedure({
211
+ ...base,
212
+ argument_count: 1,
213
+ argument_default_count: 1,
214
+ argument_names: ["p"],
215
+ argument_types: ["int4"],
216
+ all_argument_types: ["int4"],
217
+ argument_modes: ["i"],
218
+ argument_defaults: "0",
219
+ });
220
+ const branch = new Procedure({
221
+ ...base,
222
+ argument_count: 1,
223
+ argument_default_count: 0,
224
+ argument_names: ["p"],
225
+ argument_types: ["int4"],
226
+ all_argument_types: ["int4"],
227
+ argument_modes: ["i"],
228
+ argument_defaults: null,
229
+ });
230
+ const changes = diffProcedures(
231
+ testContext,
232
+ { [main.stableId]: main },
233
+ { [branch.stableId]: branch },
234
+ );
235
+ expect(changes[0]).toBeInstanceOf(DropProcedure);
236
+ expect(changes[1]).toBeInstanceOf(CreateProcedure);
237
+ expect((changes[1] as CreateProcedure).orReplace).toBe(false);
161
238
  });
162
239
 
163
240
  test("create or replace also emits a procedure comment when the comment changes", () => {
@@ -183,4 +260,25 @@ describe.concurrent("procedure.diff", () => {
183
260
  changes.some((change) => change instanceof CreateCommentOnProcedure),
184
261
  ).toBe(true);
185
262
  });
263
+
264
+ test("signature change re-emits comment even when comment itself is unchanged", () => {
265
+ // DROP destroys the old comment, so the new CREATE path must re-emit it.
266
+ const main = new Procedure({ ...base, comment: "hello" });
267
+ const branch = new Procedure({
268
+ ...base,
269
+ return_type: "text",
270
+ comment: "hello",
271
+ });
272
+ const changes = diffProcedures(
273
+ testContext,
274
+ { [main.stableId]: main },
275
+ { [branch.stableId]: branch },
276
+ );
277
+ expect(changes.some((change) => change instanceof DropProcedure)).toBe(
278
+ true,
279
+ );
280
+ expect(
281
+ changes.some((change) => change instanceof CreateCommentOnProcedure),
282
+ ).toBe(true);
283
+ });
186
284
  });