@supabase/pg-delta 1.0.0-alpha.26 → 1.0.0-alpha.28

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 (43) hide show
  1. package/dist/cli/commands/catalog-export.js +22 -1
  2. package/dist/core/catalog.diff.js +22 -3
  3. package/dist/core/catalog.filter.d.ts +17 -0
  4. package/dist/core/catalog.filter.js +75 -0
  5. package/dist/core/catalog.model.js +7 -1
  6. package/dist/core/expand-replace-dependencies.d.ts +3 -1
  7. package/dist/core/expand-replace-dependencies.js +117 -7
  8. package/dist/core/integrations/supabase.js +102 -11
  9. package/dist/core/objects/base.change.d.ts +12 -0
  10. package/dist/core/objects/base.change.js +14 -0
  11. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +4 -0
  12. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +28 -2
  13. package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +4 -0
  14. package/dist/core/objects/foreign-data-wrapper/server/server.model.js +18 -1
  15. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +4 -0
  16. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +18 -1
  17. package/dist/core/objects/materialized-view/materialized-view.diff.d.ts +1 -0
  18. package/dist/core/objects/materialized-view/materialized-view.diff.js +59 -59
  19. package/dist/core/objects/table/changes/table.alter.d.ts +1 -0
  20. package/dist/core/objects/table/changes/table.alter.js +8 -0
  21. package/dist/core/objects/view/view.diff.d.ts +1 -0
  22. package/dist/core/objects/view/view.diff.js +35 -34
  23. package/dist/core/sort/graph-builder.js +6 -0
  24. package/package.json +1 -1
  25. package/src/cli/commands/catalog-export.ts +26 -1
  26. package/src/core/catalog.diff.test.ts +173 -0
  27. package/src/core/catalog.diff.ts +24 -3
  28. package/src/core/catalog.filter.ts +96 -0
  29. package/src/core/catalog.model.ts +10 -2
  30. package/src/core/expand-replace-dependencies.test.ts +282 -0
  31. package/src/core/expand-replace-dependencies.ts +165 -7
  32. package/src/core/integrations/supabase.test.ts +335 -0
  33. package/src/core/integrations/supabase.ts +102 -11
  34. package/src/core/objects/base.change.ts +15 -0
  35. package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +28 -2
  36. package/src/core/objects/foreign-data-wrapper/server/server.model.ts +18 -1
  37. package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +18 -1
  38. package/src/core/objects/materialized-view/materialized-view.diff.test.ts +3 -2
  39. package/src/core/objects/materialized-view/materialized-view.diff.ts +99 -92
  40. package/src/core/objects/table/changes/table.alter.ts +9 -0
  41. package/src/core/objects/view/view.diff.ts +67 -60
  42. package/src/core/sort/graph-builder.ts +6 -0
  43. package/src/core/sort/sort-changes.test.ts +73 -1
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { diffCatalogs } from "./catalog.diff.ts";
3
+ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
4
+ import { Role, type RoleProps } from "./objects/role/role.model.ts";
5
+ import {
6
+ GrantViewPrivileges,
7
+ RevokeViewPrivileges,
8
+ } from "./objects/view/changes/view.privilege.ts";
9
+ import { View, type ViewProps } from "./objects/view/view.model.ts";
10
+
11
+ const idColumn: ViewProps["columns"][number] = {
12
+ name: "id",
13
+ position: 1,
14
+ data_type: "integer",
15
+ data_type_str: "integer",
16
+ is_custom_type: false,
17
+ custom_type_type: null,
18
+ custom_type_category: null,
19
+ custom_type_schema: null,
20
+ custom_type_name: null,
21
+ not_null: false,
22
+ is_identity: false,
23
+ is_identity_always: false,
24
+ is_generated: false,
25
+ collation: null,
26
+ default: null,
27
+ comment: null,
28
+ };
29
+
30
+ const nameColumn: ViewProps["columns"][number] = {
31
+ name: "name",
32
+ position: 2,
33
+ data_type: "text",
34
+ data_type_str: "text",
35
+ is_custom_type: false,
36
+ custom_type_type: null,
37
+ custom_type_category: null,
38
+ custom_type_schema: null,
39
+ custom_type_name: null,
40
+ not_null: false,
41
+ is_identity: false,
42
+ is_identity_always: false,
43
+ is_generated: false,
44
+ collation: null,
45
+ default: null,
46
+ comment: null,
47
+ };
48
+
49
+ const baseView: ViewProps = {
50
+ schema: "public",
51
+ name: "replaced_view",
52
+ definition: "SELECT id FROM source_table",
53
+ row_security: false,
54
+ force_row_security: false,
55
+ has_indexes: false,
56
+ has_rules: false,
57
+ has_triggers: false,
58
+ has_subclasses: false,
59
+ is_populated: true,
60
+ replica_identity: "d",
61
+ is_partition: false,
62
+ options: null,
63
+ partition_bound: null,
64
+ owner: "postgres",
65
+ comment: null,
66
+ columns: [idColumn],
67
+ privileges: [],
68
+ };
69
+
70
+ const makeView = (override: Partial<ViewProps> = {}) =>
71
+ new View({
72
+ ...baseView,
73
+ ...override,
74
+ columns: override.columns ?? [...baseView.columns],
75
+ privileges: override.privileges ?? [...baseView.privileges],
76
+ });
77
+
78
+ const makeRole = (name: string, override: Partial<RoleProps> = {}) =>
79
+ new Role({
80
+ name,
81
+ is_superuser: false,
82
+ can_inherit: true,
83
+ can_create_roles: false,
84
+ can_create_databases: false,
85
+ can_login: true,
86
+ can_replicate: false,
87
+ connection_limit: null,
88
+ can_bypass_rls: false,
89
+ config: null,
90
+ comment: null,
91
+ members: [],
92
+ default_privileges: [],
93
+ security_labels: [],
94
+ ...override,
95
+ });
96
+
97
+ describe("catalog.diff", () => {
98
+ test("keeps replacement-created view grants through dropped-target privilege filtering", async () => {
99
+ const baseline = await createEmptyCatalog(170000, "postgres");
100
+ const mainView = makeView();
101
+ const branchView = makeView({
102
+ definition: "SELECT id, name FROM source_table",
103
+ columns: [...mainView.columns, nameColumn],
104
+ privileges: [
105
+ { grantee: "view_reader", privilege: "SELECT", grantable: false },
106
+ ],
107
+ });
108
+
109
+ const main = new Catalog({
110
+ // oxlint-disable-next-line typescript/no-misused-spread
111
+ ...baseline,
112
+ views: { [mainView.stableId]: mainView },
113
+ });
114
+ const branch = new Catalog({
115
+ // oxlint-disable-next-line typescript/no-misused-spread
116
+ ...baseline,
117
+ views: { [branchView.stableId]: branchView },
118
+ });
119
+
120
+ const changes = diffCatalogs(main, branch);
121
+
122
+ expect(
123
+ changes.some((change) => change instanceof GrantViewPrivileges),
124
+ ).toBe(true);
125
+ });
126
+
127
+ test("keeps replacement-created view revokes through dropped-target privilege filtering", async () => {
128
+ const baseline = await createEmptyCatalog(170000, "postgres");
129
+ const mainView = makeView();
130
+ const branchView = makeView({
131
+ definition: "SELECT id, name FROM source_table",
132
+ columns: [...mainView.columns, nameColumn],
133
+ });
134
+ const postgres = makeRole("postgres", {
135
+ default_privileges: [
136
+ {
137
+ in_schema: "public",
138
+ objtype: "r",
139
+ grantee: "view_reader",
140
+ privileges: [{ privilege: "SELECT", grantable: false }],
141
+ is_implicit: false,
142
+ },
143
+ ],
144
+ });
145
+ const viewReader = makeRole("view_reader");
146
+
147
+ const roles = {
148
+ [postgres.stableId]: postgres,
149
+ [viewReader.stableId]: viewReader,
150
+ };
151
+ const main = new Catalog({
152
+ // oxlint-disable-next-line typescript/no-misused-spread
153
+ ...baseline,
154
+ roles,
155
+ views: { [mainView.stableId]: mainView },
156
+ });
157
+ const branch = new Catalog({
158
+ // oxlint-disable-next-line typescript/no-misused-spread
159
+ ...baseline,
160
+ roles,
161
+ views: { [branchView.stableId]: branchView },
162
+ });
163
+
164
+ // The recreated view inherits SELECT from default privileges, but the
165
+ // branch model wants no explicit reader ACL. The replacement filter must
166
+ // keep the generated REVOKE even though the old view stable id is dropped.
167
+ const changes = diffCatalogs(main, branch);
168
+
169
+ expect(
170
+ changes.some((change) => change instanceof RevokeViewPrivileges),
171
+ ).toBe(true);
172
+ });
173
+ });
@@ -217,19 +217,39 @@ export function diffCatalogs(
217
217
  ...diffForeignTables(diffContext, main.foreignTables, branch.foreignTables),
218
218
  );
