@supabase/pg-delta 1.0.0-alpha.18 → 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.
@@ -1,5 +1,7 @@
1
1
  import { CreateDomain } from "./objects/domain/changes/domain.create.js";
2
2
  import { DropDomain } from "./objects/domain/changes/domain.drop.js";
3
+ import { CreateIndex } from "./objects/index/changes/index.create.js";
4
+ import { DropIndex } from "./objects/index/changes/index.drop.js";
3
5
  import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.js";
4
6
  import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.js";
5
7
  import { CreateProcedure } from "./objects/procedure/changes/procedure.create.js";
@@ -222,6 +224,18 @@ function resolveObjectForStableId(stableId, mainCatalog, branchCatalog) {
222
224
  const branch = branchCatalog.materializedViews[stableId];
223
225
  return main && branch ? { kind: "materialized_view", main, branch } : null;
224
226
  }
227
+ if (stableId.startsWith("index:")) {
228
+ const main = mainCatalog.indexes[stableId];
229
+ const branch = branchCatalog.indexes[stableId];
230
+ return main && branch
231
+ ? {
232
+ kind: "index",
233
+ main,
234
+ branch,
235
+ branchIndexableObject: branchCatalog.indexableObjects[branch.tableStableId],
236
+ }
237
+ : null;
238
+ }
225
239
  if (stableId.startsWith("procedure:")) {
226
240
  const main = mainCatalog.procedures[stableId];
227
241
  const branch = branchCatalog.procedures[stableId];
@@ -307,6 +321,34 @@ function buildReplaceChanges(resolved, options) {
307
321
  ? [new CreateMaterializedView({ materializedView: resolved.branch })]
308
322
  : []),
309
323
  ];
324
+ case "index":
325
+ // Constraint-owned, primary, and partition-attached indexes are managed
326
+ // by the owning constraint or parent-index DDL, not standalone
327
+ // CREATE INDEX / DROP INDEX. The `case "table":` branch above already
328
+ // recreates constraints via AlterTableAddConstraint; emitting a
329
+ // standalone drop/create here would fail in PostgreSQL
330
+ // ("cannot drop index ... because constraint ... requires it") or
331
+ // duplicate the index the constraint recreates. Skip matches
332
+ // diffIndexes (packages/pg-delta/src/core/objects/index/index.diff.ts).
333
+ if (resolved.main.is_owned_by_constraint ||
334
+ resolved.main.is_primary ||
335
+ resolved.main.is_index_partition ||
336
+ resolved.branch.is_owned_by_constraint ||
337
+ resolved.branch.is_primary ||
338
+ resolved.branch.is_index_partition) {
339
+ return null;
340
+ }
341
+ return [
342
+ ...(addDrop ? [new DropIndex({ index: resolved.main })] : []),
343
+ ...(addCreate
344
+ ? [
345
+ new CreateIndex({
346
+ index: resolved.branch,
347
+ indexableObject: resolved.branchIndexableObject,
348
+ }),
349
+ ]
350
+ : []),
351
+ ];
310
352
  case "procedure":
311
353
  return [
312
354
  ...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []),
@@ -36,6 +36,15 @@ const indexPropsSchema = z.object({
36
36
  comment: z.string().nullable(),
37
37
  owner: z.string(),
38
38
  });
