@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.
- package/dist/cli/commands/catalog-export.js +22 -1
- package/dist/core/catalog.diff.js +22 -3
- package/dist/core/catalog.filter.d.ts +17 -0
- package/dist/core/catalog.filter.js +75 -0
- package/dist/core/catalog.model.js +7 -1
- package/dist/core/expand-replace-dependencies.d.ts +3 -1
- package/dist/core/expand-replace-dependencies.js +117 -7
- package/dist/core/integrations/supabase.js +102 -11
- package/dist/core/objects/base.change.d.ts +12 -0
- package/dist/core/objects/base.change.js +14 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +28 -2
- package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/server/server.model.js +18 -1
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +4 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +18 -1
- 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/graph-builder.js +6 -0
- package/package.json +1 -1
- package/src/cli/commands/catalog-export.ts +26 -1
- package/src/core/catalog.diff.test.ts +173 -0
- package/src/core/catalog.diff.ts +24 -3
- package/src/core/catalog.filter.ts +96 -0
- package/src/core/catalog.model.ts +10 -2
- package/src/core/expand-replace-dependencies.test.ts +282 -0
- package/src/core/expand-replace-dependencies.ts +165 -7
- package/src/core/integrations/supabase.test.ts +335 -0
- package/src/core/integrations/supabase.ts +102 -11
- package/src/core/objects/base.change.ts +15 -0
- package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +28 -2
- package/src/core/objects/foreign-data-wrapper/server/server.model.ts +18 -1
- package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +18 -1
- 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/graph-builder.ts +6 -0
- package/src/core/sort/sort-changes.test.ts +73 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { writeFile } from "node:fs/promises";
|
|
5
5
|
import { buildCommand } from "@stricli/core";
|
|
6
|
+
import { filterCatalog } from "../../core/catalog.filter.js";
|
|
6
7
|
import { extractCatalog } from "../../core/catalog.model.js";
|
|
7
8
|
import { serializeCatalog, stringifyCatalogSnapshot, } from "../../core/catalog.snapshot.js";
|
|
8
9
|
import { createManagedPool } from "../../core/postgres-config.js";
|
|
@@ -25,6 +26,19 @@ export const catalogExportCommand = buildCommand({
|
|
|
25
26
|
parse: String,
|
|
26
27
|
optional: true,
|
|
27
28
|
},
|
|
29
|
+
filter: {
|
|
30
|
+
kind: "parsed",
|
|
31
|
+
brief: 'Filter DSL as inline JSON to filter changes (e.g., \'{"*/schema": "app"}\').',
|
|
32
|
+
parse: (value) => {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(value);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new Error(`Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
optional: true,
|
|
41
|
+
},
|
|
28
42
|
},
|
|
29
43
|
aliases: {
|
|
30
44
|
t: "target",
|
|
@@ -43,6 +57,10 @@ Use cases:
|
|
|
43
57
|
- Snapshot template1 for use as an empty-database baseline
|
|
44
58
|
- Snapshot a production database to generate revert migrations
|
|
45
59
|
- Snapshot any state for reproducible offline diffs
|
|
60
|
+
|
|
61
|
+
Pass --filter to scope the snapshot to a subset of the catalog (same
|
|
62
|
+
Filter DSL accepted by plan/sync). Useful when committing a baseline
|
|
63
|
+
snapshot to a repo and only one schema's drift is interesting.
|
|
46
64
|
`.trim(),
|
|
47
65
|
},
|
|
48
66
|
async func(flags) {
|
|
@@ -52,7 +70,10 @@ Use cases:
|
|
|
52
70
|
});
|
|
53
71
|
try {
|
|
54
72
|
const catalog = await extractCatalog(pool);
|
|
55
|
-
const
|
|
73
|
+
const scoped = flags.filter
|
|
74
|
+
? await filterCatalog(catalog, flags.filter)
|
|
75
|
+
: catalog;
|
|
76
|
+
const snapshot = serializeCatalog(scoped);
|
|
56
77
|
const json = stringifyCatalogSnapshot(snapshot);
|
|
57
78
|
await writeFile(flags.output, json, "utf-8");
|
|
58
79
|
this.process.stdout.write(`Catalog snapshot written to ${flags.output}\n`);
|
|
@@ -138,19 +138,37 @@ export function diffCatalogs(main, branch, options) {
|
|
|
138
138
|
changes.push(...diffServers(diffContext, main.servers, branch.servers));
|
|
139
139
|
changes.push(...diffUserMappings(main.userMappings, branch.userMappings));
|
|
140
140
|
changes.push(...diffForeignTables(diffContext, main.foreignTables, branch.foreignTables));
|
|
141
|
-
// Filter privilege
|
|
142
|
-
// Avoid emitting redundant
|
|
141
|
+
// Filter privilege changes for objects that are only being dropped.
|
|
142
|
+
// Avoid emitting redundant ACL statements for targets that will no longer exist.
|
|
143
143
|
const droppedObjectStableIds = new Set();
|
|
144
|
+
const createdStableIds = new Set();
|
|
144
145
|
for (const change of changes) {
|
|
145
146
|
if (change.operation === "drop" && change.scope === "object") {
|
|
146
147
|
for (const dep of change.requires) {
|
|
147
148
|
droppedObjectStableIds.add(dep);
|
|
148
149
|
}
|
|
149
150
|
}
|
|
151
|
+
if (change.operation === "create" && change.scope === "object") {
|
|
152
|
+
for (const dep of change.creates) {
|
|
153
|
+
createdStableIds.add(dep);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
150
156
|
}
|
|
157
|
+
// A pure DROP does not need ACL cleanup: the target object is going away.
|
|
158
|
+
// A replacement is different: it has both DROP and CREATE for the same stable
|
|
159
|
+
// id, and its privilege ALTERs describe the ACL state of the newly created
|
|
160
|
+
// object. Keep all of them, including REVOKE/REVOKE GRANT OPTION generated to
|
|
161
|
+
// subtract privileges inherited from ALTER DEFAULT PRIVILEGES at create time.
|
|
162
|
+
const replacementStableIds = new Set([...droppedObjectStableIds].filter((id) => createdStableIds.has(id)));
|
|
151
163
|
let filteredChanges = changes.filter((change) => {
|
|
152
164
|
if (change.operation === "alter" && change.scope === "privilege") {
|
|
153
|
-
|
|
165
|
+
const targetStableId = getPrivilegeTargetStableId(change);
|
|
166
|
+
// Checking only privilege creates would keep replacement GRANTs but drop
|
|
167
|
+
// replacement REVOKEs, so preserve by replacement target stable id instead.
|
|
168
|
+
if (replacementStableIds.has(targetStableId)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return !droppedObjectStableIds.has(targetStableId);
|
|
154
172
|
}
|
|
155
173
|
return true;
|
|
156
174
|
});
|
|
@@ -158,6 +176,7 @@ export function diffCatalogs(main, branch, options) {
|
|
|
158
176
|
changes: filteredChanges,
|
|
159
177
|
mainCatalog: main,
|
|
160
178
|
branchCatalog: branch,
|
|
179
|
+
diffContext,
|
|
161
180
|
});
|
|
162
181
|
filteredChanges = normalizePostDiffChanges({
|
|
163
182
|
changes: expandedDependencies.changes,
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
import { Catalog } from "./catalog.model.ts";
|
|
16
|
+
import { type FilterDSL } from "./integrations/filter/dsl.ts";
|
|
17
|
+
export declare function filterCatalog(catalog: Catalog, filter: FilterDSL): Promise<Catalog>;
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
import { diffCatalogs } from "./catalog.diff.js";
|
|
16
|
+
import { Catalog, createEmptyCatalog } from "./catalog.model.js";
|
|
17
|
+
import { compileFilterDSL } from "./integrations/filter/dsl.js";
|
|
18
|
+
export async function filterCatalog(catalog, filter) {
|
|
19
|
+
if (typeof filter === "object" &&
|
|
20
|
+
filter !== null &&
|
|
21
|
+
filter.cascade === true) {
|
|
22
|
+
throw new Error("Filter DSL `cascade: true` is not supported by catalog-export: " +
|
|
23
|
+
"scoped snapshots are intentionally partial. Out-of-scope owners, " +
|
|
24
|
+
"roles, and types must exist on the target DB at apply time.");
|
|
25
|
+
}
|
|
26
|
+
const empty = await createEmptyCatalog(catalog.version, catalog.currentUser);
|
|
27
|
+
const changes = diffCatalogs(empty, catalog);
|
|
28
|
+
const filterFn = compileFilterDSL(filter);
|
|
29
|
+
const keep = new Set();
|
|
30
|
+
for (const change of changes) {
|
|
31
|
+
if (!filterFn(change))
|
|
32
|
+
continue;
|
|
33
|
+
for (const id of change.creates ?? [])
|
|
34
|
+
keep.add(id);
|
|
35
|
+
}
|
|
36
|
+
return pruneCatalog(catalog, keep);
|
|
37
|
+
}
|
|
38
|
+
function filterRecord(record, keep) {
|
|
39
|
+
return Object.fromEntries(Object.entries(record).filter(([id]) => keep.has(id)));
|
|
40
|
+
}
|
|
41
|
+
function pruneCatalog(catalog, keep) {
|
|
42
|
+
const tables = filterRecord(catalog.tables, keep);
|
|
43
|
+
const materializedViews = filterRecord(catalog.materializedViews, keep);
|
|
44
|
+
return new Catalog({
|
|
45
|
+
aggregates: filterRecord(catalog.aggregates, keep),
|
|
46
|
+
collations: filterRecord(catalog.collations, keep),
|
|
47
|
+
compositeTypes: filterRecord(catalog.compositeTypes, keep),
|
|
48
|
+
domains: filterRecord(catalog.domains, keep),
|
|
49
|
+
enums: filterRecord(catalog.enums, keep),
|
|
50
|
+
extensions: filterRecord(catalog.extensions, keep),
|
|
51
|
+
procedures: filterRecord(catalog.procedures, keep),
|
|
52
|
+
indexes: filterRecord(catalog.indexes, keep),
|
|
53
|
+
materializedViews,
|
|
54
|
+
subscriptions: filterRecord(catalog.subscriptions, keep),
|
|
55
|
+
publications: filterRecord(catalog.publications, keep),
|
|
56
|
+
rlsPolicies: filterRecord(catalog.rlsPolicies, keep),
|
|
57
|
+
roles: filterRecord(catalog.roles, keep),
|
|
58
|
+
schemas: filterRecord(catalog.schemas, keep),
|
|
59
|
+
sequences: filterRecord(catalog.sequences, keep),
|
|
60
|
+
tables,
|
|
61
|
+
triggers: filterRecord(catalog.triggers, keep),
|
|
62
|
+
eventTriggers: filterRecord(catalog.eventTriggers, keep),
|
|
63
|
+
rules: filterRecord(catalog.rules, keep),
|
|
64
|
+
ranges: filterRecord(catalog.ranges, keep),
|
|
65
|
+
views: filterRecord(catalog.views, keep),
|
|
66
|
+
foreignDataWrappers: filterRecord(catalog.foreignDataWrappers, keep),
|
|
67
|
+
servers: filterRecord(catalog.servers, keep),
|
|
68
|
+
userMappings: filterRecord(catalog.userMappings, keep),
|
|
69
|
+
foreignTables: filterRecord(catalog.foreignTables, keep),
|
|
70
|
+
depends: catalog.depends.filter((d) => keep.has(d.dependent_stable_id) && keep.has(d.referenced_stable_id)),
|
|
71
|
+
indexableObjects: { ...tables, ...materializedViews },
|
|
72
|
+
version: catalog.version,
|
|
73
|
+
currentUser: catalog.currentUser,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -93,7 +93,7 @@ export class Catalog {
|
|
|
93
93
|
let _pg1516Baseline = null;
|
|
94
94
|
let _pg17Baseline = null;
|
|
95
95
|
async function loadBaselineJson() {
|
|
96
|
-
const mod = await import("./fixtures/empty-catalogs/postgres-15-16-baseline.json");
|
|
96
|
+
const mod = await import("./fixtures/empty-catalogs/postgres-15-16-baseline.json", { with: { type: "json" } });
|
|
97
97
|
return mod.default;
|
|
98
98
|
}
|
|
99
99
|
async function getPg1516Baseline() {
|
|
@@ -286,6 +286,8 @@ function normalizeCatalog(catalog) {
|
|
|
286
286
|
options: redactSensitiveOptionPairs(server.options),
|
|
287
287
|
comment: server.comment,
|
|
288
288
|
privileges: server.privileges,
|
|
289
|
+
wrapper_handler: server.wrapper_handler,
|
|
290
|
+
wrapper_validator: server.wrapper_validator,
|
|
289
291
|
});
|
|
290
292
|
});
|
|
291
293
|
const userMappings = mapRecord(catalog.userMappings, (mapping) => {
|
|
@@ -293,6 +295,8 @@ function normalizeCatalog(catalog) {
|
|
|
293
295
|
user: mapping.user,
|
|
294
296
|
server: mapping.server,
|
|
295
297
|
options: redactSensitiveOptionPairs(mapping.options),
|
|
298
|
+
wrapper_handler: mapping.wrapper_handler,
|
|
299
|
+
wrapper_validator: mapping.wrapper_validator,
|
|
296
300
|
});
|
|
297
301
|
});
|
|
298
302
|
const foreignTables = mapRecord(catalog.foreignTables, (foreignTable) => new ForeignTable({
|
|
@@ -304,6 +308,8 @@ function normalizeCatalog(catalog) {
|
|
|
304
308
|
options: redactSensitiveOptionPairs(foreignTable.options),
|
|
305
309
|
comment: foreignTable.comment,
|
|
306
310
|
privileges: foreignTable.privileges,
|
|
311
|
+
wrapper_handler: foreignTable.wrapper_handler,
|
|
312
|
+
wrapper_validator: foreignTable.wrapper_validator,
|
|
307
313
|
}));
|
|
308
314
|
const subscriptions = mapRecord(catalog.subscriptions, (subscription) => {
|
|
309
315
|
return new Subscription({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Catalog } from "./catalog.model.ts";
|
|
2
2
|
import type { Change } from "./change.types.ts";
|
|
3
|
+
import type { ObjectDiffContext } from "./objects/diff-context.ts";
|
|
3
4
|
/**
|
|
4
5
|
* For objects we are replacing (drop + create), ensure that any dependents are also
|
|
5
6
|
* replaced so that destructive drops succeed. Uses dependency edges from pg_depend
|
|
@@ -12,9 +13,10 @@ interface ExpandReplaceDependenciesResult {
|
|
|
12
13
|
changes: Change[];
|
|
13
14
|
replacedTableIds: ReadonlySet<string>;
|
|
14
15
|
}
|
|
15
|
-
export declare function expandReplaceDependencies({ changes, mainCatalog, branchCatalog, }: {
|
|
16
|
+
export declare function expandReplaceDependencies({ changes, mainCatalog, branchCatalog, diffContext, }: {
|
|
16
17
|
changes: Change[];
|
|
17
18
|
mainCatalog: Catalog;
|
|
18
19
|
branchCatalog: Catalog;
|
|
20
|
+
diffContext?: Pick<ObjectDiffContext, "version" | "currentUser" | "defaultPrivilegeState">;
|
|
19
21
|
}): ExpandReplaceDependenciesResult;
|
|
20
22
|
export {};
|
|
@@ -4,8 +4,12 @@ import { CreateIndex } from "./objects/index/changes/index.create.js";
|
|
|
4
4
|
import { DropIndex } from "./objects/index/changes/index.drop.js";
|
|
5
5
|
import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.js";
|
|
6
6
|
import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.js";
|
|
7
|
+
import { buildCreateMaterializedViewChanges } from "./objects/materialized-view/materialized-view.diff.js";
|
|
7
8
|
import { CreateProcedure } from "./objects/procedure/changes/procedure.create.js";
|
|
8
9
|
import { DropProcedure } from "./objects/procedure/changes/procedure.drop.js";
|
|
10
|
+
import { CreateCommentOnRlsPolicy } from "./objects/rls-policy/changes/rls-policy.comment.js";
|
|
11
|
+
import { CreateRlsPolicy } from "./objects/rls-policy/changes/rls-policy.create.js";
|
|
12
|
+
import { DropRlsPolicy } from "./objects/rls-policy/changes/rls-policy.drop.js";
|
|
9
13
|
import { AlterTableAddConstraint } from "./objects/table/changes/table.alter.js";
|
|
10
14
|
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
|
|
11
15
|
import { CreateTable } from "./objects/table/changes/table.create.js";
|
|
@@ -19,7 +23,8 @@ import { DropRange } from "./objects/type/range/changes/range.drop.js";
|
|
|
19
23
|
import { stableId } from "./objects/utils.js";
|
|
20
24
|
import { CreateView } from "./objects/view/changes/view.create.js";
|
|
21
25
|
import { DropView } from "./objects/view/changes/view.drop.js";
|
|
22
|
-
|
|
26
|
+
import { buildCreateViewChanges } from "./objects/view/view.diff.js";
|
|
27
|
+
export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog, diffContext, }) {
|
|
23
28
|
const createdIds = new Set();
|
|
24
29
|
const droppedIds = new Set();
|
|
25
30
|
for (const change of changes) {
|
|
@@ -34,6 +39,15 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
34
39
|
replaceRoots.add(id);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
42
|
+
const promotedRlsPolicyIds = new Set();
|
|
43
|
+
const additions = collectInvalidatedRlsPolicyReplacements({
|
|
44
|
+
changes,
|
|
45
|
+
mainCatalog,
|
|
46
|
+
branchCatalog,
|
|
47
|
+
createdIds,
|
|
48
|
+
droppedIds,
|
|
49
|
+
promotedRlsPolicyIds,
|
|
50
|
+
});
|
|
37
51
|
// Procedure stableIds are signature-qualified
|
|
38
52
|
// (`procedure:schema.name(argtypes)`), so a function whose parameter types
|
|
39
53
|
// change has different ids in `createdIds` and `droppedIds` and would not
|
|
@@ -76,7 +90,7 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
76
90
|
replaceRoots.add(id);
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
|
-
if (replaceRoots.size === 0) {
|
|
93
|
+
if (replaceRoots.size === 0 && additions.length === 0) {
|
|
80
94
|
return {
|
|
81
95
|
changes,
|
|
82
96
|
replacedTableIds: new Set(),
|
|
@@ -92,7 +106,6 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
92
106
|
}
|
|
93
107
|
list.add(dep.dependent_stable_id);
|
|
94
108
|
}
|
|
95
|
-
const additions = [];
|
|
96
109
|
const visitedTargets = new Set();
|
|
97
110
|
const visitedRefs = new Set(replaceRoots);
|
|
98
111
|
const queue = [...replaceRoots];
|
|
@@ -144,10 +157,14 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
144
157
|
const replacementChanges = buildReplaceChanges(resolved, {
|
|
145
158
|
addDrop,
|
|
146
159
|
addCreate,
|
|
160
|
+
diffContext,
|
|
147
161
|
});
|
|
148
162
|
if (!replacementChanges)
|
|
149
163
|
continue;
|
|
150
164
|
additions.push(...replacementChanges);
|
|
165
|
+
if (resolved.kind === "rls_policy") {
|
|
166
|
+
promotedRlsPolicyIds.add(targetId);
|
|
167
|
+
}
|
|
151
168
|
// If we added a DropTable(T) for an existing table, mark T so any
|
|
152
169
|
// pre-existing object-scope AlterTable*(T) changes get dropped below —
|
|
153
170
|
// the DropTable+CreateTable pair supersedes all structural alterations.
|
|
@@ -170,10 +187,70 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
170
187
|
};
|
|
171
188
|
}
|
|
172
189
|
return {
|
|
173
|
-
changes: [
|
|
190
|
+
changes: [
|
|
191
|
+
...removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds),
|
|
192
|
+
...additions,
|
|
193
|
+
],
|
|
174
194
|
replacedTableIds: tablesReplacedByExpansion,
|
|
175
195
|
};
|
|
176
196
|
}
|
|
197
|
+
function collectInvalidatedRlsPolicyReplacements({ changes, mainCatalog, branchCatalog, createdIds, droppedIds, promotedRlsPolicyIds, }) {
|
|
198
|
+
// In-place rewrites report stable ids through `invalidates`: the referenced
|
|
199
|
+
// object keeps its identity, but dependents bound to the old definition must
|
|
200
|
+
// be torn down first. RLS policy expressions are tracked in pg_depend, so use
|
|
201
|
+
// those catalog edges to promote only policies that depend on an invalidated
|
|
202
|
+
// id, without coupling this expansion pass to a concrete table-change class.
|
|
203
|
+
const invalidatedIds = new Set();
|
|
204
|
+
for (const change of changes) {
|
|
205
|
+
for (const invalidatedId of change.invalidates) {
|
|
206
|
+
invalidatedIds.add(invalidatedId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (invalidatedIds.size === 0)
|
|
210
|
+
return [];
|
|
211
|
+
const replacements = [];
|
|
212
|
+
for (const dep of mainCatalog.depends) {
|
|
213
|
+
if (!invalidatedIds.has(dep.referenced_stable_id))
|
|
214
|
+
continue;
|
|
215
|
+
const targetId = normalizeDependentId(dep.dependent_stable_id);
|
|
216
|
+
if (!targetId?.startsWith("rlsPolicy:"))
|
|
217
|
+
continue;
|
|
218
|
+
if (promotedRlsPolicyIds.has(targetId))
|
|
219
|
+
continue;
|
|
220
|
+
if (createdIds.has(targetId) && droppedIds.has(targetId))
|
|
221
|
+
continue;
|
|
222
|
+
const resolved = resolveObjectForStableId(targetId, mainCatalog, branchCatalog);
|
|
223
|
+
if (!resolved || resolved.kind !== "rls_policy")
|
|
224
|
+
continue;
|
|
225
|
+
const addDrop = !droppedIds.has(targetId);
|
|
226
|
+
const addCreate = !createdIds.has(targetId);
|
|
227
|
+
const replacementChanges = buildReplaceChanges(resolved, {
|
|
228
|
+
addDrop,
|
|
229
|
+
addCreate,
|
|
230
|
+
});
|
|
231
|
+
if (!replacementChanges)
|
|
232
|
+
continue;
|
|
233
|
+
replacements.push(...replacementChanges);
|
|
234
|
+
promotedRlsPolicyIds.add(targetId);
|
|
235
|
+
for (const change of replacementChanges) {
|
|
236
|
+
for (const id of change.creates ?? [])
|
|
237
|
+
createdIds.add(id);
|
|
238
|
+
for (const id of change.drops ?? [])
|
|
239
|
+
droppedIds.add(id);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return replacements;
|
|
243
|
+
}
|
|
244
|
+
function removeSupersededRlsPolicyAlters(changes, promotedRlsPolicyIds) {
|
|
245
|
+
if (promotedRlsPolicyIds.size === 0)
|
|
246
|
+
return changes;
|
|
247
|
+
return changes.filter((change) => {
|
|
248
|
+
if (change.objectType !== "rls_policy" || change.operation !== "alter") {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return !promotedRlsPolicyIds.has(change.policy.stableId);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
177
254
|
function isOwnedSequenceColumnDependency(referencedId, dependentId, mainCatalog, branchCatalog) {
|
|
178
255
|
// When a sequence replace root is still OWNED BY the same column, the
|
|
179
256
|
// sequence->column pg_depend edge is bookkeeping for ownership, not a signal
|
|
@@ -264,6 +341,11 @@ function resolveObjectForStableId(stableId, mainCatalog, branchCatalog) {
|
|
|
264
341
|
const branch = branchCatalog.procedures[stableId];
|
|
265
342
|
return main && branch ? { kind: "procedure", main, branch } : null;
|
|
266
343
|
}
|
|
344
|
+
if (stableId.startsWith("rlsPolicy:")) {
|
|
345
|
+
const main = mainCatalog.rlsPolicies[stableId];
|
|
346
|
+
const branch = branchCatalog.rlsPolicies[stableId];
|
|
347
|
+
return main && branch ? { kind: "rls_policy", main, branch } : null;
|
|
348
|
+
}
|
|
267
349
|
if (stableId.startsWith("domain:")) {
|
|
268
350
|
const main = mainCatalog.domains[stableId];
|
|
269
351
|
const branch = branchCatalog.domains[stableId];
|
|
@@ -293,7 +375,7 @@ function resolveObjectForStableId(stableId, mainCatalog, branchCatalog) {
|
|
|
293
375
|
return null;
|
|
294
376
|
}
|
|
295
377
|
function buildReplaceChanges(resolved, options) {
|
|
296
|
-
const { addDrop, addCreate } = options;
|
|
378
|
+
const { addDrop, addCreate, diffContext } = options;
|
|
297
379
|
if (!addDrop && !addCreate)
|
|
298
380
|
return null;
|
|
299
381
|
switch (resolved.kind) {
|
|
@@ -327,7 +409,9 @@ function buildReplaceChanges(resolved, options) {
|
|
|
327
409
|
case "view":
|
|
328
410
|
return [
|
|
329
411
|
...(addDrop ? [new DropView({ view: resolved.main })] : []),
|
|
330
|
-
...(addCreate
|
|
412
|
+
...(addCreate
|
|
413
|
+
? buildCreateViewReplacementChanges(resolved.branch, diffContext)
|
|
414
|
+
: []),
|
|
331
415
|
];
|
|
332
416
|
case "materialized_view":
|
|
333
417
|
return [
|
|
@@ -335,7 +419,13 @@ function buildReplaceChanges(resolved, options) {
|
|
|
335
419
|
? [new DropMaterializedView({ materializedView: resolved.main })]
|
|
336
420
|
: []),
|
|
337
421
|
...(addCreate
|
|
338
|
-
?
|
|
422
|
+
? diffContext
|
|
423
|
+
? buildCreateMaterializedViewChanges(diffContext, resolved.branch)
|
|
424
|
+
: [
|
|
425
|
+
new CreateMaterializedView({
|
|
426
|
+
materializedView: resolved.branch,
|
|
427
|
+
}),
|
|
428
|
+
]
|
|
339
429
|
: []),
|
|
340
430
|
];
|
|
341
431
|
case "index":
|
|
@@ -373,6 +463,18 @@ function buildReplaceChanges(resolved, options) {
|
|
|
373
463
|
? [new CreateProcedure({ procedure: resolved.branch })]
|
|
374
464
|
: []),
|
|
375
465
|
];
|
|
466
|
+
case "rls_policy":
|
|
467
|
+
return [
|
|
468
|
+
...(addDrop ? [new DropRlsPolicy({ policy: resolved.main })] : []),
|
|
469
|
+
...(addCreate
|
|
470
|
+
? [
|
|
471
|
+
new CreateRlsPolicy({ policy: resolved.branch }),
|
|
472
|
+
...(resolved.branch.comment !== null
|
|
473
|
+
? [new CreateCommentOnRlsPolicy({ policy: resolved.branch })]
|
|
474
|
+
: []),
|
|
475
|
+
]
|
|
476
|
+
: []),
|
|
477
|
+
];
|
|
376
478
|
case "enum":
|
|
377
479
|
return [
|
|
378
480
|
...(addDrop ? [new DropEnum({ enum: resolved.main })] : []),
|
|
@@ -401,3 +503,11 @@ function buildReplaceChanges(resolved, options) {
|
|
|
401
503
|
return null;
|
|
402
504
|
}
|
|
403
505
|
}
|
|
506
|
+
function buildCreateViewReplacementChanges(view, diffContext) {
|
|
507
|
+
// Dependency-closure replacements synthesize a create without going through
|
|
508
|
+
// `diffViews`, so replay the same owner/comment/security-label/ACL metadata
|
|
509
|
+
// that a normal non-alterable view replacement would emit.
|
|
510
|
+
return diffContext
|
|
511
|
+
? buildCreateViewChanges(diffContext, view)
|
|
512
|
+
: [new CreateView({ view })];
|
|
513
|
+
}
|
|
@@ -120,6 +120,30 @@ export const supabase = {
|
|
|
120
120
|
"trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS],
|
|
121
121
|
},
|
|
122
122
|
},
|
|
123
|
+
// Defensive fallback for dynamically-created pgmq queue /
|
|
124
|
+
// archive tables. `pgmq.q_<name>` and `pgmq.a_<name>` are
|
|
125
|
+
// materialized by `select pgmq.create('<name>')`, NOT by
|
|
126
|
+
// `CREATE EXTENSION pgmq`, so emitting a user trigger against
|
|
127
|
+
// them fails locally with
|
|
128
|
+
// `relation "pgmq.q_<name>" does not exist`. On a healthy
|
|
129
|
+
// install the trigger extractor's `extension_table_oids` join
|
|
130
|
+
// (packages/pg-delta/src/core/objects/trigger/trigger.model.ts)
|
|
131
|
+
// already drops these via the `pg_depend deptype='e'` row pgmq
|
|
132
|
+
// records during `pgmq.create()`; this rule covers projects
|
|
133
|
+
// where that row is missing (older pgmq, manual table
|
|
134
|
+
// rewrites, `pg_dump`/restore that loses extension deps, ...).
|
|
135
|
+
// pgmq 1.4.4 — the version Supabase Cloud currently ships —
|
|
136
|
+
// does not record the dependency at all.
|
|
137
|
+
{
|
|
138
|
+
not: {
|
|
139
|
+
and: [
|
|
140
|
+
{ "trigger/schema": "pgmq" },
|
|
141
|
+
{
|
|
142
|
+
"trigger/table_name": { op: "regex", value: "^[qa]_" },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
123
147
|
],
|
|
124
148
|
},
|
|
125
149
|
// Exclude system objects
|
|
@@ -180,15 +204,25 @@ export const supabase = {
|
|
|
180
204
|
],
|
|
181
205
|
},
|
|
182
206
|
// Platform-managed foreign data wrappers — Wasm-based FDWs
|
|
183
|
-
// (e.g. `clerk`, `clerk_oauth`)
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
207
|
+
// (e.g. `clerk`, `clerk_oauth`) provisioned via the `wrappers`
|
|
208
|
+
// extension. Supabase Cloud creates these as
|
|
209
|
+
// `CREATE FOREIGN DATA WRAPPER clerk_oauth HANDLER
|
|
210
|
+
// extensions.wasm_fdw_handler VALIDATOR
|
|
211
|
+
// extensions.wasm_fdw_validator` at project creation; replaying
|
|
212
|
+
// the DDL against a local image fails because the local
|
|
213
|
+
// environment has no equivalent pre-step. We can't rely on the
|
|
214
|
+
// FDW owner alone — after a dump/restore the owner is often
|
|
215
|
+
// rewritten away from `supabase_admin` — so match on the shared
|
|
216
|
+
// Wasm handler/validator (`extensions.wasm_fdw_handler` /
|
|
217
|
+
// `extensions.wasm_fdw_validator`) instead.
|
|
218
|
+
//
|
|
219
|
+
// Matching the bare `extensions.*` namespace would be too broad:
|
|
220
|
+
// contrib FDWs like `postgres_fdw` also install their
|
|
221
|
+
// handler/validator into `extensions` on Supabase, and those ARE
|
|
222
|
+
// available in the local image, so a user-created `postgres_fdw`
|
|
223
|
+
// wrapper (and its servers/foreign tables/user mappings) must
|
|
224
|
+
// still roundtrip. Keying on the `wasm_fdw_*` function names
|
|
225
|
+
// targets only the platform Wasm wrappers.
|
|
192
226
|
{
|
|
193
227
|
and: [
|
|
194
228
|
{ objectType: "foreign_data_wrapper" },
|
|
@@ -197,13 +231,70 @@ export const supabase = {
|
|
|
197
231
|
{
|
|
198
232
|
"foreign_data_wrapper/handler": {
|
|
199
233
|
op: "regex",
|
|
200
|
-
value: "^extensions\\.",
|
|
234
|
+
value: "^extensions\\.wasm_fdw_handler$",
|
|
201
235
|
},
|
|
202
236
|
},
|
|
203
237
|
{
|
|
204
238
|
"foreign_data_wrapper/validator": {
|
|
205
239
|
op: "regex",
|
|
206
|
-
value: "^extensions\\.",
|
|
240
|
+
value: "^extensions\\.wasm_fdw_validator$",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
// Platform-managed Wasm FDW dependents (CLI-1470 follow-up).
|
|
248
|
+
// Suppressing the wrapper DDL alone leaves `CREATE SERVER` /
|
|
249
|
+
// `CREATE FOREIGN TABLE` / `CREATE USER MAPPING` that reference
|
|
250
|
+
// a wrapper local Docker never provisions (`clerk_oauth`, etc.).
|
|
251
|
+
// Match on the parent wrapper's Wasm handler/validator
|
|
252
|
+
// (`extensions.wasm_fdw_handler` / `extensions.wasm_fdw_validator`,
|
|
253
|
+
// joined at extract time) — the same discriminator used for the
|
|
254
|
+
// wrapper itself above. A bare `extensions.*` match would also
|
|
255
|
+
// drop user-created `postgres_fdw` servers/foreign tables/user
|
|
256
|
+
// mappings (whose handler installs into `extensions` but which
|
|
257
|
+
// the local image CAN provision), so keep it scoped to the Wasm
|
|
258
|
+
// function names. Server _privilege_ scope is excluded here —
|
|
259
|
+
// `GRANT/REVOKE ON SERVER` does not require superuser and remains
|
|
260
|
+
// user-declarative state (see CLI-1469 companion test).
|
|
261
|
+
{
|
|
262
|
+
and: [
|
|
263
|
+
{ objectType: "server" },
|
|
264
|
+
{ not: { scope: "privilege" } },
|
|
265
|
+
{
|
|
266
|
+
or: [
|
|
267
|
+
{
|
|
268
|
+
"{server,foreign_table,user_mapping}/wrapper_handler": {
|
|
269
|
+
op: "regex",
|
|
270
|
+
value: "^extensions\\.wasm_fdw_handler$",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"{server,foreign_table,user_mapping}/wrapper_validator": {
|
|
275
|
+
op: "regex",
|
|
276
|
+
value: "^extensions\\.wasm_fdw_validator$",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
and: [
|
|
285
|
+
{ objectType: ["foreign_table", "user_mapping"] },
|
|
286
|
+
{
|
|
287
|
+
or: [
|
|
288
|
+
{
|
|
289
|
+
"{server,foreign_table,user_mapping}/wrapper_handler": {
|
|
290
|
+
op: "regex",
|
|
291
|
+
value: "^extensions\\.wasm_fdw_handler$",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"{server,foreign_table,user_mapping}/wrapper_validator": {
|
|
296
|
+
op: "regex",
|
|
297
|
+
value: "^extensions\\.wasm_fdw_validator$",
|
|
207
298
|
},
|
|
208
299
|
},
|
|
209
300
|
],
|
|
@@ -39,6 +39,18 @@ export declare abstract class BaseChange {
|
|
|
39
39
|
* Defaults to an empty array. Override in subclasses that remove objects.
|
|
40
40
|
*/
|
|
41
41
|
get drops(): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Stable identifiers this change invalidates in place.
|
|
44
|
+
*
|
|
45
|
+
* Unlike `drops`, the object keeps its identity. This is an ordering-only
|
|
46
|
+
* signal for mutations that rewrite an existing object in a way that requires
|
|
47
|
+
* dependents bound to the old definition to be dropped before the mutation
|
|
48
|
+
* and rebuilt afterward.
|
|
49
|
+
*
|
|
50
|
+
* Defaults to an empty array. Override in subclasses that invalidate
|
|
51
|
+
* dependents without dropping the object.
|
|
52
|
+
*/
|
|
53
|
+
get invalidates(): string[];
|
|
42
54
|
/**
|
|
43
55
|
* Stable identifiers this change requires to exist beforehand.
|
|
44
56
|
*
|
|
@@ -31,6 +31,20 @@ export class BaseChange {
|
|
|
31
31
|
get drops() {
|
|
32
32
|
return [];
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Stable identifiers this change invalidates in place.
|
|
36
|
+
*
|
|
37
|
+
* Unlike `drops`, the object keeps its identity. This is an ordering-only
|
|
38
|
+
* signal for mutations that rewrite an existing object in a way that requires
|
|
39
|
+
* dependents bound to the old definition to be dropped before the mutation
|
|
40
|
+
* and rebuilt afterward.
|
|
41
|
+
*
|
|
42
|
+
* Defaults to an empty array. Override in subclasses that invalidate
|
|
43
|
+
* dependents without dropping the object.
|
|
44
|
+
*/
|
|
45
|
+
get invalidates() {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
34
48
|
/**
|
|
35
49
|
* Stable identifiers this change requires to exist beforehand.
|
|
36
50
|
*
|