219
219
 
220
- // Filter privilege REVOKEs for objects that are being dropped
221
- // Avoid emitting redundant REVOKE statements for targets that will no longer exist.
220
+ // Filter privilege changes for objects that are only being dropped.
221
+ // Avoid emitting redundant ACL statements for targets that will no longer exist.
222
222
  const droppedObjectStableIds = new Set<string>();
223
+ const createdStableIds = new Set<string>();
223
224
  for (const change of changes) {
224
225
  if (change.operation === "drop" && change.scope === "object") {
225
226
  for (const dep of change.requires) {
226
227
  droppedObjectStableIds.add(dep);
227
228
  }
228
229
  }
230
+ if (change.operation === "create" && change.scope === "object") {
231
+ for (const dep of change.creates) {
232
+ createdStableIds.add(dep);
233
+ }
234
+ }
229
235
  }
236
+ // A pure DROP does not need ACL cleanup: the target object is going away.
237
+ // A replacement is different: it has both DROP and CREATE for the same stable
238
+ // id, and its privilege ALTERs describe the ACL state of the newly created
239
+ // object. Keep all of them, including REVOKE/REVOKE GRANT OPTION generated to
240
+ // subtract privileges inherited from ALTER DEFAULT PRIVILEGES at create time.
241
+ const replacementStableIds = new Set(
242
+ [...droppedObjectStableIds].filter((id) => createdStableIds.has(id)),
243
+ );
230
244
  let filteredChanges = changes.filter((change) => {
231
245
  if (change.operation === "alter" && change.scope === "privilege") {
232
- return !droppedObjectStableIds.has(getPrivilegeTargetStableId(change));
246
+ const targetStableId = getPrivilegeTargetStableId(change);
247
+ // Checking only privilege creates would keep replacement GRANTs but drop
248
+ // replacement REVOKEs, so preserve by replacement target stable id instead.
249
+ if (replacementStableIds.has(targetStableId)) {
250
+ return true;
251
+ }
252
+ return !droppedObjectStableIds.has(targetStableId);
233
253
  }
234
254
  return true;
235
255
  });
