@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
@@ -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
 
6
10
  const TableRelkindSchema = z.enum([
7
11
  "r", // table (regular relation)
@@ -163,7 +167,11 @@ export class Index extends BasePgModel {
163
167
  nulls_not_distinct: this.nulls_not_distinct,
164
168
  immediate: this.immediate,
165
169
  is_clustered: this.is_clustered,
166
- is_replica_identity: this.is_replica_identity,
170
+ // is_replica_identity excluded: the table's `replica_identity` /
171
+ // `replica_identity_index` is the source of truth, set via
172
+ // ALTER TABLE ... REPLICA IDENTITY USING INDEX. Including this flag here
173
+ // would trigger spurious DROP+CREATE of the index whenever the table's
174
+ // replica identity changes.
167
175
  // key_columns excluded: contains attribute numbers that can differ between databases
168
176
  // even when indexes are logically identical. The definition field already captures
169
177
  // the logical structure using column names, so we compare by definition instead.
@@ -215,8 +223,16 @@ export class Index extends BasePgModel {
215
223
  }
216
224
  }
217
225
 
218
- export async function extractIndexes(pool: Pool): Promise<Index[]> {
219
- const { rows: indexRows } = await pool.query<IndexProps>(sql`
226
+ export async function extractIndexes(
227
+ pool: Pool,
228
+ options?: ExtractRetryOptions,
229
+ ): Promise<Index[]> {
230
+ const indexRows = await extractWithDefinitionRetry({
231
+ label: "indexes",
232
+ options,
233
+ hasNullDefinition: (row) => row.definition === null,
234
+ query: async () => {
235
+ const result = await pool.query<IndexProps>(sql`
220
236
  with extension_oids as (
221
237
  select objid
222
238
  from pg_depend d
@@ -372,8 +388,11 @@ export async function extractIndexes(pool: Pool): Promise<Index[]> {
372
388
 
373
389
  order by 1, 2
374
390
  `);
375
- const validatedRows = indexRows
376
- .map((row: unknown) => indexRowSchema.parse(row))
377
- .filter((row): row is IndexProps => row.definition !== null);
391
+ return result.rows.map((row: unknown) => indexRowSchema.parse(row));
392
+ },
393
+ });
394
+ const validatedRows = indexRows.filter(
395
+ (row): row is IndexProps => row.definition !== null,
396
+ );
378
397
  return validatedRows.map((row: IndexProps) => new Index(row));
379
398
  }
@@ -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
  }
@@ -128,7 +128,7 @@ export class AlterPublicationAddTables extends AlterPublicationChange {
128
128
  export class AlterPublicationDropTables extends AlterPublicationChange {
129
129
  public readonly publication: Publication;
130
130
  public readonly scope = "object" as const;
131
- private readonly tables: PublicationTableProps[];
131
+ public readonly tables: PublicationTableProps[];
132
132
 
133
133
  constructor(props: {
134
134
  publication: Publication;
@@ -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));