@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
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Pool } from "pg";
3
+ import {
4
+ extractMaterializedViews,
5
+ MaterializedView,
6
+ } from "./materialized-view.model.ts";
7
+
8
+ const baseRow = {
9
+ schema: "public",
10
+ row_security: false,
11
+ force_row_security: false,
12
+ has_indexes: false,
13
+ has_rules: false,
14
+ has_triggers: false,
15
+ has_subclasses: false,
16
+ is_populated: true,
17
+ replica_identity: "d" as const,
18
+ is_partition: false,
19
+ options: null,
20
+ partition_bound: null,
21
+ owner: "postgres",
22
+ comment: null,
23
+ columns: [],
24
+ privileges: [],
25
+ };
26
+
27
+ const mockPool = (rows: unknown[]): Pool =>
28
+ ({ query: async () => ({ rows }) }) as unknown as Pool;
29
+
30
+ const mockPoolSequence = (...attempts: unknown[][]): Pool => {
31
+ let i = 0;
32
+ return {
33
+ query: async () => ({
34
+ rows: attempts[Math.min(i++, attempts.length - 1)],
35
+ }),
36
+ } as unknown as Pool;
37
+ };
38
+
39
+ const NO_BACKOFF = { backoffMs: 0 } as const;
40
+
41
+ describe("extractMaterializedViews", () => {
42
+ test("skips rows where pg_get_viewdef returned NULL after exhausting retries", async () => {
43
+ const mvs = await extractMaterializedViews(
44
+ mockPool([
45
+ {
46
+ ...baseRow,
47
+ name: '"good_mv"',
48
+ definition: "SELECT 1",
49
+ },
50
+ { ...baseRow, name: '"orphan_mv"', definition: null },
51
+ ]),
52
+ NO_BACKOFF,
53
+ );
54
+
55
+ expect(mvs).toHaveLength(1);
56
+ expect(mvs[0]).toBeInstanceOf(MaterializedView);
57
+ expect(mvs[0]?.name).toBe('"good_mv"');
58
+ expect(mvs[0]?.definition).toBe("SELECT 1");
59
+ });
60
+
61
+ test("does not throw ZodError when the only row has a null definition", async () => {
62
+ await expect(
63
+ extractMaterializedViews(
64
+ mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
65
+ NO_BACKOFF,
66
+ ),
67
+ ).resolves.toEqual([]);
68
+ });
69
+
70
+ test("returns all materialized views when every row has a valid definition", async () => {
71
+ const mvs = await extractMaterializedViews(
72
+ mockPool([
73
+ { ...baseRow, name: '"a"', definition: "SELECT 1" },
74
+ { ...baseRow, name: '"b"', definition: "SELECT 2" },
75
+ ]),
76
+ NO_BACKOFF,
77
+ );
78
+ expect(mvs.map((m) => m.name)).toEqual(['"a"', '"b"']);
79
+ });
80
+
81
+ test("recovers when pg_get_viewdef is NULL on first attempt but resolved on retry", async () => {
82
+ const mvs = await extractMaterializedViews(
83
+ mockPoolSequence(
84
+ [{ ...baseRow, name: '"racy_mv"', definition: null }],
85
+ [{ ...baseRow, name: '"racy_mv"', definition: "SELECT 42" }],
86
+ ),
87
+ { retries: 2, backoffMs: 0 },
88
+ );
89
+ expect(mvs).toHaveLength(1);
90
+ expect(mvs[0]?.name).toBe('"racy_mv"');
91
+ expect(mvs[0]?.definition).toBe("SELECT 42");
92
+ });
93
+ });
@@ -10,6 +10,10 @@ import {
10
10
  type PrivilegeProps,
11
11
  privilegePropsSchema,
12
12
  } from "../base.privilege-diff.ts";
13
+ import {
14
+ type ExtractRetryOptions,
15
+ extractWithDefinitionRetry,
16
+ } from "../extract-with-retry.ts";
13
17
  import { ReplicaIdentitySchema } from "../table/table.model.ts";
14
18
 