@@ -238,6 +258,7 @@ export function diffCatalogs(
238
258
  changes: filteredChanges,
239
259
  mainCatalog: main,
240
260
  branchCatalog: branch,
261
+ diffContext,
241
262
  });
242
263
  filteredChanges = normalizePostDiffChanges({
243
264
  changes: expandedDependencies.changes,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Prune a catalog to the objects that match a Filter DSL expression.
3
+ *
4
+ * The Filter DSL is defined over Change objects, so the catalog is
5
+ * diffed against an empty baseline first to materialize one CREATE
6
+ * change per object. The filter then evaluates against the same shape
7
+ * it would at plan time, and the surviving stableIds drive the prune.
8
+ *
9
+ * Dependency cascade is not applied. A scoped snapshot is partial by
10
+ * design: out-of-scope owners, roles, and types must exist on the
11
+ * target DB at apply time. Cascading would expand the filter beyond
12
+ * what the caller asked for and, in practice, collapse schema-scoped
13
+ * exports whose kept objects reference cluster-scoped owners.
14
+ */
15
+
16
+ import { diffCatalogs } from "./catalog.diff.ts";
17
+ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
18
+ import { compileFilterDSL, type FilterDSL } from "./integrations/filter/dsl.ts";
19
+
20
+ export async function filterCatalog(
21
+ catalog: Catalog,
22
+ filter: FilterDSL,
23
+ ): Promise<Catalog> {
24
+ if (
25
+ typeof filter === "object" &&
26
+ filter !== null &&
27
+ (filter as Record<string, unknown>).cascade === true
28
+ ) {
29
+ throw new Error(
30
+ "Filter DSL `cascade: true` is not supported by catalog-export: " +
31
+ "scoped snapshots are intentionally partial. Out-of-scope owners, " +
32
+ "roles, and types must exist on the target DB at apply time.",
33
+ );
34
+ }
35
+
36
+ const empty = await createEmptyCatalog(catalog.version, catalog.currentUser);
37
+ const changes = diffCatalogs(empty, catalog);
38
+ const filterFn = compileFilterDSL(filter);
39
+
40
+ const keep = new Set<string>();
41
+ for (const change of changes) {
42
+ if (!filterFn(change)) continue;
43
+ for (const id of change.creates ?? []) keep.add(id);
44
+ }
45
+
46
+ return pruneCatalog(catalog, keep);
47
+ }
48
+
49
+ function filterRecord<T>(
50
+ record: Record<string, T>,
51
+ keep: ReadonlySet<string>,
52
+ ): Record<string, T> {
53
+ return Object.fromEntries(
54
+ Object.entries(record).filter(([id]) => keep.has(id)),
55
+ );
56
+ }
57
+
58
+ function pruneCatalog(catalog: Catalog, keep: ReadonlySet<string>): Catalog {
59
+ const tables = filterRecord(catalog.tables, keep);
60
+ const materializedViews = filterRecord(catalog.materializedViews, keep);
61
+
62
+ return new Catalog({
63
+ aggregates: filterRecord(catalog.aggregates, keep),
64
+ collations: filterRecord(catalog.collations, keep),
65
+ compositeTypes: filterRecord(catalog.compositeTypes, keep),
66
+ domains: filterRecord(catalog.domains, keep),
67
+ enums: filterRecord(catalog.enums, keep),
68
+ extensions: filterRecord(catalog.extensions, keep),
69
+ procedures: filterRecord(catalog.procedures, keep),
70
+ indexes: filterRecord(catalog.indexes, keep),
71
+ materializedViews,
72
+ subscriptions: filterRecord(catalog.subscriptions, keep),
73
+ publications: filterRecord(catalog.publications, keep),
74
+ rlsPolicies: filterRecord(catalog.rlsPolicies, keep),
75
+ roles: filterRecord(catalog.roles, keep),
76
+ schemas: filterRecord(catalog.schemas, keep),
77
+ sequences: filterRecord(catalog.sequences, keep),
78
+ tables,
79
+ triggers: filterRecord(catalog.triggers, keep),
80
+ eventTriggers: filterRecord(catalog.eventTriggers, keep),
81
+ rules: filterRecord(catalog.rules, keep),
82
+ ranges: filterRecord(catalog.ranges, keep),
83
+ views: filterRecord(catalog.views, keep),
84
+ foreignDataWrappers: filterRecord(catalog.foreignDataWrappers, keep),
85
+ servers: filterRecord(catalog.servers, keep),
86
+ userMappings: filterRecord(catalog.userMappings, keep),
87
+ foreignTables: filterRecord(catalog.foreignTables, keep),
88
+ depends: catalog.depends.filter(
89
+ (d) =>
90
+ keep.has(d.dependent_stable_id) && keep.has(d.referenced_stable_id),
91
+ ),
92
+ indexableObjects: { ...tables, ...materializedViews },
93
+ version: catalog.version,
94
+ currentUser: catalog.currentUser,
95
+ });
96
+ }
@@ -182,8 +182,10 @@ let _pg1516Baseline: Catalog | null = null;
182
182
  let _pg17Baseline: Catalog | null = null;
183
183
 
184
184
  async function loadBaselineJson(): Promise<Record<string, unknown>> {
185
- const mod =
186
- await import("./fixtures/empty-catalogs/postgres-15-16-baseline.json");
185
+ const mod = await import(
186
+ "./fixtures/empty-catalogs/postgres-15-16-baseline.json",
187
+ { with: { type: "json" } }
188
+ );
187
189
  return mod.default as Record<string, unknown>;
188
190
  }
189
191
 
@@ -447,6 +449,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
447
449
  options: redactSensitiveOptionPairs(server.options),
448
450
  comment: server.comment,
449
451
  privileges: server.privileges,
452
+ wrapper_handler: server.wrapper_handler,
453
+ wrapper_validator: server.wrapper_validator,
450
454
  });
