@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.
- package/dist/core/catalog.diff.js +22 -3
- package/dist/core/expand-replace-dependencies.d.ts +3 -1
- package/dist/core/expand-replace-dependencies.js +117 -7
- package/dist/core/objects/base.change.d.ts +12 -0
- package/dist/core/objects/base.change.js +14 -0
- package/dist/core/objects/materialized-view/materialized-view.diff.d.ts +1 -0
- package/dist/core/objects/materialized-view/materialized-view.diff.js +59 -59
- package/dist/core/objects/table/changes/table.alter.d.ts +1 -0
- package/dist/core/objects/table/changes/table.alter.js +8 -0
- package/dist/core/objects/view/view.diff.d.ts +1 -0
- package/dist/core/objects/view/view.diff.js +35 -34
- package/dist/core/sort/cycle-breakers.js +8 -3
- package/dist/core/sort/graph-builder.js +6 -0
- package/package.json +2 -2
- package/src/core/catalog.diff.test.ts +173 -0
- package/src/core/catalog.diff.ts +24 -3
- package/src/core/expand-replace-dependencies.test.ts +282 -0
- package/src/core/expand-replace-dependencies.ts +165 -7
- package/src/core/objects/base.change.ts +15 -0
- package/src/core/objects/materialized-view/materialized-view.diff.test.ts +3 -2
- package/src/core/objects/materialized-view/materialized-view.diff.ts +99 -92
- package/src/core/objects/table/changes/table.alter.ts +9 -0
- package/src/core/objects/view/view.diff.ts +67 -60
- package/src/core/sort/cycle-breakers.test.ts +126 -0
- package/src/core/sort/cycle-breakers.ts +12 -2
- package/src/core/sort/graph-builder.ts +6 -0
- package/src/core/sort/sort-changes.test.ts +73 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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 {
|
|
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", [
|