@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
|
@@ -52,6 +52,8 @@ declare const foreignTablePropsSchema: z.ZodObject<{
|
|
|
52
52
|
provider: z.ZodString;
|
|
53
53
|
label: z.ZodString;
|
|
54
54
|
}, z.z.core.$strip>>>>;
|
|
55
|
+
wrapper_handler: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
56
|
+
wrapper_validator: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
55
57
|
}, z.z.core.$strip>;
|
|
56
58
|
type ForeignTablePrivilegeProps = PrivilegeProps;
|
|
57
59
|
export type ForeignTableProps = z.infer<typeof foreignTablePropsSchema>;
|
|
@@ -65,6 +67,8 @@ export declare class ForeignTable extends BasePgModel implements TableLikeObject
|
|
|
65
67
|
readonly columns: ForeignTableProps["columns"];
|
|
66
68
|
readonly privileges: ForeignTablePrivilegeProps[];
|
|
67
69
|
readonly security_labels: SecurityLabelProps[];
|
|
70
|
+
readonly wrapper_handler: ForeignTableProps["wrapper_handler"];
|
|
71
|
+
readonly wrapper_validator: ForeignTableProps["wrapper_validator"];
|
|
68
72
|
constructor(props: ForeignTableProps);
|
|
69
73
|
get stableId(): `foreignTable:${string}`;
|
|
70
74
|
get identityFields(): {
|
|
@@ -23,6 +23,9 @@ const foreignTablePropsSchema = z.object({
|
|
|
23
23
|
columns: z.array(columnPropsSchema),
|
|
24
24
|
privileges: z.array(privilegePropsSchema),
|
|
25
25
|
security_labels: z.array(securityLabelPropsSchema).default([]).optional(),
|
|
26
|
+
// Parent FDW handler/validator — filter metadata only, not in dataFields.
|
|
27
|
+
wrapper_handler: z.string().nullable().optional(),
|
|
28
|
+
wrapper_validator: z.string().nullable().optional(),
|
|
26
29
|
});
|
|
27
30
|
export class ForeignTable extends BasePgModel {
|
|
28
31
|
schema;
|
|
@@ -34,6 +37,8 @@ export class ForeignTable extends BasePgModel {
|
|
|
34
37
|
columns;
|
|
35
38
|
privileges;
|
|
36
39
|
security_labels;
|
|
40
|
+
wrapper_handler;
|
|
41
|
+
wrapper_validator;
|
|
37
42
|
constructor(props) {
|
|
38
43
|
super();
|
|
39
44
|
// Identity fields
|
|
@@ -47,6 +52,8 @@ export class ForeignTable extends BasePgModel {
|
|
|
47
52
|
this.columns = props.columns;
|
|
48
53
|
this.privileges = props.privileges;
|
|
49
54
|
this.security_labels = props.security_labels ?? [];
|
|
55
|
+
this.wrapper_handler = props.wrapper_handler ?? null;
|
|
56
|
+
this.wrapper_validator = props.wrapper_validator ?? null;
|
|
50
57
|
}
|
|
51
58
|
get stableId() {
|
|
52
59
|
return `foreignTable:${this.schema}.${this.name}`;
|
|
@@ -114,12 +121,22 @@ export async function extractForeignTables(pool) {
|
|
|
114
121
|
c.relowner::regrole::text as owner,
|
|
115
122
|
quote_ident(srv.srvname) as server,
|
|
116
123
|
coalesce(ft.ftoptions, array[]::text[]) as options,
|
|
117
|
-
c.oid as oid
|
|
124
|
+
c.oid as oid,
|
|
125
|
+
case
|
|
126
|
+
when fdw.fdwhandler = 0 then null
|
|
127
|
+
else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
|
|
128
|
+
end as wrapper_handler,
|
|
129
|
+
case
|
|
130
|
+
when fdw.fdwvalidator = 0 then null
|
|
131
|
+
else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
|
|
132
|
+
end as wrapper_validator
|
|
118
133
|
from
|
|
119
134
|
pg_class c
|
|
120
135
|
inner join pg_foreign_table ft on ft.ftrelid = c.oid
|
|
121
136
|
inner join pg_foreign_server srv on srv.oid = ft.ftserver
|
|
122
137
|
inner join pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
|
|
138
|
+
left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
|
|
139
|
+
left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
|
|
123
140
|
left outer join extension_oids e1 on c.oid = e1.objid
|
|
124
141
|
where
|
|
125
142
|
c.relkind = 'f'
|
|
@@ -133,6 +150,8 @@ export async function extractForeignTables(pool) {
|
|
|
133
150
|
ft.owner,
|
|
134
151
|
ft.server,
|
|
135
152
|
ft.options,
|
|
153
|
+
ft.wrapper_handler,
|
|
154
|
+
ft.wrapper_validator,
|
|
136
155
|
obj_description(ft.oid, 'pg_class') as comment,
|
|
137
156
|
coalesce(json_agg(
|
|
138
157
|
case when a.attname is not null then
|
|
@@ -218,7 +237,14 @@ export async function extractForeignTables(pool) {
|
|
|
218
237
|
left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum
|
|
219
238
|
left join pg_type ty on ty.oid = a.atttypid
|
|
220
239
|
group by
|
|
221
|
-
ft.oid,
|
|
240
|
+
ft.oid,
|
|
241
|
+
ft.schema,
|
|
242
|
+
ft.name,
|
|
243
|
+
ft.owner,
|
|
244
|
+
ft.server,
|
|
245
|
+
ft.options,
|
|
246
|
+
ft.wrapper_handler,
|
|
247
|
+
ft.wrapper_validator
|
|
222
248
|
order by
|
|
223
249
|
ft.schema, ft.name
|
|
224
250
|
`);
|
|
@@ -26,6 +26,8 @@ declare const serverPropsSchema: z.ZodObject<{
|
|
|
26
26
|
grantable: z.ZodBoolean;
|
|
27
27
|
columns: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
|
|
28
28
|
}, z.z.core.$strip>>;
|
|
29
|
+
wrapper_handler: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
30
|
+
wrapper_validator: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
29
31
|
}, z.z.core.$strip>;
|
|
30
32
|
type ServerPrivilegeProps = PrivilegeProps;
|
|
31
33
|
export type ServerProps = z.infer<typeof serverPropsSchema>;
|
|
@@ -38,6 +40,8 @@ export declare class Server extends BasePgModel {
|
|
|
38
40
|
readonly options: ServerProps["options"];
|
|
39
41
|
readonly comment: ServerProps["comment"];
|
|
40
42
|
readonly privileges: ServerPrivilegeProps[];
|
|
43
|
+
readonly wrapper_handler: ServerProps["wrapper_handler"];
|
|
44
|
+
readonly wrapper_validator: ServerProps["wrapper_validator"];
|
|
41
45
|
constructor(props: ServerProps);
|
|
42
46
|
get stableId(): `server:${string}`;
|
|
43
47
|
get identityFields(): {
|
|
@@ -21,6 +21,9 @@ const serverPropsSchema = z.object({
|
|
|
21
21
|
options: z.array(z.string()).nullable(),
|
|
22
22
|
comment: z.string().nullable(),
|
|
23
23
|
privileges: z.array(privilegePropsSchema),
|
|
24
|
+
// Parent FDW handler/validator — filter metadata only, not in dataFields.
|
|
25
|
+
wrapper_handler: z.string().nullable().optional(),
|
|
26
|
+
wrapper_validator: z.string().nullable().optional(),
|
|
24
27
|
});
|
|
25
28
|
export class Server extends BasePgModel {
|
|
26
29
|
name;
|
|
@@ -31,6 +34,8 @@ export class Server extends BasePgModel {
|
|
|
31
34
|
options;
|
|
32
35
|
comment;
|
|
33
36
|
privileges;
|
|
37
|
+
wrapper_handler;
|
|
38
|
+
wrapper_validator;
|
|
34
39
|
constructor(props) {
|
|
35
40
|
super();
|
|
36
41
|
// Identity fields
|
|
@@ -43,6 +48,8 @@ export class Server extends BasePgModel {
|
|
|
43
48
|
this.options = props.options;
|
|
44
49
|
this.comment = props.comment;
|
|
45
50
|
this.privileges = props.privileges;
|
|
51
|
+
this.wrapper_handler = props.wrapper_handler ?? null;
|
|
52
|
+
this.wrapper_validator = props.wrapper_validator ?? null;
|
|
46
53
|
}
|
|
47
54
|
get stableId() {
|
|
48
55
|
return `server:${this.name}`;
|
|
@@ -96,10 +103,20 @@ export async function extractServers(pool) {
|
|
|
96
103
|
)
|
|
97
104
|
from lateral aclexplode(srv.srvacl) as x(grantor, grantee, privilege_type, is_grantable)
|
|
98
105
|
), '[]'
|
|
99
|
-
) as privileges
|
|
106
|
+
) as privileges,
|
|
107
|
+
case
|
|
108
|
+
when fdw.fdwhandler = 0 then null
|
|
109
|
+
else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
|
|
110
|
+
end as wrapper_handler,
|
|
111
|
+
case
|
|
112
|
+
when fdw.fdwvalidator = 0 then null
|
|
113
|
+
else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
|
|
114
|
+
end as wrapper_validator
|
|
100
115
|
from
|
|
101
116
|
pg_catalog.pg_foreign_server srv
|
|
102
117
|
inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
|
|
118
|
+
left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
|
|
119
|
+
left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
|
|
103
120
|
where
|
|
104
121
|
not fdw.fdwname like any(array['pg\\_%'])
|
|
105
122
|
order by
|
|
@@ -16,12 +16,16 @@ declare const userMappingPropsSchema: z.ZodObject<{
|
|
|
16
16
|
user: z.ZodString;
|
|
17
17
|
server: z.ZodString;
|
|
18
18
|
options: z.ZodNullable<z.ZodArray<z.ZodString>>;
|
|
19
|
+
wrapper_handler: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
20
|
+
wrapper_validator: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
19
21
|
}, z.z.core.$strip>;
|
|
20
22
|
export type UserMappingProps = z.infer<typeof userMappingPropsSchema>;
|
|
21
23
|
export declare class UserMapping extends BasePgModel {
|
|
22
24
|
readonly user: UserMappingProps["user"];
|
|
23
25
|
readonly server: UserMappingProps["server"];
|
|
24
26
|
readonly options: UserMappingProps["options"];
|
|
27
|
+
readonly wrapper_handler: UserMappingProps["wrapper_handler"];
|
|
28
|
+
readonly wrapper_validator: UserMappingProps["wrapper_validator"];
|
|
25
29
|
constructor(props: UserMappingProps);
|
|
26
30
|
get stableId(): `userMapping:${string}:${string}`;
|
|
27
31
|
get identityFields(): {
|
|
@@ -16,11 +16,16 @@ const userMappingPropsSchema = z.object({
|
|
|
16
16
|
user: z.string(),
|
|
17
17
|
server: z.string(),
|
|
18
18
|
options: z.array(z.string()).nullable(),
|
|
19
|
+
// Parent FDW handler/validator — filter metadata only, not in dataFields.
|
|
20
|
+
wrapper_handler: z.string().nullable().optional(),
|
|
21
|
+
wrapper_validator: z.string().nullable().optional(),
|
|
19
22
|
});
|
|
20
23
|
export class UserMapping extends BasePgModel {
|
|
21
24
|
user;
|
|
22
25
|
server;
|
|
23
26
|
options;
|
|
27
|
+
wrapper_handler;
|
|
28
|
+
wrapper_validator;
|
|
24
29
|
constructor(props) {
|
|
25
30
|
super();
|
|
26
31
|
// Identity fields
|
|
@@ -28,6 +33,8 @@ export class UserMapping extends BasePgModel {
|
|
|
28
33
|
this.server = props.server;
|
|
29
34
|
// Data fields
|
|
30
35
|
this.options = props.options;
|
|
36
|
+
this.wrapper_handler = props.wrapper_handler ?? null;
|
|
37
|
+
this.wrapper_validator = props.wrapper_validator ?? null;
|
|
31
38
|
}
|
|
32
39
|
get stableId() {
|
|
33
40
|
return `userMapping:${this.server}:${this.user}`;
|
|
@@ -62,11 +69,21 @@ export async function extractUserMappings(pool) {
|
|
|
62
69
|
else um.umuser::regrole::text
|
|
63
70
|
end as user,
|
|
64
71
|
quote_ident(srv.srvname) as server,
|
|
65
|
-
coalesce(um.umoptions, array[]::text[]) as options
|
|
72
|
+
coalesce(um.umoptions, array[]::text[]) as options,
|
|
73
|
+
case
|
|
74
|
+
when fdw.fdwhandler = 0 then null
|
|
75
|
+
else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
|
|
76
|
+
end as wrapper_handler,
|
|
77
|
+
case
|
|
78
|
+
when fdw.fdwvalidator = 0 then null
|
|
79
|
+
else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
|
|
80
|
+
end as wrapper_validator
|
|
66
81
|
from
|
|
67
82
|
pg_catalog.pg_user_mapping um
|
|
68
83
|
inner join pg_catalog.pg_foreign_server srv on srv.oid = um.umserver
|
|
69
84
|
inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
|
|
85
|
+
left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
|
|
86
|
+
left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
|
|
70
87
|
where
|
|
71
88
|
not fdw.fdwname like any(array['pg\\_%'])
|
|
72
89
|
order by
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ObjectDiffContext } from "../diff-context.ts";
|
|
2
2
|
import type { MaterializedViewChange } from "./changes/materialized-view.types.ts";
|
|
3
3
|
import type { MaterializedView } from "./materialized-view.model.ts";
|
|
4
|
+
export declare function buildCreateMaterializedViewChanges(ctx: Pick<ObjectDiffContext, "version" | "currentUser" | "defaultPrivilegeState">, mv: MaterializedView): MaterializedViewChange[];
|
|
4
5
|
/**
|
|
5
6
|
* Diff two sets of materialized views from main and branch catalogs.
|
|
6
7
|
*
|
|
@@ -8,6 +8,63 @@ import { CreateMaterializedView } from "./changes/materialized-view.create.js";
|
|
|
8
8
|
import { DropMaterializedView } from "./changes/materialized-view.drop.js";
|
|
9
9
|
import { GrantMaterializedViewPrivileges, RevokeGrantOptionMaterializedViewPrivileges, RevokeMaterializedViewPrivileges, } from "./changes/materialized-view.privilege.js";
|
|
10
10
|
import { CreateSecurityLabelOnMaterializedView, DropSecurityLabelOnMaterializedView, } from "./changes/materialized-view.security-label.js";
|
|
11
|
+
export function buildCreateMaterializedViewChanges(ctx, mv) {
|
|
12
|
+
const changes = [
|
|
13
|
+
new CreateMaterializedView({
|
|
14
|
+
materializedView: mv,
|
|
15
|
+
}),
|
|
16
|
+
];
|
|
17
|
+
// OWNER: If the materialized view should be owned by someone other than the current user,
|
|
18
|
+
// emit ALTER MATERIALIZED VIEW ... OWNER TO after creation
|
|
19
|
+
if (mv.owner !== ctx.currentUser) {
|
|
20
|
+
changes.push(new AlterMaterializedViewChangeOwner({
|
|
21
|
+
materializedView: mv,
|
|
22
|
+
owner: mv.owner,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
// Materialized view comment on creation
|
|
26
|
+
if (mv.comment !== null) {
|
|
27
|
+
changes.push(new CreateCommentOnMaterializedView({
|
|
28
|
+
materializedView: mv,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
// Column comments on creation
|
|
32
|
+
for (const col of mv.columns) {
|
|
33
|
+
if (col.comment !== null) {
|
|
34
|
+
changes.push(new CreateCommentOnMaterializedViewColumn({
|
|
35
|
+
materializedView: mv,
|
|
36
|
+
column: col,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Security labels on the matview itself (columns of matviews are not
|
|
41
|
+
// supported targets of SECURITY LABEL, so we only label the relation).
|
|
42
|
+
for (const label of mv.security_labels) {
|
|
43
|
+
changes.push(new CreateSecurityLabelOnMaterializedView({
|
|
44
|
+
materializedView: mv,
|
|
45
|
+
securityLabel: label,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
// PRIVILEGES: For created objects, compare against default privileges state
|
|
49
|
+
// The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
|
|
50
|
+
// so objects are created with the default privileges state in effect.
|
|
51
|
+
// We compare default privileges against desired privileges to generate REVOKE/GRANT statements
|
|
52
|
+
// needed to reach the final desired state.
|
|
53
|
+
const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(ctx.currentUser, "materialized_view", mv.schema ?? "");
|
|
54
|
+
const creatorFilteredDefaults = mv.owner !== ctx.currentUser
|
|
55
|
+
? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
|
|
56
|
+
: effectiveDefaults;
|
|
57
|
+
const desiredPrivileges = mv.privileges;
|
|
58
|
+
// Filter out owner privileges - owner always has ALL privileges implicitly
|
|
59
|
+
// and shouldn't be compared. Use the materialized view owner as the reference.
|
|
60
|
+
const privilegeResults = diffPrivileges(creatorFilteredDefaults, desiredPrivileges, mv.owner);
|
|
61
|
+
changes.push(...emitColumnPrivilegeChanges(privilegeResults, mv, mv, "materializedView", {
|
|
62
|
+
Grant: GrantMaterializedViewPrivileges,
|
|
63
|
+
Revoke: RevokeMaterializedViewPrivileges,
|
|
64
|
+
RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges,
|
|
65
|
+
}, effectiveDefaults, ctx.version));
|
|
66
|
+
return changes;
|
|
67
|
+
}
|
|
11
68
|
/**
|
|
12
69
|
* Diff two sets of materialized views from main and branch catalogs.
|
|
13
70
|
*
|
|
@@ -20,62 +77,7 @@ export function diffMaterializedViews(ctx, main, branch) {
|
|
|
20
77
|
const { created, dropped, altered } = diffObjects(main, branch);
|
|
21
78
|
const changes = [];
|
|
22
79
|
for (const materializedViewId of created) {
|
|
23
|
-
|
|
24
|
-
changes.push(new CreateMaterializedView({
|
|
25
|
-
materializedView: mv,
|
|
26
|
-
}));
|
|
27
|
-
// OWNER: If the materialized view should be owned by someone other than the current user,
|
|
28
|
-
// emit ALTER MATERIALIZED VIEW ... OWNER TO after creation
|
|
29
|
-
if (mv.owner !== ctx.currentUser) {
|
|
30
|
-
changes.push(new AlterMaterializedViewChangeOwner({
|
|
31
|
-
materializedView: mv,
|
|
32
|
-
owner: mv.owner,
|
|
33
|
-
}));
|
|
34
|
-
}
|
|
35
|
-
// Note: RLS (row_security, force_row_security) is a non-alterable property for materialized views.
|
|
36
|
-
// If RLS needs to be enabled, the materialized view must be dropped and recreated, which is
|
|
37
|
-
// handled in the "altered" section when non-alterable properties change.
|
|
38
|
-
// Materialized view comment on creation
|
|
39
|
-
if (mv.comment !== null) {
|
|
40
|
-
changes.push(new CreateCommentOnMaterializedView({
|
|
41
|
-
materializedView: mv,
|
|
42
|
-
}));
|
|
43
|
-
}
|
|
44
|
-
// Column comments on creation
|
|
45
|
-
for (const col of mv.columns) {
|
|
46
|
-
if (col.comment !== null) {
|
|
47
|
-
changes.push(new CreateCommentOnMaterializedViewColumn({
|
|
48
|
-
materializedView: mv,
|
|
49
|
-
column: col,
|
|
50
|
-
}));
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
// Security labels on the matview itself (columns of matviews are not
|
|
54
|
-
// supported targets of SECURITY LABEL, so we only label the relation).
|
|
55
|
-
for (const label of mv.security_labels) {
|
|
56
|
-
changes.push(new CreateSecurityLabelOnMaterializedView({
|
|
57
|
-
materializedView: mv,
|
|
58
|
-
securityLabel: label,
|
|
59
|
-
}));
|
|
60
|
-
}
|
|
61
|
-
// PRIVILEGES: For created objects, compare against default privileges state
|
|
62
|
-
// The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
|
|
63
|
-
// so objects are created with the default privileges state in effect.
|
|
64
|
-
// We compare default privileges against desired privileges to generate REVOKE/GRANT statements
|
|
65
|
-
// needed to reach the final desired state.
|
|
66
|
-
const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(ctx.currentUser, "materialized_view", mv.schema ?? "");
|
|
67
|
-
const creatorFilteredDefaults = mv.owner !== ctx.currentUser
|
|
68
|
-
? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
|
|
69
|
-
: effectiveDefaults;
|
|
70
|
-
const desiredPrivileges = mv.privileges;
|
|
71
|
-
// Filter out owner privileges - owner always has ALL privileges implicitly
|
|
72
|
-
// and shouldn't be compared. Use the materialized view owner as the reference.
|
|
73
|
-
const privilegeResults = diffPrivileges(creatorFilteredDefaults, desiredPrivileges, mv.owner);
|
|
74
|
-
changes.push(...emitColumnPrivilegeChanges(privilegeResults, mv, mv, "materializedView", {
|
|
75
|
-
Grant: GrantMaterializedViewPrivileges,
|
|
76
|
-
Revoke: RevokeMaterializedViewPrivileges,
|
|
77
|
-
RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges,
|
|
78
|
-
}, effectiveDefaults, ctx.version));
|
|
80
|
+
changes.push(...buildCreateMaterializedViewChanges(ctx, branch[materializedViewId]));
|
|
79
81
|
}
|
|
80
82
|
for (const materializedViewId of dropped) {
|
|
81
83
|
changes.push(new DropMaterializedView({ materializedView: main[materializedViewId] }));
|
|
@@ -101,9 +103,7 @@ export function diffMaterializedViews(ctx, main, branch) {
|
|
|
101
103
|
const nonAlterablePropsChanged = hasNonAlterableChanges(mainMaterializedView, branchMaterializedView, NON_ALTERABLE_FIELDS, { options: deepEqual });
|
|
102
104
|
if (nonAlterablePropsChanged) {
|
|
103
105
|
// Replace the entire materialized view (drop + create)
|
|
104
|
-
changes.push(new DropMaterializedView({ materializedView: mainMaterializedView }),
|
|
105
|
-
materializedView: branchMaterializedView,
|
|
106
|
-
}));
|
|
106
|
+
changes.push(new DropMaterializedView({ materializedView: mainMaterializedView }), ...buildCreateMaterializedViewChanges(ctx, branchMaterializedView));
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
109
|
// Only alterable properties changed - check each one
|
|
@@ -285,6 +285,7 @@ export declare class AlterTableAlterColumnType extends AlterTableChange {
|
|
|
285
285
|
previousColumn?: ColumnProps;
|
|
286
286
|
});
|
|
287
287
|
get requires(): `column:${string}.${string}.${string}`[];
|
|
288
|
+
get invalidates(): `column:${string}.${string}.${string}`[];
|
|
288
289
|
serialize(_options?: SerializeOptions): string;
|
|
289
290
|
}
|
|
290
291
|
/**
|
|
@@ -452,6 +452,14 @@ export class AlterTableAlterColumnType extends AlterTableChange {
|
|
|
452
452
|
stableId.column(this.table.schema, this.table.name, this.column.name),
|
|
453
453
|
];
|
|
454
454
|
}
|
|
455
|
+
get invalidates() {
|
|
456
|
+
// ALTER COLUMN ... TYPE rewrites the column in place. The column keeps its
|
|
457
|
+
// identity, but anything bound to its old type (views, rules, etc.) must be
|
|
458
|
+
// dropped before the rewrite and rebuilt after, so report it as invalidated.
|
|
459
|
+
return [
|
|
460
|
+
stableId.column(this.table.schema, this.table.name, this.column.name),
|
|
461
|
+
];
|
|
462
|
+
}
|
|
455
463
|
serialize(_options) {
|
|
456
464
|
// previousColumn is optional so direct serializer tests/fixtures can keep
|
|
457
465
|
// emitting canonical ALTER TYPE SQL without forcing a USING expression.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ObjectDiffContext } from "../diff-context.ts";
|
|
2
2
|
import type { ViewChange } from "./changes/view.types.ts";
|
|
3
3
|
import type { View } from "./view.model.ts";
|
|
4
|
+
export declare function buildCreateViewChanges(ctx: Pick<ObjectDiffContext, "version" | "currentUser" | "defaultPrivilegeState">, view: View): ViewChange[];
|
|
4
5
|
/**
|
|
5
6
|
* Diff two sets of views from main and branch catalogs.
|
|
6
7
|
*
|
|
@@ -9,6 +9,39 @@ import { CreateView } from "./changes/view.create.js";
|
|
|
9
9
|
import { DropView } from "./changes/view.drop.js";
|
|
10
10
|
import { GrantViewPrivileges, RevokeGrantOptionViewPrivileges, RevokeViewPrivileges, } from "./changes/view.privilege.js";
|
|
11
11
|
import { CreateSecurityLabelOnView, DropSecurityLabelOnView, } from "./changes/view.security-label.js";
|
|
12
|
+
export function buildCreateViewChanges(ctx, view) {
|
|
13
|
+
const changes = [new CreateView({ view })];
|
|
14
|
+
// OWNER: If the view should be owned by someone other than the current user,
|
|
15
|
+
// emit ALTER VIEW ... OWNER TO after creation
|
|
16
|
+
if (view.owner !== ctx.currentUser) {
|
|
17
|
+
changes.push(new AlterViewChangeOwner({ view, owner: view.owner }));
|
|
18
|
+
}
|
|
19
|
+
if (view.comment !== null) {
|
|
20
|
+
changes.push(new CreateCommentOnView({ view }));
|
|
21
|
+
}
|
|
22
|
+
for (const label of view.security_labels) {
|
|
23
|
+
changes.push(new CreateSecurityLabelOnView({ view, securityLabel: label }));
|
|
24
|
+
}
|
|
25
|
+
// PRIVILEGES: For created objects, compare against default privileges state
|
|
26
|
+
// The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
|
|
27
|
+
// so objects are created with the default privileges state in effect.
|
|
28
|
+
// We compare default privileges against desired privileges to generate REVOKE/GRANT statements
|
|
29
|
+
// needed to reach the final desired state.
|
|
30
|
+
const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(ctx.currentUser, "view", view.schema ?? "");
|
|
31
|
+
const creatorFilteredDefaults = view.owner !== ctx.currentUser
|
|
32
|
+
? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
|
|
33
|
+
: effectiveDefaults;
|
|
34
|
+
const desiredPrivileges = view.privileges;
|
|
35
|
+
// Filter out owner privileges - owner always has ALL privileges implicitly
|
|
36
|
+
// and shouldn't be compared. Use the view owner as the reference.
|
|
37
|
+
const privilegeResults = diffPrivileges(creatorFilteredDefaults, desiredPrivileges, view.owner);
|
|
38
|
+
changes.push(...emitColumnPrivilegeChanges(privilegeResults, view, view, "view", {
|
|
39
|
+
Grant: GrantViewPrivileges,
|
|
40
|
+
Revoke: RevokeViewPrivileges,
|
|
41
|
+
RevokeGrantOption: RevokeGrantOptionViewPrivileges,
|
|
42
|
+
}, effectiveDefaults, ctx.version));
|
|
43
|
+
return changes;
|
|
44
|
+
}
|
|
12
45
|
/**
|
|
13
46
|
* Diff two sets of views from main and branch catalogs.
|
|
14
47
|
*
|
|
@@ -20,40 +53,8 @@ import { CreateSecurityLabelOnView, DropSecurityLabelOnView, } from "./changes/v
|
|
|
20
53
|
export function diffViews(ctx, main, branch) {
|
|
21
54
|
const { created, dropped, altered } = diffObjects(main, branch);
|
|
22
55
|
const changes = [];
|
|
23
|
-
const appendCreateViewChanges = (view) => {
|
|
24
|
-
changes.push(new CreateView({ view }));
|
|
25
|
-
// OWNER: If the view should be owned by someone other than the current user,
|
|
26
|
-
// emit ALTER VIEW ... OWNER TO after creation
|
|
27
|
-
if (view.owner !== ctx.currentUser) {
|
|
28
|
-
changes.push(new AlterViewChangeOwner({ view, owner: view.owner }));
|
|
29
|
-
}
|
|
30
|
-
if (view.comment !== null) {
|
|
31
|
-
changes.push(new CreateCommentOnView({ view }));
|
|
32
|
-
}
|
|
33
|
-
for (const label of view.security_labels) {
|
|
34
|
-
changes.push(new CreateSecurityLabelOnView({ view, securityLabel: label }));
|
|
35
|
-
}
|
|
36
|
-
// PRIVILEGES: For created objects, compare against default privileges state
|
|
37
|
-
// The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
|
|
38
|
-
// so objects are created with the default privileges state in effect.
|
|
39
|
-
// We compare default privileges against desired privileges to generate REVOKE/GRANT statements
|
|
40
|
-
// needed to reach the final desired state.
|
|
41
|
-
const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(ctx.currentUser, "view", view.schema ?? "");
|
|
42
|
-
const creatorFilteredDefaults = view.owner !== ctx.currentUser
|
|
43
|
-
? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
|
|
44
|
-
: effectiveDefaults;
|
|
45
|
-
const desiredPrivileges = view.privileges;
|
|
46
|
-
// Filter out owner privileges - owner always has ALL privileges implicitly
|
|
47
|
-
// and shouldn't be compared. Use the view owner as the reference.
|
|
48
|
-
const privilegeResults = diffPrivileges(creatorFilteredDefaults, desiredPrivileges, view.owner);
|
|
49
|
-
changes.push(...emitColumnPrivilegeChanges(privilegeResults, view, view, "view", {
|
|
50
|
-
Grant: GrantViewPrivileges,
|
|
51
|
-
Revoke: RevokeViewPrivileges,
|
|
52
|
-
RevokeGrantOption: RevokeGrantOptionViewPrivileges,
|
|
53
|
-
}, effectiveDefaults, ctx.version));
|
|
54
|
-
};
|
|
55
56
|
for (const viewId of created) {
|
|
56
|
-
|
|
57
|
+
changes.push(...buildCreateViewChanges(ctx, branch[viewId]));
|
|
57
58
|
}
|
|
58
59
|
for (const viewId of dropped) {
|
|
59
60
|
changes.push(new DropView({ view: main[viewId] }));
|
|
@@ -83,7 +84,7 @@ export function diffViews(ctx, main, branch) {
|
|
|
83
84
|
// NON_ALTERABLE_FIELDS - a position change always implies a definition change.
|
|
84
85
|
if (!deepEqual(normalizeColumns(mainView.columns), normalizeColumns(branchView.columns))) {
|
|
85
86
|
changes.push(new DropView({ view: mainView }));
|
|
86
|
-
|
|
87
|
+
changes.push(...buildCreateViewChanges(ctx, branchView));
|
|
87
88
|
}
|
|
88
89
|
else if (nonAlterablePropsChanged) {
|
|
89
90
|
// Replace the entire view using CREATE OR REPLACE to avoid drop when possible
|
|
@@ -119,6 +119,12 @@ export function buildGraphData(phaseChanges, options) {
|
|
|
119
119
|
for (const droppedId of changeItem.drops ?? []) {
|
|
120
120
|
createdIds.add(droppedId);
|
|
121
121
|
}
|
|
122
|
+
// In-place mutations keep the object identity but invalidate
|
|
123
|
+
// dependents, so for drop-phase ordering they behave like producers of
|
|
124
|
+
// the invalidated ids without changing Change.drops.
|
|
125
|
+
for (const invalidatedId of changeItem.invalidates) {
|
|
126
|
+
createdIds.add(invalidatedId);
|
|
127
|
+
}
|
|
122
128
|
}
|
|
123
129
|
return createdIds;
|
|
124
130
|
});
|
package/package.json
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { writeFile } from "node:fs/promises";
|
|
6
6
|
import { buildCommand, type CommandContext } from "@stricli/core";
|
|
7
|
+
import { filterCatalog } from "../../core/catalog.filter.ts";
|
|
7
8
|
import { extractCatalog } from "../../core/catalog.model.ts";
|
|
8
9
|
import {
|
|
9
10
|
serializeCatalog,
|
|
10
11
|
stringifyCatalogSnapshot,
|
|
11
12
|
} from "../../core/catalog.snapshot.ts";
|
|
13
|
+
import type { FilterDSL } from "../../core/integrations/filter/dsl.ts";
|
|
12
14
|
import { createManagedPool } from "../../core/postgres-config.ts";
|
|
13
15
|
|
|
14
16
|
export const catalogExportCommand = buildCommand({
|
|
@@ -30,6 +32,21 @@ export const catalogExportCommand = buildCommand({
|
|
|
30
32
|
parse: String,
|
|
31
33
|
optional: true,
|
|
32
34
|
},
|
|
35
|
+
filter: {
|
|
36
|
+
kind: "parsed",
|
|
37
|
+
brief:
|
|
38
|
+
'Filter DSL as inline JSON to filter changes (e.g., \'{"*/schema": "app"}\').',
|
|
39
|
+
parse: (value: string): FilterDSL => {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(value) as FilterDSL;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid filter JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
optional: true,
|
|
49
|
+
},
|
|
33
50
|
},
|
|
34
51
|
aliases: {
|
|
35
52
|
t: "target",
|
|
@@ -48,6 +65,10 @@ Use cases:
|
|
|
48
65
|
- Snapshot template1 for use as an empty-database baseline
|
|
49
66
|
- Snapshot a production database to generate revert migrations
|
|
50
67
|
- Snapshot any state for reproducible offline diffs
|
|
68
|
+
|
|
69
|
+
Pass --filter to scope the snapshot to a subset of the catalog (same
|
|
70
|
+
Filter DSL accepted by plan/sync). Useful when committing a baseline
|
|
71
|
+
snapshot to a repo and only one schema's drift is interesting.
|
|
51
72
|
`.trim(),
|
|
52
73
|
},
|
|
53
74
|
async func(
|
|
@@ -56,6 +77,7 @@ Use cases:
|
|
|
56
77
|
target: string;
|
|
57
78
|
output: string;
|
|
58
79
|
role?: string;
|
|
80
|
+
filter?: FilterDSL;
|
|
59
81
|
},
|
|
60
82
|
) {
|
|
61
83
|
const { pool, close } = await createManagedPool(flags.target, {
|
|
@@ -65,7 +87,10 @@ Use cases:
|
|
|
65
87
|
|
|
66
88
|
try {
|
|
67
89
|
const catalog = await extractCatalog(pool);
|
|
68
|
-
const
|
|
90
|
+
const scoped = flags.filter
|
|
91
|
+
? await filterCatalog(catalog, flags.filter)
|
|
92
|
+
: catalog;
|
|
93
|
+
const snapshot = serializeCatalog(scoped);
|
|
69
94
|
const json = stringifyCatalogSnapshot(snapshot);
|
|
70
95
|
await writeFile(flags.output, json, "utf-8");
|
|
71
96
|
this.process.stdout.write(
|