39
+ // pg_get_indexdef(oid, colno, pretty) invokes pg_get_indexdef_worker with
40
+ // missing_ok = true, so it can return NULL when any internal system-cache lookup
41
+ // fails (race with concurrent DROP, role visibility edge cases, orphaned index
42
+ // metadata, recovery transients). An unreadable index cannot be diffed, so we
43
+ // accept NULL here and filter the row out with a debug log instead of crashing
44
+ // the whole catalog extraction.
45
+ const indexRowSchema = indexPropsSchema.extend({
46
+ definition: z.string().nullable(),
47
+ });
39
48
  export class Index extends BasePgModel {
40
49
  schema;
41
50
  table_name;
@@ -332,7 +341,8 @@ export async function extractIndexes(pool) {
332
341
 
333
342
  order by 1, 2
334
343
  `);
335
- // Validate and parse each row using the Zod schema
336
- const validatedRows = indexRows.map((row) => indexPropsSchema.parse(row));
344
+ const validatedRows = indexRows
345
+ .map((row) => indexRowSchema.parse(row))
346
+ .filter((row) => row.definition !== null);
337
347
  return validatedRows.map((row) => new Index(row));
338
348
  }
@@ -33,6 +33,29 @@ export class CreateRlsPolicy extends CreateRlsPolicyChange {
33
33
  dependencies.add(stableId.table(this.policy.schema, this.policy.table_name));
34
34
  // Owner dependency
35
35
  dependencies.add(stableId.role(this.policy.owner));
36
+ // Relations and functions referenced inside USING / WITH CHECK
37
+ // expressions must exist before the policy is created. These come from
38
+ // pg_depend (populated by PostgreSQL's recordDependencyOnExpr at policy
39
+ // creation), not from re-parsing the expression text.
40
+ for (const ref of this.policy.referenced_relations) {
41
+ switch (ref.kind) {
42
+ case "table":
43
+ dependencies.add(stableId.table(ref.schema, ref.name));
44
+ break;
45
+ case "view":
46
+ dependencies.add(stableId.view(ref.schema, ref.name));
47
+ break;
48
+ case "materialized_view":
49
+ dependencies.add(stableId.materializedView(ref.schema, ref.name));
50
+ break;
51
+ case "foreign_table":
52
+ dependencies.add(stableId.foreignTable(ref.schema, ref.name));
53
+ break;
54
+ }
55
+ }
56
+ for (const ref of this.policy.referenced_procedures) {
57
+ dependencies.add(stableId.procedure(ref.schema, ref.name, ref.argument_types.join(",")));
58
+ }
36
59
  return Array.from(dependencies);
37
60
  }
38
61
  serialize(_options) {
@@ -1,6 +1,23 @@
1
1
  import type { Pool } from "pg";
2
2
  import z from "zod";
3
3
  import { BasePgModel } from "../base.model.ts";
4
+ declare const rlsPolicyReferencedRelationSchema: z.ZodObject<{
5
+ kind: z.ZodEnum<{
6
+ foreign_table: "foreign_table";
7
+ materialized_view: "materialized_view";
8
+ table: "table";
9
+ view: "view";
10
+ }>;
11
+ schema: z.ZodString;
12
+ name: z.ZodString;
13
+ }, z.z.core.$strip>;
14
+ export type RlsPolicyReferencedRelation = z.infer<typeof rlsPolicyReferencedRelationSchema>;
15
+ declare const rlsPolicyReferencedProcedureSchema: z.ZodObject<{
16
+ schema: z.ZodString;
17
+ name: z.ZodString;
18
+ argument_types: z.ZodArray<z.ZodString>;
19
+ }, z.z.core.$strip>;
20
+ export type RlsPolicyReferencedProcedure = z.infer<typeof rlsPolicyReferencedProcedureSchema>;
4
21
  declare const rlsPolicyPropsSchema: z.ZodObject<{
5
22
  schema: z.ZodString;
6
23
  name: z.ZodString;
@@ -18,6 +35,21 @@ declare const rlsPolicyPropsSchema: z.ZodObject<{
18
35
  with_check_expression: z.ZodNullable<z.ZodString>;
19
36
  owner: z.ZodString;
20
37
  comment: z.ZodNullable<z.ZodString>;
38
+ referenced_relations: z.ZodArray<z.ZodObject<{
39
+ kind: z.ZodEnum<{
40
+ foreign_table: "foreign_table";
41
+ materialized_view: "materialized_view";
42
+ table: "table";
43
+ view: "view";
44
+ }>;
45
+ schema: z.ZodString;
46
+ name: z.ZodString;
47
+ }, z.z.core.$strip>>;
48
+ referenced_procedures: z.ZodArray<z.ZodObject<{
49
+ schema: z.ZodString;
50
+ name: z.ZodString;
51
+ argument_types: z.ZodArray<z.ZodString>;
52
+ }, z.z.core.$strip>>;
21
53
  }, z.z.core.$strip>;
22
54
  export type RlsPolicyProps = z.infer<typeof rlsPolicyPropsSchema>;
23
55
  export declare class RlsPolicy extends BasePgModel {
@@ -31,6 +63,23 @@ export declare class RlsPolicy extends BasePgModel {
31
63
  readonly with_check_expression: RlsPolicyProps["with_check_expression"];
32
64
  readonly owner: RlsPolicyProps["owner"];
33
65
  readonly comment: RlsPolicyProps["comment"];
66
+ /**
67
+ * Tables / views / materialized views / foreign tables that
68
+ * `using_expression` / `with_check_expression` reference, sourced from
69
+ * `pg_depend` (`recordDependencyOnExpr` at policy creation). Drives
70
+ * ordering dependencies in `CreateRlsPolicy.requires`. Intentionally
71
+ * excluded from `dataFields` — it's derived from the expression text
72
+ * and changes lockstep with it.
73
+ */
74
+ readonly referenced_relations: RlsPolicyProps["referenced_relations"];
75
+ /**
76
+ * Functions / procedures that `using_expression` / `with_check_expression`
77
+ * reference, sourced from `pg_depend` (refclassid = `pg_proc`). The
78
+ * argument-type signature comes straight from `pg_proc.proargtypes` via
79
+ * `format_type`, so it matches the signature the procedure extractor
80
+ * embeds in `stableId.procedure(...)`. Not part of `dataFields`.
81
+ */
82
+ readonly referenced_procedures: RlsPolicyProps["referenced_procedures"];
34
83
  constructor(props: RlsPolicyProps);
35
84
  get stableId(): `rlsPolicy:${string}`;
36
85
  get identityFields(): {
@@ -8,6 +8,22 @@ const RlsPolicyCommandSchema = z.enum([
8
8
  "d", // DELETE command
9
9
  "*", // ALL commands
10
10
  ]);
11
+ const RlsPolicyReferencedRelationKindSchema = z.enum([
12
+ "table",
13
+ "view",
14
+ "materialized_view",
15
+ "foreign_table",
16
+ ]);
17
+ const rlsPolicyReferencedRelationSchema = z.object({
18
+ kind: RlsPolicyReferencedRelationKindSchema,
19
+ schema: z.string(),
20
+ name: z.string(),
21
+ });
22
+ const rlsPolicyReferencedProcedureSchema = z.object({
23
+ schema: z.string(),
24
+ name: z.string(),
25
+ argument_types: z.array(z.string()),
26
+ });
11
27
  const rlsPolicyPropsSchema = z.object({
12
28
  schema: z.string(),
13
29
  name: z.string(),
@@ -19,6 +35,8 @@ const rlsPolicyPropsSchema = z.object({
19
35
  with_check_expression: z.string().nullable(),
20
36
  owner: z.string(),
21
37
  comment: z.string().nullable(),
38
+ referenced_relations: z.array(rlsPolicyReferencedRelationSchema),
39
+ referenced_procedures: z.array(rlsPolicyReferencedProcedureSchema),
22
40
  });
23
41
  export class RlsPolicy extends BasePgModel {
24
42
  schema;
@@ -31,6 +49,23 @@ export class RlsPolicy extends BasePgModel {
31
49
  with_check_expression;
32
50
  owner;
33
51
  comment;
52
+ /**
53
+ * Tables / views / materialized views / foreign tables that
54
+ * `using_expression` / `with_check_expression` reference, sourced from
55
+ * `pg_depend` (`recordDependencyOnExpr` at policy creation). Drives
56
+ * ordering dependencies in `CreateRlsPolicy.requires`. Intentionally
57
+ * excluded from `dataFields` — it's derived from the expression text
58
+ * and changes lockstep with it.
59
+ */
60
+ referenced_relations;
61
+ /**
62
+ * Functions / procedures that `using_expression` / `with_check_expression`
63
+ * reference, sourced from `pg_depend` (refclassid = `pg_proc`). The
64
+ * argument-type signature comes straight from `pg_proc.proargtypes` via
65
+ * `format_type`, so it matches the signature the procedure extractor
66
+ * embeds in `stableId.procedure(...)`. Not part of `dataFields`.
67
+ */
68
+ referenced_procedures;
34
69
  constructor(props) {
35
70
  super();
36
71
  // Identity fields
@@ -45,6 +80,9 @@ export class RlsPolicy extends BasePgModel {
45
80
  this.with_check_expression = props.with_check_expression;
46
81
  this.owner = props.owner;
47
82
  this.comment = props.comment;
83
+ // Derived metadata (not part of equality)
84
+ this.referenced_relations = props.referenced_relations;
85
+ this.referenced_procedures = props.referenced_procedures;
48
86
  }
49
87
  get stableId() {
50
88
  return `rlsPolicy:${this.schema}.${this.table_name}.${this.name}`;
@@ -88,6 +126,59 @@ extension_table_oids as (
88
126
  d.refclassid = 'pg_extension'::regclass
89
127
  and d.classid = 'pg_class'::regclass
90
128
  and d.deptype = 'e'
129
+ ),
130
+ policy_relation_deps as (
131
+ -- Relations referenced inside polqual / polwithcheck. PostgreSQL records
132
+ -- these via recordDependencyOnExpr(..., DEPENDENCY_NORMAL = 'n') at
133
+ -- CREATE POLICY time, so pg_depend is authoritative and we don't need to
134
+ -- re-parse the expression text. Covers regular tables, partitioned
135
+ -- tables, views, materialized views, and foreign tables — any relation
136
+ -- kind the policy can reference in a subquery.
137
+ select distinct
138
+ d.objid as policy_oid,
139
+ case ref_c.relkind
140
+ when 'r' then 'table'
141
+ when 'p' then 'table'
142
+ when 'v' then 'view'
143
+ when 'm' then 'materialized_view'
144
+ when 'f' then 'foreign_table'
145
+ end as ref_kind,
146
+ ref_ns.nspname as ref_schema,
147
+ ref_c.relname as ref_name
148
+ from
149
+ pg_depend d
150
+ join pg_policy p on p.oid = d.objid
151
+ join pg_class ref_c on ref_c.oid = d.refobjid
152
+ join pg_namespace ref_ns on ref_ns.oid = ref_c.relnamespace
153
+ where
154
+ d.classid = 'pg_policy'::regclass
155
+ and d.refclassid = 'pg_class'::regclass
156
+ and d.deptype = 'n'
157
+ and ref_c.relkind in ('r', 'p', 'v', 'm', 'f')
158
+ and d.refobjid <> p.polrelid
159
+ ),
160
+ policy_procedure_deps as (
161
+ -- Functions / procedures referenced inside polqual / polwithcheck. Same
162
+ -- pg_depend mechanism as above, just refclassid = pg_proc. proargtypes
163
+ -- formatted via format_type(oid, null) matches the signature produced by
164
+ -- the procedure extractor (see procedure.model.ts), so stableId.procedure
165
+ -- on both sides of the diff lines up exactly.
166
+ select distinct
167
+ d.objid as policy_oid,
168
+ ref_ns.nspname as ref_schema,
169
+ ref_p.proname as ref_name,
170
+ array(
171
+ select format_type(oid, null)
172
+ from unnest(ref_p.proargtypes) as oid
173
+ ) as ref_argument_types
174
+ from
175
+ pg_depend d
176
+ join pg_proc ref_p on ref_p.oid = d.refobjid
177
+ join pg_namespace ref_ns on ref_ns.oid = ref_p.pronamespace
178
+ where
179
+ d.classid = 'pg_policy'::regclass
180
+ and d.refclassid = 'pg_proc'::regclass
181
+ and d.deptype = 'n'
91
182
  )
92
183
  select
93
184
  tc.relnamespace::regnamespace::text as schema,
@@ -107,7 +198,37 @@ select
107
198
  pg_get_expr(p.polqual, p.polrelid) as using_expression,
108
199
  pg_get_expr(p.polwithcheck, p.polrelid) as with_check_expression,
109
200
  tc.relowner::regrole::text as owner,
110
- obj_description(p.oid, 'pg_policy') as comment
201
+ obj_description(p.oid, 'pg_policy') as comment,
202
+ coalesce(
203
+ (
204
+ select json_agg(
205
+ json_build_object(
206
+ 'kind', prd.ref_kind,
207
+ 'schema', prd.ref_schema,
208
+ 'name', prd.ref_name
209
+ )
210
+ order by prd.ref_schema, prd.ref_name
211
+ )
212
+ from policy_relation_deps prd
213
+ where prd.policy_oid = p.oid
214
+ ),
215
+ '[]'
216
+ ) as referenced_relations,
217
+ coalesce(
218
+ (
219
+ select json_agg(
220
+ json_build_object(
221
+ 'schema', ppd.ref_schema,
222
+ 'name', ppd.ref_name,
223
+ 'argument_types', ppd.ref_argument_types
224
+ )
225
+ order by ppd.ref_schema, ppd.ref_name, ppd.ref_argument_types
226
+ )
227
+ from policy_procedure_deps ppd
228
+ where ppd.policy_oid = p.oid
229
+ ),
230
+ '[]'
231
+ ) as referenced_procedures
111
232
  from
112
233
  pg_catalog.pg_policy p
113
234
  inner join pg_catalog.pg_class tc on tc.oid = p.polrelid
@@ -696,6 +696,8 @@ const rlsPolicy = new RlsPolicy({
696
696
  with_check_expression: null,
697
697
  owner: "owner1",
698
698
  comment: "rls policy comment",
699
+ referenced_relations: [],
700
+ referenced_procedures: [],
699
701
  });
700
702
  const rlsPolicyRestrictive = new RlsPolicy({
701
703
  schema: "public",
@@ -708,6 +710,8 @@ const rlsPolicyRestrictive = new RlsPolicy({
708
710
  with_check_expression: "status <> 'locked'",
709
711
  owner: "owner1",
710
712
  comment: null,
713
+ referenced_relations: [],
714
+ referenced_procedures: [],
711
715
  });
712
716
  const index = new Index({
713
717
  schema: "public",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.18",
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({
@@ -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];
@@ -346,6 +354,20 @@ function resolveObjectForStableId(
346
354
  return main && branch ? { kind: "materialized_view", main, branch } : null;
347
355
  }
348
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
+
349
371
  if (stableId.startsWith("procedure:")) {
350
372
  const main = mainCatalog.procedures[stableId];
351
373
  const branch = branchCatalog.procedures[stableId];
@@ -447,6 +469,36 @@ function buildReplaceChanges(
447
469
  ? [new CreateMaterializedView({ materializedView: resolved.branch })]
448
470
  : []),
449
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
+ ];
450
502
  case "procedure":
451
503
  return [
452
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
  }
@@ -23,6 +23,8 @@ describe.concurrent("rls-policy", () => {
23
23
  with_check_expression: null,
24
24
  owner: "owner",
25
25
  comment: null,
26
+ referenced_relations: [],
27
+ referenced_procedures: [],
26
28
  };
27
29
  const policy = new RlsPolicy({
28
30
  ...props,
@@ -52,6 +54,8 @@ describe.concurrent("rls-policy", () => {
52
54
  with_check_expression: null,
53
55
  owner: "owner",
54
56
  comment: null,
57
+ referenced_relations: [],
58
+ referenced_procedures: [],
55
59
  };
56
60
  const policy = new RlsPolicy({
57
61
  ...props,
@@ -81,6 +85,8 @@ describe.concurrent("rls-policy", () => {
81
85
  with_check_expression: null,
82
86
  owner: "owner",
83
87
  comment: null,
88
+ referenced_relations: [],
89
+ referenced_procedures: [],
84
90
  };
85
91
  const main = new RlsPolicy({
86
92
  ...props,
@@ -120,6 +126,8 @@ describe.concurrent("rls-policy", () => {
120
126
  with_check_expression: null,
121
127
  owner: "owner",
122
128
  comment: null,
129
+ referenced_relations: [],
130
+ referenced_procedures: [],
123
131
  };
124
132
  const main = new RlsPolicy({
125
133
  ...props,
@@ -159,6 +167,8 @@ describe.concurrent("rls-policy", () => {
159
167
  with_check_expression: null,
160
168
  owner: "test",
161
169
  comment: null,
170
+ referenced_relations: [],
171
+ referenced_procedures: [],
162
172
  };
163
173
  const policy = new RlsPolicy({
164
174
  ...props,
@@ -188,6 +198,8 @@ describe.concurrent("rls-policy", () => {
188
198
  with_check_expression: null,
189
199
  owner: "test",
190
200
  comment: null,
201
+ referenced_relations: [],
202
+ referenced_procedures: [],
191
203
  };
192
204
  const policy = new RlsPolicy({
193
205
  ...props,
@@ -217,6 +229,8 @@ describe.concurrent("rls-policy", () => {
217
229
  using_expression: "expr",
218
230
  owner: "test",
219
231
  comment: null,
232
+ referenced_relations: [],
233
+ referenced_procedures: [],
220
234
  };
221
235
  const policy = new RlsPolicy({
222
236
  ...props,
@@ -246,6 +260,8 @@ describe.concurrent("rls-policy", () => {
246
260
  using_expression: "expr",
247
261
  owner: "test",
248
262
  comment: null,
263
+ referenced_relations: [],
264
+ referenced_procedures: [],
249
265
  };
250
266
  const policy = new RlsPolicy({
251
267
  ...props,
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { assertValidSql } from "../../../test-utils/assert-valid-sql.ts";
3
+ import { stableId } from "../../utils.ts";
3
4
  import { RlsPolicy } from "../rls-policy.model.ts";
4
5
  import { CreateRlsPolicy } from "./rls-policy.create.ts";
5
6
 
@@ -16,6 +17,8 @@ describe("rls-policy", () => {
16
17
  with_check_expression: null,
17
18
  owner: "test",
18
19
  comment: null,
20
+ referenced_relations: [],
21
+ referenced_procedures: [],
19
22
  });
20
23
 
21
24
  const change = new CreateRlsPolicy({
@@ -41,6 +44,8 @@ describe("rls-policy", () => {
41
44
  with_check_expression: null,
42
45
  owner: "test",
43
46
  comment: null,
47
+ referenced_relations: [],
48
+ referenced_procedures: [],
44
49
  });
45
50
 
46
51
  const change = new CreateRlsPolicy({
@@ -66,6 +71,8 @@ describe("rls-policy", () => {
66
71
  with_check_expression: "expr2",
67
72
  owner: "test",
68
73
  comment: null,
74
+ referenced_relations: [],
75
+ referenced_procedures: [],
69
76
  });
70
77
 
71
78
  const change = new CreateRlsPolicy({
@@ -78,4 +85,125 @@ describe("rls-policy", () => {
78
85
  "CREATE POLICY test_policy_all ON public.test_table AS RESTRICTIVE FOR UPDATE TO role1, role2 USING (expr1) WITH CHECK (expr2)",
79
86
  );
80
87
  });
88
+
89
+ test("requires referenced relations reported by pg_depend", () => {
90
+ const policy = new RlsPolicy({
91
+ schema: "app",
92
+ name: "cross_relation_policy",
93
+ table_name: "accounts",
94
+ command: "r",
95
+ permissive: true,
96
+ roles: ["public"],
97
+ using_expression:
98
+ "(EXISTS (SELECT 1 FROM app.users) AND EXISTS (SELECT 1 FROM app.active_accounts))",
99
+ with_check_expression:
100
+ "(id IN (SELECT account_id FROM app.memberships WHERE active))",
101
+ owner: "test",
102
+ comment: null,
103
+ referenced_relations: [
104
+ { kind: "table", schema: "app", name: "users" },
105
+ { kind: "table", schema: "app", name: "memberships" },
106
+ { kind: "view", schema: "app", name: "active_accounts" },
107
+ { kind: "materialized_view", schema: "app", name: "account_stats" },
108
+ { kind: "foreign_table", schema: "app", name: "remote_profiles" },
109
+ ],
110
+ referenced_procedures: [],
111
+ });
112
+
113
+ const change = new CreateRlsPolicy({ policy });
114
+
115
+ expect(change.requires).toContain(stableId.table("app", "users"));
116
+ expect(change.requires).toContain(stableId.table("app", "memberships"));
117
+ expect(change.requires).toContain(stableId.view("app", "active_accounts"));
118
+ expect(change.requires).toContain(
119
+ stableId.materializedView("app", "account_stats"),
120
+ );
121
+ expect(change.requires).toContain(
122
+ stableId.foreignTable("app", "remote_profiles"),
123
+ );
124
+ });
125
+
126
+ test("requires referenced procedures reported by pg_depend", () => {
127
+ const policy = new RlsPolicy({
128
+ schema: "app",
129
+ name: "function_guarded_policy",
130
+ table_name: "accounts",
131
+ command: "r",
132
+ permissive: true,
133
+ roles: ["public"],
134
+ using_expression: "public.is_admin()",
135
+ with_check_expression: null,
136
+ owner: "test",
137
+ comment: null,
138
+ referenced_relations: [],
139
+ referenced_procedures: [
140
+ { schema: "public", name: "is_admin", argument_types: [] },
141
+ {
142
+ schema: "public",
143
+ name: "has_role",
144
+ argument_types: ["text", "integer"],
145
+ },
146
+ ],
147
+ });
148
+
149
+ const change = new CreateRlsPolicy({ policy });
150
+
151
+ expect(change.requires).toContain(stableId.procedure("public", "is_admin"));
152
+ expect(change.requires).toContain(
153
+ stableId.procedure("public", "has_role", "text,integer"),
154
+ );
155
+ });
156
+
157
+ test("does not require additional objects when referenced lists are empty", () => {
158
+ const policy = new RlsPolicy({
159
+ schema: "app",
160
+ name: "simple_policy",
161
+ table_name: "accounts",
162
+ command: "*",
163
+ permissive: true,
164
+ roles: [],
165
+ using_expression: null,
166
+ with_check_expression: null,
167
+ owner: "test",
168
+ comment: null,
169
+ referenced_relations: [],
170
+ referenced_procedures: [],
171
+ });
172
+
173
+ const change = new CreateRlsPolicy({ policy });
174
+
175
+ expect(change.requires).toEqual([
176
+ stableId.schema("app"),
177
+ stableId.table("app", "accounts"),
178
+ stableId.role("test"),
179
+ ]);
180
+ });
181
+
182
+ // Sequences referenced via nextval() are a known gap. pg_depend only
183
+ // records the sequence edge when the argument is written as a regclass
184
+ // literal (e.g. `nextval('app.seq'::regclass)`); bare string literals
185
+ // produce no pg_depend row. Tracked in
186
+ // https://github.com/supabase/pg-toolbelt/issues/220.
187
+ test.skip("requires referenced sequences (follow-up)", () => {
188
+ const policy = new RlsPolicy({
189
+ schema: "app",
190
+ name: "sequence_policy",
191
+ table_name: "accounts",
192
+ command: "r",
193
+ permissive: true,
194
+ roles: ["public"],
195
+ using_expression: "id < nextval('app.next_id'::regclass)",
196
+ with_check_expression: null,
197
+ owner: "test",
198
+ comment: null,
199
+ referenced_relations: [],
200
+ referenced_procedures: [],
201
+ });
202
+
203
+ const change = new CreateRlsPolicy({ policy });
204
+
205
+ // Expected once the gap is closed:
206
+ // expect(change.requires).toContain(stableId.sequence("app", "next_id"));
207
+ expect(change.requires.length).toBeGreaterThan(0);
208
+ });
81
209
  });
@@ -45,6 +45,33 @@ export class CreateRlsPolicy extends CreateRlsPolicyChange {
45
45
  // Owner dependency
46
46
  dependencies.add(stableId.role(this.policy.owner));
47
47
 
48
+ // Relations and functions referenced inside USING / WITH CHECK
49
+ // expressions must exist before the policy is created. These come from
50
+ // pg_depend (populated by PostgreSQL's recordDependencyOnExpr at policy
51
+ // creation), not from re-parsing the expression text.
52
+ for (const ref of this.policy.referenced_relations) {
53
+ switch (ref.kind) {
54
+ case "table":
55
+ dependencies.add(stableId.table(ref.schema, ref.name));
56
+ break;
57
+ case "view":
58
+ dependencies.add(stableId.view(ref.schema, ref.name));
59
+ break;
60
+ case "materialized_view":
61
+ dependencies.add(stableId.materializedView(ref.schema, ref.name));
62
+ break;
63
+ case "foreign_table":
64
+ dependencies.add(stableId.foreignTable(ref.schema, ref.name));
65
+ break;
66
+ }
67
+ }
68
+
69
+ for (const ref of this.policy.referenced_procedures) {
70
+ dependencies.add(
71
+ stableId.procedure(ref.schema, ref.name, ref.argument_types.join(",")),
72
+ );
73
+ }
74
+
48
75
  return Array.from(dependencies);
49
76
  }
50
77
 
@@ -16,6 +16,8 @@ describe("rls-policy", () => {
16
16
  with_check_expression: null,
17
17
  owner: "test",
18
18
  comment: null,
19
+ referenced_relations: [],
20
+ referenced_procedures: [],
19
21
  });
20
22
 
21
23
  const change = new DropRlsPolicy({
@@ -20,6 +20,8 @@ const base: RlsPolicyProps = {
20
20
  with_check_expression: null,
21
21
  owner: "o1",
22
22
  comment: null,
23
+ referenced_relations: [],
24
+ referenced_procedures: [],
23
25
  };
24
26
 
25
27
  describe.concurrent("rls-policy.diff", () => {
@@ -11,6 +11,33 @@ const RlsPolicyCommandSchema = z.enum([
11
11
  "*", // ALL commands
12
12
  ]);
13
13
 
14
+ const RlsPolicyReferencedRelationKindSchema = z.enum([
15
+ "table",
16
+ "view",
17
+ "materialized_view",
18
+ "foreign_table",
19
+ ]);
20
+
21
+ const rlsPolicyReferencedRelationSchema = z.object({
22
+ kind: RlsPolicyReferencedRelationKindSchema,
23
+ schema: z.string(),
24
+ name: z.string(),
25
+ });
26
+
27
+ export type RlsPolicyReferencedRelation = z.infer<
28
+ typeof rlsPolicyReferencedRelationSchema
29
+ >;
30
+
31
+ const rlsPolicyReferencedProcedureSchema = z.object({
32
+ schema: z.string(),
33
+ name: z.string(),
34
+ argument_types: z.array(z.string()),
35
+ });
36
+
37
+ export type RlsPolicyReferencedProcedure = z.infer<
38
+ typeof rlsPolicyReferencedProcedureSchema
39
+ >;
40
+
14
41
  const rlsPolicyPropsSchema = z.object({
15
42
  schema: z.string(),
16
43
  name: z.string(),
@@ -22,6 +49,8 @@ const rlsPolicyPropsSchema = z.object({
22
49
  with_check_expression: z.string().nullable(),
23
50
  owner: z.string(),
24
51
  comment: z.string().nullable(),
52
+ referenced_relations: z.array(rlsPolicyReferencedRelationSchema),
53
+ referenced_procedures: z.array(rlsPolicyReferencedProcedureSchema),
25
54
  });
26
55
 
27
56
  export type RlsPolicyProps = z.infer<typeof rlsPolicyPropsSchema>;
@@ -37,6 +66,23 @@ export class RlsPolicy extends BasePgModel {
37
66
  public readonly with_check_expression: RlsPolicyProps["with_check_expression"];
38
67
  public readonly owner: RlsPolicyProps["owner"];
39
68
  public readonly comment: RlsPolicyProps["comment"];
69
+ /**
70
+ * Tables / views / materialized views / foreign tables that
71
+ * `using_expression` / `with_check_expression` reference, sourced from
72
+ * `pg_depend` (`recordDependencyOnExpr` at policy creation). Drives
73
+ * ordering dependencies in `CreateRlsPolicy.requires`. Intentionally
74
+ * excluded from `dataFields` — it's derived from the expression text
75
+ * and changes lockstep with it.
76
+ */
77
+ public readonly referenced_relations: RlsPolicyProps["referenced_relations"];
78
+ /**
79
+ * Functions / procedures that `using_expression` / `with_check_expression`
80
+ * reference, sourced from `pg_depend` (refclassid = `pg_proc`). The
81
+ * argument-type signature comes straight from `pg_proc.proargtypes` via
82
+ * `format_type`, so it matches the signature the procedure extractor
83
+ * embeds in `stableId.procedure(...)`. Not part of `dataFields`.
84
+ */
85
+ public readonly referenced_procedures: RlsPolicyProps["referenced_procedures"];
40
86
 
41
87
  constructor(props: RlsPolicyProps) {
42
88
  super();
@@ -54,6 +100,10 @@ export class RlsPolicy extends BasePgModel {
54
100
  this.with_check_expression = props.with_check_expression;
55
101
  this.owner = props.owner;
56
102
  this.comment = props.comment;
103
+
104
+ // Derived metadata (not part of equality)
105
+ this.referenced_relations = props.referenced_relations;
106
+ this.referenced_procedures = props.referenced_procedures;
57
107
  }
58
108
 
59
109
  get stableId(): `rlsPolicy:${string}` {
@@ -101,6 +151,59 @@ extension_table_oids as (
101
151
  d.refclassid = 'pg_extension'::regclass
102
152
  and d.classid = 'pg_class'::regclass
103
153
  and d.deptype = 'e'
154
+ ),
155
+ policy_relation_deps as (
156
+ -- Relations referenced inside polqual / polwithcheck. PostgreSQL records
157
+ -- these via recordDependencyOnExpr(..., DEPENDENCY_NORMAL = 'n') at
158
+ -- CREATE POLICY time, so pg_depend is authoritative and we don't need to
159
+ -- re-parse the expression text. Covers regular tables, partitioned
160
+ -- tables, views, materialized views, and foreign tables — any relation
161
+ -- kind the policy can reference in a subquery.
162
+ select distinct
163
+ d.objid as policy_oid,
164
+ case ref_c.relkind
165
+ when 'r' then 'table'
166
+ when 'p' then 'table'
167
+ when 'v' then 'view'
168
+ when 'm' then 'materialized_view'
169
+ when 'f' then 'foreign_table'
170
+ end as ref_kind,
171
+ ref_ns.nspname as ref_schema,
172
+ ref_c.relname as ref_name
173
+ from
174
+ pg_depend d
175
+ join pg_policy p on p.oid = d.objid
176
+ join pg_class ref_c on ref_c.oid = d.refobjid
177
+ join pg_namespace ref_ns on ref_ns.oid = ref_c.relnamespace
178
+ where
179
+ d.classid = 'pg_policy'::regclass
180
+ and d.refclassid = 'pg_class'::regclass
181
+ and d.deptype = 'n'
182
+ and ref_c.relkind in ('r', 'p', 'v', 'm', 'f')
183
+ and d.refobjid <> p.polrelid
184
+ ),
185
+ policy_procedure_deps as (
186
+ -- Functions / procedures referenced inside polqual / polwithcheck. Same
187
+ -- pg_depend mechanism as above, just refclassid = pg_proc. proargtypes
188
+ -- formatted via format_type(oid, null) matches the signature produced by
189
+ -- the procedure extractor (see procedure.model.ts), so stableId.procedure
190
+ -- on both sides of the diff lines up exactly.
191
+ select distinct
192
+ d.objid as policy_oid,
193
+ ref_ns.nspname as ref_schema,
194
+ ref_p.proname as ref_name,
195
+ array(
196
+ select format_type(oid, null)
197
+ from unnest(ref_p.proargtypes) as oid
198
+ ) as ref_argument_types
199
+ from
200
+ pg_depend d
201
+ join pg_proc ref_p on ref_p.oid = d.refobjid
202
+ join pg_namespace ref_ns on ref_ns.oid = ref_p.pronamespace
203
+ where
204
+ d.classid = 'pg_policy'::regclass
205
+ and d.refclassid = 'pg_proc'::regclass
206
+ and d.deptype = 'n'
104
207
  )
105
208
  select
106
209
  tc.relnamespace::regnamespace::text as schema,
@@ -120,7 +223,37 @@ select
120
223
  pg_get_expr(p.polqual, p.polrelid) as using_expression,
121
224
  pg_get_expr(p.polwithcheck, p.polrelid) as with_check_expression,
122
225
  tc.relowner::regrole::text as owner,
123
- obj_description(p.oid, 'pg_policy') as comment
226
+ obj_description(p.oid, 'pg_policy') as comment,
227
+ coalesce(
228
+ (
229
+ select json_agg(
230
+ json_build_object(
231
+ 'kind', prd.ref_kind,
232
+ 'schema', prd.ref_schema,
233
+ 'name', prd.ref_name
234
+ )
235
+ order by prd.ref_schema, prd.ref_name
236
+ )
237
+ from policy_relation_deps prd
238
+ where prd.policy_oid = p.oid
239
+ ),
240
+ '[]'
241
+ ) as referenced_relations,
242
+ coalesce(
243
+ (
244
+ select json_agg(
245
+ json_build_object(
246
+ 'schema', ppd.ref_schema,
247
+ 'name', ppd.ref_name,
248
+ 'argument_types', ppd.ref_argument_types
249
+ )
250
+ order by ppd.ref_schema, ppd.ref_name, ppd.ref_argument_types
251
+ )
252
+ from policy_procedure_deps ppd
253
+ where ppd.policy_oid = p.oid
254
+ ),
255
+ '[]'
256
+ ) as referenced_procedures
124
257
  from
125
258
  pg_catalog.pg_policy p
126
259
  inner join pg_catalog.pg_class tc on tc.oid = p.polrelid
@@ -1000,6 +1000,8 @@ const rlsPolicy = new RlsPolicy({
1000
1000
  with_check_expression: null,
1001
1001
  owner: "owner1",
1002
1002
  comment: "rls policy comment",
1003
+ referenced_relations: [],
1004
+ referenced_procedures: [],
1003
1005
  });
1004
1006
 
1005
1007
  const rlsPolicyRestrictive = new RlsPolicy({
@@ -1013,6 +1015,8 @@ const rlsPolicyRestrictive = new RlsPolicy({
1013
1015
  with_check_expression: "status <> 'locked'",
1014
1016
  owner: "owner1",
1015
1017
  comment: null,
1018
+ referenced_relations: [],
1019
+ referenced_procedures: [],
1016
1020
  });
1017
1021
 
1018
1022
  const index = new Index({