15
19
  const materializedViewPropsSchema = z.object({
@@ -33,6 +37,15 @@ const materializedViewPropsSchema = z.object({
33
37
  privileges: z.array(privilegePropsSchema),
34
38
  });
35
39
 
40
+ // pg_get_viewdef(oid) can return NULL when the underlying matview (or its
41
+ // pg_rewrite row) is dropped between catalog scan and resolution, or under
42
+ // transient catalog state during recovery. An unreadable matview cannot be
43
+ // diffed, so we accept NULL here and filter the row out at extraction time
44
+ // rather than crashing the whole catalog parse with a ZodError.
45
+ const materializedViewRowSchema = materializedViewPropsSchema.extend({
46
+ definition: z.string().nullable(),
47
+ });
48
+
36
49
  type MaterializedViewPrivilegeProps = PrivilegeProps;
37
50
  export type MaterializedViewProps = z.infer<typeof materializedViewPropsSchema>;
38
51
 
@@ -142,8 +155,14 @@ export class MaterializedView extends BasePgModel implements TableLikeObject {
142
155
 
143
156
  export async function extractMaterializedViews(
144
157
  pool: Pool,
158
+ options?: ExtractRetryOptions,
145
159
  ): Promise<MaterializedView[]> {
146
- const { rows: mvRows } = await pool.query<MaterializedViewProps>(sql`
160
+ const mvRows = await extractWithDefinitionRetry({
161
+ label: "materialized views",
162
+ options,
163
+ hasNullDefinition: (row) => row.definition === null,
164
+ query: async () => {
165
+ const result = await pool.query<MaterializedViewProps>(sql`
147
166
  with extension_oids as (
148
167
  select
149
168
  objid
@@ -248,9 +267,13 @@ group by
248
267
  order by
249
268
  c.relnamespace::regnamespace, c.relname
250
269
  `);
251
- // Validate and parse each row using the Zod schema
252
- const validatedRows = mvRows.map((row: unknown) =>
253
- materializedViewPropsSchema.parse(row),
270
+ return result.rows.map((row: unknown) =>
271
+ materializedViewRowSchema.parse(row),
272
+ );
273
+ },
274
+ });
275
+ const validatedRows = mvRows.filter(
276
+ (row): row is MaterializedViewProps => row.definition !== null,
254
277
  );
255
278
  return validatedRows.map(
256
279
  (row: MaterializedViewProps) => new MaterializedView(row),
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Pool } from "pg";
3
+ import { extractProcedures, Procedure } from "./procedure.model.ts";
4
+
5
+ const baseRow = {
6
+ schema: "public",
7
+ kind: "f" as const,
8
+ return_type: "integer",
9
+ return_type_schema: "pg_catalog",
10
+ language: "sql",
11
+ security_definer: false,
12
+ volatility: "v" as const,
13
+ parallel_safety: "u" as const,
14
+ execution_cost: 100,
15
+ result_rows: 0,
16
+ is_strict: false,
17
+ leakproof: false,
18
+ returns_set: false,
19
+ argument_count: 0,
20
+ argument_default_count: 0,
21
+ argument_names: null,
22
+ argument_types: null,
23
+ all_argument_types: null,
24
+ argument_modes: null,
25
+ argument_defaults: null,
26
+ source_code: "select 1",
27
+ binary_path: null,
28
+ sql_body: null,
29
+ config: null,
30
+ owner: "postgres",
31
+ comment: null,
32
+ privileges: [],
33
+ };
34
+
35
+ const mockPool = (rows: unknown[]): Pool =>
36
+ ({ query: async () => ({ rows }) }) as unknown as Pool;
37
+
38
+ const mockPoolSequence = (...attempts: unknown[][]): Pool => {
39
+ let i = 0;
40
+ return {
41
+ query: async () => ({
42
+ rows: attempts[Math.min(i++, attempts.length - 1)],
43
+ }),
44
+ } as unknown as Pool;
45
+ };
46
+
47
+ const NO_BACKOFF = { backoffMs: 0 } as const;
48
+
49
+ describe("extractProcedures", () => {
50
+ test("skips rows where pg_get_functiondef returned NULL after exhausting retries", async () => {
51
+ const procs = await extractProcedures(
52
+ mockPool([
53
+ {
54
+ ...baseRow,
55
+ name: '"good_fn"',
56
+ definition:
57
+ "CREATE OR REPLACE FUNCTION good_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
58
+ },
59
+ { ...baseRow, name: '"orphan_fn"', definition: null },
60
+ ]),
61
+ NO_BACKOFF,
62
+ );
63
+
64
+ expect(procs).toHaveLength(1);
65
+ expect(procs[0]).toBeInstanceOf(Procedure);
66
+ expect(procs[0]?.name).toBe('"good_fn"');
67
+ });
68
+
69
+ test("does not throw ZodError when the only row has a null definition", async () => {
70
+ await expect(
71
+ extractProcedures(
72
+ mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
73
+ NO_BACKOFF,
74
+ ),
75
+ ).resolves.toEqual([]);
76
+ });
77
+
78
+ test("returns all procedures when every row has a valid definition", async () => {
79
+ const procs = await extractProcedures(
80
+ mockPool([
81
+ {
82
+ ...baseRow,
83
+ name: '"a"',
84
+ definition:
85
+ "CREATE OR REPLACE FUNCTION a() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
86
+ },
87
+ {
88
+ ...baseRow,
89
+ name: '"b"',
90
+ definition:
91
+ "CREATE OR REPLACE FUNCTION b() RETURNS integer AS $$ select 2 $$ LANGUAGE sql;",
92
+ },
93
+ ]),
94
+ NO_BACKOFF,
95
+ );
96
+ expect(procs.map((p) => p.name)).toEqual(['"a"', '"b"']);
97
+ });
98
+
99
+ test("recovers when pg_get_functiondef is NULL on first attempt but resolved on retry", async () => {
100
+ const procs = await extractProcedures(
101
+ mockPoolSequence(
102
+ [{ ...baseRow, name: '"racy_fn"', definition: null }],
103
+ [
104
+ {
105
+ ...baseRow,
106
+ name: '"racy_fn"',
107
+ definition:
108
+ "CREATE OR REPLACE FUNCTION racy_fn() RETURNS integer AS $$ select 1 $$ LANGUAGE sql;",
109
+ },
110
+ ],
111
+ ),
112
+ { retries: 2, backoffMs: 0 },
113
+ );
114
+ expect(procs).toHaveLength(1);
115
+ expect(procs[0]?.name).toBe('"racy_fn"');
116
+ });
117
+ });
@@ -6,6 +6,10 @@ import {
6
6
  type PrivilegeProps,
7
7
  privilegePropsSchema,
8
8
  } from "../base.privilege-diff.ts";
9
+ import {
10
+ type ExtractRetryOptions,
11
+ extractWithDefinitionRetry,
12
+ } from "../extract-with-retry.ts";
9
13
 
10
14
  const FunctionKindSchema = z.enum([
11
15
  "f", // function
@@ -66,6 +70,15 @@ const procedurePropsSchema = z.object({
66
70
  privileges: z.array(privilegePropsSchema),
67
71
  });
68
72
 
73
+ // pg_get_functiondef(oid) can return NULL when the function (its pg_proc
74
+ // row) is dropped between catalog scan and resolution, or under transient
75
+ // catalog state. An unreadable function cannot be diffed, so we accept NULL
76
+ // here and filter the row out at extraction time rather than crashing the
77
+ // whole catalog parse with a ZodError.
78
+ const procedureRowSchema = procedurePropsSchema.extend({
79
+ definition: z.string().nullable(),
80
+ });
81
+
69
82
  type ProcedurePrivilegeProps = PrivilegeProps;
70
83
  export type ProcedureProps = z.infer<typeof procedurePropsSchema>;
71
84
 
@@ -183,8 +196,16 @@ export class Procedure extends BasePgModel {
183
196
  }
184
197
  }
185
198
 
186
- export async function extractProcedures(pool: Pool): Promise<Procedure[]> {
187
- const { rows: procedureRows } = await pool.query<ProcedureProps>(sql`
199
+ export async function extractProcedures(
200
+ pool: Pool,
201
+ options?: ExtractRetryOptions,
202
+ ): Promise<Procedure[]> {
203
+ const procedureRows = await extractWithDefinitionRetry({
204
+ label: "procedures",
205
+ options,
206
+ hasNullDefinition: (row) => row.definition === null,
207
+ query: async () => {
208
+ const result = await pool.query<ProcedureProps>(sql`
188
209
  with extension_oids as (
189
210
  select
190
211
  objid
@@ -256,9 +277,11 @@ from
256
277
  order by
257
278
  1, 2
258
279
  `);
259
- // Validate and parse each row using the Zod schema
260
- const validatedRows = procedureRows.map((row: unknown) =>
261
- procedurePropsSchema.parse(row),
280
+ return result.rows.map((row: unknown) => procedureRowSchema.parse(row));
281
+ },
282
+ });
283
+ const validatedRows = procedureRows.filter(
284
+ (row): row is ProcedureProps => row.definition !== null,
262
285
  );
263
286
  return validatedRows.map((row: ProcedureProps) => new Procedure(row));
264
287
  }
@@ -59,7 +59,25 @@ export function diffRlsPolicies(
59
59
  {},
60
60
  );
61
61
 
62
- if (nonAlterablePropsChanged) {
62
+ // The set of relations and procedures that the policy's USING / WITH
63
+ // CHECK expressions reference is recorded by PostgreSQL in pg_depend
64
+ // (recordDependencyOnExpr at policy creation). When that set changes
65
+ // it is unsafe to ALTER POLICY in place: the old reference target may
66
+ // be dropped in the same plan, and the new reference target may only
67
+ // exist after the create phase. Drop+create lets the sort phase order
68
+ // the policy's drop before the referenced object's drop and the
69
+ // policy's recreate after the referenced object's create.
70
+ const referencedDependenciesChanged = hasNonAlterableChanges(
71
+ mainRlsPolicy,
72
+ branchRlsPolicy,
73
+ ["referenced_procedures", "referenced_relations"] as const,
74
+ {
75
+ referenced_procedures: deepEqual,
76
+ referenced_relations: deepEqual,
77
+ },
78
+ );
79
+
80
+ if (nonAlterablePropsChanged || referencedDependenciesChanged) {
63
81
  // Replace the entire RLS policy (drop + create)
64
82
  changes.push(
65
83
  new DropRlsPolicy({ policy: mainRlsPolicy }),
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Pool } from "pg";
3
+ import { extractRules, Rule } from "./rule.model.ts";
4
+
5
+ const baseRow = {
6
+ schema: "public",
7
+ table_name: '"events"',
8
+ relation_kind: "r" as const,
9
+ event: "INSERT" as const,
10
+ enabled: "O" as const,
11
+ is_instead: false,
12
+ owner: "postgres",
13
+ comment: null,
14
+ columns: [] as string[],
15
+ };
16
+
17
+ const mockPool = (rows: unknown[]): Pool =>
18
+ ({ query: async () => ({ rows }) }) as unknown as Pool;
19
+
20
+ const mockPoolSequence = (...attempts: unknown[][]): Pool => {
21
+ let i = 0;
22
+ return {
23
+ query: async () => ({
24
+ rows: attempts[Math.min(i++, attempts.length - 1)],
25
+ }),
26
+ } as unknown as Pool;
27
+ };
28
+
29
+ const NO_BACKOFF = { backoffMs: 0 } as const;
30
+
31
+ describe("extractRules", () => {
32
+ test("skips rows where pg_get_ruledef returned NULL after exhausting retries", async () => {
33
+ const rules = await extractRules(
34
+ mockPool([
35
+ {
36
+ ...baseRow,
37
+ name: '"good_rule"',
38
+ definition:
39
+ "CREATE RULE good_rule AS ON INSERT TO events DO INSTEAD NOTHING;",
40
+ },
41
+ { ...baseRow, name: '"orphan_rule"', definition: null },
42
+ ]),
43
+ NO_BACKOFF,
44
+ );
45
+
46
+ expect(rules).toHaveLength(1);
47
+ expect(rules[0]).toBeInstanceOf(Rule);
48
+ expect(rules[0]?.name).toBe('"good_rule"');
49
+ });
50
+
51
+ test("does not throw ZodError when the only row has a null definition", async () => {
52
+ await expect(
53
+ extractRules(
54
+ mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
55
+ NO_BACKOFF,
56
+ ),
57
+ ).resolves.toEqual([]);
58
+ });
59
+
60
+ test("returns all rules when every row has a valid definition", async () => {
61
+ const rules = await extractRules(
62
+ mockPool([
63
+ {
64
+ ...baseRow,
65
+ name: '"a"',
66
+ definition:
67
+ "CREATE RULE a AS ON INSERT TO events DO INSTEAD NOTHING;",
68
+ },
69
+ {
70
+ ...baseRow,
71
+ name: '"b"',
72
+ definition:
73
+ "CREATE RULE b AS ON UPDATE TO events DO INSTEAD NOTHING;",
74
+ },
75
+ ]),
76
+ NO_BACKOFF,
77
+ );
78
+ expect(rules.map((r) => r.name)).toEqual(['"a"', '"b"']);
79
+ });
80
+
81
+ test("recovers when pg_get_ruledef is NULL on first attempt but resolved on retry", async () => {
82
+ const rules = await extractRules(
83
+ mockPoolSequence(
84
+ [{ ...baseRow, name: '"racy_rule"', definition: null }],
85
+ [
86
+ {
87
+ ...baseRow,
88
+ name: '"racy_rule"',
89
+ definition:
90
+ "CREATE RULE racy_rule AS ON INSERT TO events DO INSTEAD NOTHING;",
91
+ },
92
+ ],
93
+ ),
94
+ { retries: 2, backoffMs: 0 },
95
+ );
96
+ expect(rules).toHaveLength(1);
97
+ expect(rules[0]?.name).toBe('"racy_rule"');
98
+ });
99
+ });
@@ -2,6 +2,10 @@ import { sql } from "@ts-safeql/sql-tag";
2
2
  import type { Pool } from "pg";
3
3
  import z from "zod";
4
4
  import { BasePgModel } from "../base.model.ts";
5
+ import {
6
+ type ExtractRetryOptions,
7
+ extractWithDefinitionRetry,
8
+ } from "../extract-with-retry.ts";
5
9
  import { stableId } from "../utils.ts";
6
10
 
7
11
  const RuleEventSchema = z.enum(["SELECT", "INSERT", "UPDATE", "DELETE"]);
@@ -29,6 +33,15 @@ const rulePropsSchema = z.object({
29
33
  columns: z.array(z.string()),
30
34
  });
31
35
 
36
+ // pg_get_ruledef(oid, pretty) can return NULL when the rule (its pg_rewrite
37
+ // row) is dropped between catalog scan and resolution, or under transient
38
+ // catalog state. An unreadable rule cannot be diffed, so we accept NULL here
39
+ // and filter the row out at extraction time rather than crashing the whole
40
+ // catalog parse with a ZodError.
41
+ const ruleRowSchema = rulePropsSchema.extend({
42
+ definition: z.string().nullable(),
43
+ });
44
+
32
45
  export type RuleEnabledState = z.infer<typeof RuleEnabledStateSchema>;
33
46
  export type RuleProps = z.infer<typeof rulePropsSchema>;
34
47
 
@@ -97,8 +110,16 @@ export class Rule extends BasePgModel {
97
110
  }
98
111
  }
99
112
 
100
- export async function extractRules(pool: Pool): Promise<Rule[]> {
101
- const { rows: ruleRows } = await pool.query<RuleProps>(sql`
113
+ export async function extractRules(
114
+ pool: Pool,
115
+ options?: ExtractRetryOptions,
116
+ ): Promise<Rule[]> {
117
+ const ruleRows = await extractWithDefinitionRetry({
118
+ label: "rules",
119
+ options,
120
+ hasNullDefinition: (row) => row.definition === null,
121
+ query: async () => {
122
+ const result = await pool.query<RuleProps>(sql`
102
123
  WITH extension_rule_oids AS (
103
124
  SELECT
104
125
  objid
@@ -164,9 +185,12 @@ export async function extractRules(pool: Pool): Promise<Rule[]> {
164
185
  ORDER BY
165
186
  1, 3, 2
166
187
  `);
188
+ return result.rows.map((row: unknown) => ruleRowSchema.parse(row));
189
+ },
190
+ });
167
191
 
168
- const validatedRows = ruleRows.map((row: unknown) =>
169
- rulePropsSchema.parse(row),
192
+ const validatedRows = ruleRows.filter(
193
+ (row): row is RuleProps => row.definition !== null,
170
194
  );
171
195
 
172
196
  return validatedRows.map((row) => new Rule(row));
@@ -327,6 +327,93 @@ describe.concurrent("sequence.diff", () => {
327
327
  expect(changes).toHaveLength(0);
328
328
  });
329
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
+
330
417
  test("create with comment emits CreateCommentOnSequence", () => {
331
418
  const s = new Sequence({ ...base, comment: "my seq" });
332
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
 
@@ -157,12 +159,35 @@ export function diffSequences(
157
159
  NON_ALTERABLE_FIELDS,
158
160
  );
159
161
 
160
- if (nonAlterablePropsChanged) {
161
- // Replace the entire sequence (drop + create)
162
- changes.push(
163
- new DropSequence({ sequence: mainSequence }),
164
- new CreateSequence({ sequence: branchSequence }),
165
- );
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 }));
166
191
  // Re-apply OWNED BY if present on branch
167
192
  if (
168
193
  branchSequence.owned_by_schema !== null &&