451
455
  });
452
456
 
@@ -455,6 +459,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
455
459
  user: mapping.user,
456
460
  server: mapping.server,
457
461
  options: redactSensitiveOptionPairs(mapping.options),
462
+ wrapper_handler: mapping.wrapper_handler,
463
+ wrapper_validator: mapping.wrapper_validator,
458
464
  });
459
465
  });
460
466
 
@@ -470,6 +476,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
470
476
  options: redactSensitiveOptionPairs(foreignTable.options),
471
477
  comment: foreignTable.comment,
472
478
  privileges: foreignTable.privileges,
479
+ wrapper_handler: foreignTable.wrapper_handler,
480
+ wrapper_validator: foreignTable.wrapper_validator,
473
481
  }),
474
482
  );
475
483
 
@@ -6,6 +6,14 @@ import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts";
6
6
  import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
7
7
  import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts";
8
8
  import { Procedure } from "./objects/procedure/procedure.model.ts";
9
+ import {
10
+ AlterRlsPolicySetUsingExpression,
11
+ AlterRlsPolicySetWithCheckExpression,
12
+ } from "./objects/rls-policy/changes/rls-policy.alter.ts";
13
+ import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.ts";
14
+ import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.ts";
15
+ import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.ts";
16
+ import { RlsPolicy } from "./objects/rls-policy/rls-policy.model.ts";
9
17
  import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
