@supabase/pg-delta 1.0.0-alpha.27 → 1.0.0-alpha.29

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.
@@ -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,
@@ -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
  });