@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.
@@ -30,6 +30,71 @@ import {
30
30
  import type { ViewChange } from "./changes/view.types.ts";
31
31
  import type { View } from "./view.model.ts";
32
32
 
33
+ export function buildCreateViewChanges(
34
+ ctx: Pick<
35
+ ObjectDiffContext,
36
+ "version" | "currentUser" | "defaultPrivilegeState"
37
+ >,
38
+ view: View,
39
+ ): ViewChange[] {
40
+ const changes: ViewChange[] = [new CreateView({ view })];
41
+
42
+ // OWNER: If the view should be owned by someone other than the current user,
43
+ // emit ALTER VIEW ... OWNER TO after creation
44
+ if (view.owner !== ctx.currentUser) {
45
+ changes.push(new AlterViewChangeOwner({ view, owner: view.owner }));
46
+ }
47
+
48
+ if (view.comment !== null) {
49
+ changes.push(new CreateCommentOnView({ view }));
50
+ }
51
+
52
+ for (const label of view.security_labels) {
53
+ changes.push(new CreateSecurityLabelOnView({ view, securityLabel: label }));
54
+ }
55
+
56
+ // PRIVILEGES: For created objects, compare against default privileges state
57
+ // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
58
+ // so objects are created with the default privileges state in effect.
59
+ // We compare default privileges against desired privileges to generate REVOKE/GRANT statements
60
+ // needed to reach the final desired state.
61
+ const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(
62
+ ctx.currentUser,
63
+ "view",
64
+ view.schema ?? "",
65
+ );
66
+ const creatorFilteredDefaults =
67
+ view.owner !== ctx.currentUser
68
+ ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
69
+ : effectiveDefaults;
70
+ const desiredPrivileges = view.privileges;
71
+ // Filter out owner privileges - owner always has ALL privileges implicitly
72
+ // and shouldn't be compared. Use the view owner as the reference.
73
+ const privilegeResults = diffPrivileges(
74
+ creatorFilteredDefaults,
75
+ desiredPrivileges,
76
+ view.owner,
77
+ );
78
+
79
+ changes.push(
80
+ ...(emitColumnPrivilegeChanges(
81
+ privilegeResults,
82
+ view,
83
+ view,
84
+ "view",
85
+ {
86
+ Grant: GrantViewPrivileges,
87
+ Revoke: RevokeViewPrivileges,
88
+ RevokeGrantOption: RevokeGrantOptionViewPrivileges,
89
+ },
90
+ effectiveDefaults,
91
+ ctx.version,
92
+ ) as ViewChange[]),
93
+ );
94
+
95
+ return changes;
96
+ }
97
+
33
98
  /**
34
99
  * Diff two sets of views from main and branch catalogs.
35
100
  *
@@ -49,67 +114,9 @@ export function diffViews(
49
114
  const { created, dropped, altered } = diffObjects(main, branch);
50
115
 
51
116
  const changes: ViewChange[] = [];
52
- const appendCreateViewChanges = (view: View) => {
53
- changes.push(new CreateView({ view }));
54
-
55
- // OWNER: If the view should be owned by someone other than the current user,
56
- // emit ALTER VIEW ... OWNER TO after creation
57
- if (view.owner !== ctx.currentUser) {
58
- changes.push(new AlterViewChangeOwner({ view, owner: view.owner }));
59
- }
60
-
61
- if (view.comment !== null) {
62
- changes.push(new CreateCommentOnView({ view }));
63
- }
64
-
65
- for (const label of view.security_labels) {
66
- changes.push(
67
- new CreateSecurityLabelOnView({ view, securityLabel: label }),
68
- );
69
- }
70
-
71
- // PRIVILEGES: For created objects, compare against default privileges state
72
- // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
73
- // so objects are created with the default privileges state in effect.
74
- // We compare default privileges against desired privileges to generate REVOKE/GRANT statements
75
- // needed to reach the final desired state.
76
- const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(
77
- ctx.currentUser,
78
- "view",
79
- view.schema ?? "",
80
- );
81
- const creatorFilteredDefaults =
82
- view.owner !== ctx.currentUser
83
- ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
84
- : effectiveDefaults;
85
- const desiredPrivileges = view.privileges;
86
- // Filter out owner privileges - owner always has ALL privileges implicitly
87
- // and shouldn't be compared. Use the view owner as the reference.
88
- const privilegeResults = diffPrivileges(
89
- creatorFilteredDefaults,
90
- desiredPrivileges,
91
- view.owner,
92
- );
93
-
94
- changes.push(
95
- ...(emitColumnPrivilegeChanges(
96
- privilegeResults,
97
- view,
98
- view,
99
- "view",
100
- {
101
- Grant: GrantViewPrivileges,
102
- Revoke: RevokeViewPrivileges,
103
- RevokeGrantOption: RevokeGrantOptionViewPrivileges,
104
- },
105
- effectiveDefaults,
106
- ctx.version,
107
- ) as ViewChange[]),
108
- );
109
- };
110
117
 
111
118
  for (const viewId of created) {
112
- appendCreateViewChanges(branch[viewId]);
119
+ changes.push(...buildCreateViewChanges(ctx, branch[viewId]));
113
120
  }
114
121
 
115
122
  for (const viewId of dropped) {
@@ -153,7 +160,7 @@ export function diffViews(
153
160
  )
154
161
  ) {
155
162
  changes.push(new DropView({ view: mainView }));
156
- appendCreateViewChanges(branchView);
163
+ changes.push(...buildCreateViewChanges(ctx, branchView));
157
164
  } else if (nonAlterablePropsChanged) {
158
165
  // Replace the entire view using CREATE OR REPLACE to avoid drop when possible
159
166
  changes.push(new CreateView({ view: branchView, orReplace: true }));
@@ -682,6 +682,132 @@ describe("tryBreakCycleByChangeInjection", () => {
682
682
  expect(broken).toContain(terminalDrop);
683
683
  });
684
684
 
685
+ test("publication FK-chain 4-cycle with partial publication membership: injects FK drops", () => {
686
+ // Sentry SUPABASE-API-7RS / CLI-1605. Same shape as the previous test,
687
+ // but the publication only contains the terminal constraint's table
688
+ // (trades) and the first dropped table (public_offering_events) — the
689
+ // intermediate FK-chain table (trade_status_events) was never a member
690
+ // of supabase_realtime. The breaker must not require every dropped
691
+ // table in the cycle to be a publication member; the pub edge only
692
+ // needs one of them.
693
+ //
694
+ // Schema:
695
+ // trades.trade_id UNIQUE (trades_trade_id_key) — table survives
696
+ // trade_status_events.trade_id REFERENCES trades(trade_id)
697
+ // public_offering_events.source_event_id REFERENCES trade_status_events(id)
698
+ // publication supabase_realtime: trades, public_offering_events only
699
+ const tableTrades = new Table({
700
+ ...baseTableProps,
701
+ name: "trades",
702
+ columns: [
703
+ { ...integerColumn("id", 1), not_null: true },
704
+ { ...integerColumn("trade_id", 2), not_null: true },
705
+ ],
706
+ constraints: [uniqueConstraint("trades_trade_id_key", "trade_id")],
707
+ });
708
+ const tableTradeStatusEvents = new Table({
709
+ ...baseTableProps,
710
+ name: "trade_status_events",
711
+ columns: [
712
+ { ...integerColumn("id", 1), not_null: true },
713
+ integerColumn("trade_id", 2),
714
+ ],
715
+ constraints: [
716
+ fkConstraint({
717
+ name: "trade_status_events_trade_id_fkey",
718
+ fkColumn: "trade_id",
719
+ targetSchema: "public",
720
+ targetTable: "trades",
721
+ targetColumn: "trade_id",
722
+ }),
723
+ ],
724
+ });
725
+ const tablePublicOfferingEvents = new Table({
726
+ ...baseTableProps,
727
+ name: "public_offering_events",
728
+ columns: [
729
+ { ...integerColumn("id", 1), not_null: true },
730
+ integerColumn("source_event_id", 2),
731
+ ],
732
+ constraints: [
733
+ fkConstraint({
734
+ name: "public_offering_events_source_event_id_fkey",
735
+ fkColumn: "source_event_id",
736
+ targetSchema: "public",
737
+ targetTable: "trade_status_events",
738
+ }),
739
+ ],
740
+ });
741
+ const publication = new Publication({
742
+ name: "supabase_realtime",
743
+ owner: "postgres",
744
+ comment: null,
745
+ all_tables: false,
746
+ publish_insert: true,
747
+ publish_update: true,
748
+ publish_delete: true,
749
+ publish_truncate: true,
750
+ publish_via_partition_root: false,
751
+ tables: [
752
+ {
753
+ schema: "public",
754
+ name: "public_offering_events",
755
+ columns: null,
756
+ row_filter: null,
757
+ },
758
+ { schema: "public", name: "trades", columns: null, row_filter: null },
759
+ ],
760
+ schemas: [],
761
+ });
762
+
763
+ const terminalDrop = new AlterTableDropConstraint({
764
+ table: tableTrades,
765
+ constraint: tableTrades.constraints[0],
766
+ });
767
+ const changes: Change[] = [
768
+ new AlterPublicationDropTables({
769
+ publication,
770
+ tables: publication.tables,
771
+ }),
772
+ new DropTable({ table: tablePublicOfferingEvents }),
773
+ new DropTable({ table: tableTradeStatusEvents }),
774
+ terminalDrop,
775
+ ];
776
+
777
+ const broken = tryBreakCycleByChangeInjection([0, 1, 2, 3], changes);
778
+ if (broken === null) throw new Error("expected breaker to fire");
779
+
780
+ const injectedDropNames = broken
781
+ .filter(
782
+ (change): change is AlterTableDropConstraint =>
783
+ change instanceof AlterTableDropConstraint && change !== terminalDrop,
784
+ )
785
+ .map((change) => change.constraint.name)
786
+ .sort();
787
+ expect(injectedDropNames).toEqual([
788
+ "public_offering_events_source_event_id_fkey",
789
+ "trade_status_events_trade_id_fkey",
790
+ ]);
791
+
792
+ for (const [tableId, constraintName] of [
793
+ [
794
+ tablePublicOfferingEvents.stableId,
795
+ "public_offering_events_source_event_id_fkey",
796
+ ],
797
+ [tableTradeStatusEvents.stableId, "trade_status_events_trade_id_fkey"],
798
+ ] as const) {
799
+ const rewrittenDrop = broken.find(
800
+ (change): change is DropTable =>
801
+ change instanceof DropTable && change.table.stableId === tableId,
802
+ );
803
+ if (!rewrittenDrop) throw new Error(`missing DropTable for ${tableId}`);
804
+ expect(
805
+ rewrittenDrop.externallyDroppedConstraints.has(constraintName),
806
+ ).toBe(true);
807
+ }
808
+ expect(broken).toContain(terminalDrop);
809
+ });
810
+
685
811
  test("returns null for a cycle with no recognised pattern (e.g. publication-only)", () => {
686
812
  // Cycle of `AlterPublicationSetOwner` changes — neither FK nor
687
813
  // publication-column shape. Breaker must bail so the formatted
@@ -418,8 +418,18 @@ function tryBreakPublicationFkConstraintDropCycle(
418
418
  return null;
419
419
  }
420
420
 
421
- for (const dropTable of dropTables) {
422
- if (!publicationTableIds.has(dropTable.table.stableId)) return null;
421
+ // At least one dropped table must be a publication member — that's the
422
+ // publication DropTable edge that pulls the publication change into the
423
+ // cycle (the back-edge is the terminal constraint's table, checked above).
424
+ // Don't require ALL of them: publications like supabase_realtime commonly
425
+ // contain only a subset of tables, so intermediate FK-chain tables may not
426
+ // be members (Sentry SUPABASE-API-7RS / CLI-1605).
427
+ if (
428
+ !dropTables.some((dropTable) =>
429
+ publicationTableIds.has(dropTable.table.stableId),
430
+ )
431
+ ) {
432
+ return null;
423
433
  }
424
434
 
425
435
  const cycleDropTableIds = new Set(
@@ -156,6 +156,12 @@ export function buildGraphData(
156
156
  for (const droppedId of changeItem.drops ?? []) {
157
157
  createdIds.add(droppedId);
158
158
  }
159
+ // In-place mutations keep the object identity but invalidate
160
+ // dependents, so for drop-phase ordering they behave like producers of
161
+ // the invalidated ids without changing Change.drops.
162
+ for (const invalidatedId of changeItem.invalidates) {
163
+ createdIds.add(invalidatedId);
164
+ }
159
165
  }
160
166
  return createdIds;
161
167
  },
@@ -4,9 +4,15 @@ import type { Change } from "../change.types.ts";
4
4
  import type { PgDepend } from "../depend.ts";
5
5
  import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts";
6
6
  import { Publication } from "../objects/publication/publication.model.ts";
7
- import { AlterTableDropConstraint } from "../objects/table/changes/table.alter.ts";
7
+ import {
8
+ AlterTableAlterColumnType,
9
+ AlterTableDropConstraint,
10
+ } from "../objects/table/changes/table.alter.ts";
8
11
  import { DropTable } from "../objects/table/changes/table.drop.ts";
9
12
  import { Table } from "../objects/table/table.model.ts";
13
+ import { CreateView } from "../objects/view/changes/view.create.ts";
14
+ import { DropView } from "../objects/view/changes/view.drop.ts";
15
+ import { View } from "../objects/view/view.model.ts";
10
16
  import { sortChanges } from "./sort-changes.ts";
11
17
 
12
18
  const baseTableProps = {
@@ -142,6 +148,29 @@ function table(
142
148
  });
143
149
  }
144
150
 
151
+ function view(name: string, columns = [integerColumn("id", 1)]) {
152
+ return new View({
153
+ schema: "public",
154
+ name,
155
+ definition: "SELECT id FROM users",
156
+ row_security: false,
157
+ force_row_security: false,
158
+ has_indexes: false,
159
+ has_rules: true,
160
+ has_triggers: false,
161
+ has_subclasses: false,
162
+ is_populated: true,
163
+ replica_identity: "d",
164
+ is_partition: false,
165
+ options: null,
166
+ partition_bound: null,
167
+ owner: "postgres",
168
+ comment: null,
169
+ columns,
170
+ privileges: [],
171
+ });
172
+ }
173
+
145
174
  async function catalogWithDepends(depends: PgDepend[]) {
146
175
  const base = await createEmptyCatalog(170000, "postgres");
147
176
  // oxlint-disable-next-line typescript/no-misused-spread
@@ -159,6 +188,49 @@ function changeLabel(change: Change) {
159
188
  }
160
189
 
161
190
  describe("sortChanges", () => {
191
+ test("orders dependent view drop before drop-phase column type rewrite", async () => {
192
+ const branchTable = table("users");
193
+ const mainColumn = {
194
+ ...integerColumn("age", 4),
195
+ data_type: "numeric",
196
+ data_type_str: "numeric",
197
+ };
198
+ const branchColumn = integerColumn("age", 4);
199
+ const dependentView = view("user_ages", [
200
+ integerColumn("id", 1),
201
+ mainColumn,
202
+ ]);
203
+ const recreatedView = view("user_ages", [
204
+ integerColumn("id", 1),
205
+ branchColumn,
206
+ ]);
207
+ const changes: Change[] = [
208
+ new AlterTableAlterColumnType({
209
+ table: branchTable,
210
+ column: branchColumn,
211
+ previousColumn: mainColumn,
212
+ }),
213
+ new DropView({ view: dependentView }),
214
+ new CreateView({ view: recreatedView }),
215
+ ];
216
+ const mainCatalog = await catalogWithDepends([
217
+ {
218
+ dependent_stable_id: dependentView.stableId,
219
+ referenced_stable_id: "column:public.users.age",
220
+ deptype: "n",
221
+ },
222
+ ]);
223
+ const branchCatalog = await catalogWithDepends([]);
224
+
225
+ const sorted = sortChanges({ mainCatalog, branchCatalog }, changes);
226
+
227
+ expect(sorted.map(changeLabel)).toEqual([
228
+ "DropView",
229
+ "AlterTableAlterColumnType",
230
+ "CreateView",
231
+ ]);
232
+ });
233
+
162
234
  test("breaks publication FK-chain constraint-drop cycle with one dropped table", async () => {
163
235
  const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]);
164
236
  const posts = table("posts", [