10
18
  import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
11
19
  import { diffSequences } from "./objects/sequence/sequence.diff.ts";
@@ -40,6 +48,7 @@ function mockChange(overrides: {
40
48
  scope: "object",
41
49
  creates,
42
50
  drops,
51
+ invalidates: [],
43
52
  requires: [],
44
53
  table: { schema: "public", name: "t" },
45
54
  serialize: () => [],
@@ -49,6 +58,20 @@ function mockChange(overrides: {
49
58
  } as unknown as Change;
50
59
  }
51
60
 
61
+ function mockInvalidatingChange(invalidates: string[]): Change {
62
+ return {
63
+ objectType: "table",
64
+ operation: "alter",
65
+ scope: "object",
66
+ creates: [],
67
+ drops: [],
68
+ invalidates,
69
+ requires: [],
70
+ table: { schema: "public", name: "t" },
71
+ serialize: () => "",
72
+ } as unknown as Change;
73
+ }
74
+
52
75
  describe("expandReplaceDependencies", () => {
53
76
  test("returns changes unchanged when there are no replace roots", async () => {
54
77
  const catalog = await createEmptyCatalog(160004, "u");
@@ -695,4 +718,263 @@ describe("expandReplaceDependencies", () => {
695
718
 
696
719
  expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true);
697
720
  });
721
+
722
+ test("promotes dependent RLS policy when a procedure's signature changes", async () => {
723
+ const baseline = await createEmptyCatalog(170000, "postgres");
724
+ const procedureBase = {
725
+ schema: "public",
726
+ name: "check_role",
727
+ kind: "f" as const,
728
+ return_type: "boolean",
729
+ return_type_schema: "pg_catalog",
730
+ language: "plpgsql",
731
+ security_definer: false,
732
+ volatility: "v" as const,
733
+ parallel_safety: "u" as const,
734
+ execution_cost: 100,
735
+ result_rows: 0,
736
+ is_strict: false,
737
+ leakproof: false,
738
+ returns_set: false,
739
+ argument_names: ["id", "role"],
740
+ all_argument_types: null,
741
+ argument_modes: null,
742
+ source_code: "BEGIN RETURN true; END;",
743
+ binary_path: null,
744
+ sql_body: null,
745
+ config: null,
746
+ owner: "postgres",
747
+ comment: null,
748
+ privileges: [],
749
+ };
750
+ const mainProcedure = new Procedure({
751
+ ...procedureBase,
752
+ argument_count: 2,
753
+ argument_default_count: 0,
754
+ argument_types: ["uuid", "text"],
755
+ argument_defaults: null,
756
+ definition:
757
+ "CREATE FUNCTION public.check_role(id uuid, role text) RETURNS boolean ...",
758
+ });
759
+ const branchProcedure = new Procedure({
760
+ ...procedureBase,
761
+ argument_count: 3,
762
+ argument_default_count: 1,
763
+ argument_names: ["id", "role", "extra"],
764
+ argument_types: ["uuid", "text", "text"],
765
+ argument_defaults: "'default'::text",
766
+ definition:
767
+ "CREATE FUNCTION public.check_role(id uuid, role text, extra text DEFAULT 'default'::text) RETURNS boolean ...",
768
+ });
769
+ const policyBase = {
770
+ schema: "public",
771
+ table_name: "profiles",
772
+ name: "check_role_policy",
773
+ command: "r" as const,
774
+ permissive: true,
775
+ roles: ["public"],
776
+ using_expression: "public.check_role(id, role)",
777
+ with_check_expression: null,
778
+ owner: "postgres",
779
+ comment: "policy comment",
780
+ referenced_relations: [],
781
+ };
782
+ const mainPolicy = new RlsPolicy({
783
+ ...policyBase,
784
+ referenced_procedures: [
785
+ {
786
+ schema: "public",
787
+ name: "check_role",
788
+ argument_types: ["uuid", "text"],
789
+ },
790
+ ],
791
+ });
792
+ const branchPolicy = new RlsPolicy({
793
+ ...policyBase,
794
+ referenced_procedures: [
795
+ {
796
+ schema: "public",
797
+ name: "check_role",
798
+ argument_types: ["uuid", "text", "text"],
799
+ },
800
+ ],
801
+ });
802
+
803
+ const changes: Change[] = [
804
+ new DropProcedure({ procedure: mainProcedure }),
805
+ new CreateProcedure({ procedure: branchProcedure }),
806
+ ];
807
+ const mainCatalog = new Catalog({
808
+ // oxlint-disable-next-line typescript/no-misused-spread
809
+ ...baseline,
810
+ procedures: { [mainProcedure.stableId]: mainProcedure },
811
+ rlsPolicies: { [mainPolicy.stableId]: mainPolicy },
812
+ depends: [
813
+ {
814
+ dependent_stable_id: mainPolicy.stableId,
815
+ referenced_stable_id: mainProcedure.stableId,
816
+ deptype: "n",
817
+ },
818
+ ],
819
+ });
820
+ const branchCatalog = new Catalog({
821
+ // oxlint-disable-next-line typescript/no-misused-spread
822
+ ...baseline,
823
+ procedures: { [branchProcedure.stableId]: branchProcedure },
824
+ rlsPolicies: { [branchPolicy.stableId]: branchPolicy },
825
+ depends: [],
826
+ });
827
+
828
+ const expanded = expandReplaceDependencies({
829
+ changes,
830
+ mainCatalog,
831
+ branchCatalog,
832
+ });
833
+
834
+ expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true);
835
+ expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe(
836
+ true,
837
+ );
838
+ expect(
839
+ expanded.changes.some((c) => c instanceof CreateCommentOnRlsPolicy),
840
+ ).toBe(true);
841
+ });
842
+
843
+ test("promotes dependent RLS policy when a referenced column is invalidated", async () => {
844
+ const baseline = await createEmptyCatalog(170000, "postgres");
845
+ const columnTemplate = {
846
+ position: 1,
847
+ data_type: "text",
848
+ data_type_str: "text",
849
+ is_custom_type: false,
850
+ custom_type_type: null,
851
+ custom_type_category: null,
852
+ custom_type_schema: null,
853
+ custom_type_name: null,
854
+ not_null: true,
855
+ is_identity: false,
856
+ is_identity_always: false,
857
+ is_generated: false,
858
+ collation: null,
859
+ default: null,
860
+ comment: null,
861
+ };
862
+ const tableBase = {
863
+ schema: "public",
864
+ name: "solution_categories_with_policy",
865
+ persistence: "p" as const,
866
+ row_security: true,
867
+ force_row_security: false,
868
+ has_indexes: false,
869
+ has_rules: false,
870
+ has_triggers: false,
871
+ has_subclasses: false,
872
+ is_populated: true,
873
+ replica_identity: "d" as const,
874
+ is_partition: false,
875
+ options: null,
876
+ partition_bound: null,
877
+ partition_by: null,
878
+ owner: "postgres",
879
+ comment: null,
880
+ parent_schema: null,
881
+ parent_name: null,
882
+ constraints: [],
883
+ privileges: [],
884
+ };
885
+ const mainRoleColumn = {
886
+ ...columnTemplate,
887
+ name: "role",
888
+ };
889
+ const branchRoleColumn = {
890
+ ...columnTemplate,
891
+ name: "role",
892
+ data_type: "user_role_enum",
893
+ data_type_str: "public.user_role_enum",
894
+ is_custom_type: true,
895
+ custom_type_type: "e",
896
+ custom_type_category: "E",
897
+ custom_type_schema: "public",
898
+ custom_type_name: "user_role_enum",
899
+ };
900
+ const mainTable = new Table({
901
+ ...tableBase,
902
+ columns: [mainRoleColumn],
903
+ });
904
+ const branchTable = new Table({
905
+ ...tableBase,
906
+ columns: [branchRoleColumn],
907
+ });
908
+ const policyBase = {
909
+ schema: "public",
910
+ table_name: "solution_categories_with_policy",
911
+ name: "categories_admin_manage",
912
+ command: "*" as const,
913
+ permissive: true,
914
+ roles: ["public"],
915
+ owner: "postgres",
916
+ comment: null,
917
+ referenced_relations: [],
918
+ referenced_procedures: [],
919
+ };
920
+ const mainPolicy = new RlsPolicy({
921
+ ...policyBase,
922
+ using_expression: "role = 'admin'",
923
+ with_check_expression: "role = 'admin'",
924
+ });
925
+ const branchPolicy = new RlsPolicy({
926
+ ...policyBase,
927
+ using_expression: "role = 'admin'::public.user_role_enum",
928
+ with_check_expression: "role = 'admin'::public.user_role_enum",
929
+ });
930
+ const alterUsing = new AlterRlsPolicySetUsingExpression({
931
+ policy: mainPolicy,
932
+ usingExpression: branchPolicy.using_expression,
933
+ });
934
+ const alterWithCheck = new AlterRlsPolicySetWithCheckExpression({
935
+ policy: mainPolicy,
936
+ withCheckExpression: branchPolicy.with_check_expression,
937
+ });
938
+ const changes: Change[] = [
939
+ mockInvalidatingChange([
940
+ "column:public.solution_categories_with_policy.role",
941
+ ]),
942
+ alterUsing,
943
+ alterWithCheck,
944
+ ];
945
+ const mainCatalog = new Catalog({
946
+ // oxlint-disable-next-line typescript/no-misused-spread
947
+ ...baseline,
948
+ tables: { [mainTable.stableId]: mainTable },
949
+ rlsPolicies: { [mainPolicy.stableId]: mainPolicy },
950
+ depends: [
951
+ {
952
+ dependent_stable_id: mainPolicy.stableId,
953
+ referenced_stable_id:
954
+ "column:public.solution_categories_with_policy.role",
955
+ deptype: "n",
956
+ },
957
+ ],
958
+ });
959
+ const branchCatalog = new Catalog({
960
+ // oxlint-disable-next-line typescript/no-misused-spread
961
+ ...baseline,
962
+ tables: { [branchTable.stableId]: branchTable },
963
+ rlsPolicies: { [branchPolicy.stableId]: branchPolicy },
964
+ depends: [],
965
+ });
966
+
967
+ const expanded = expandReplaceDependencies({
968
+ changes,
969
+ mainCatalog,
970
+ branchCatalog,
971
+ });
972
+
973
+ expect(expanded.changes.some((c) => c instanceof DropRlsPolicy)).toBe(true);
974
+ expect(expanded.changes.some((c) => c instanceof CreateRlsPolicy)).toBe(
975
+ true,
976
+ );
977
+ expect(expanded.changes).not.toContain(alterUsing);
978
+ expect(expanded.changes).not.toContain(alterWithCheck);
979
+ });
698
980
  });