@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.
Files changed (43) hide show
  1. package/dist/cli/commands/catalog-export.js +22 -1
  2. package/dist/core/catalog.diff.js +22 -3
  3. package/dist/core/catalog.filter.d.ts +17 -0
  4. package/dist/core/catalog.filter.js +75 -0
  5. package/dist/core/catalog.model.js +7 -1
  6. package/dist/core/expand-replace-dependencies.d.ts +3 -1
  7. package/dist/core/expand-replace-dependencies.js +117 -7
  8. package/dist/core/integrations/supabase.js +102 -11
  9. package/dist/core/objects/base.change.d.ts +12 -0
  10. package/dist/core/objects/base.change.js +14 -0
  11. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +4 -0
  12. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +28 -2
  13. package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +4 -0
  14. package/dist/core/objects/foreign-data-wrapper/server/server.model.js +18 -1
  15. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +4 -0
  16. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +18 -1
  17. package/dist/core/objects/materialized-view/materialized-view.diff.d.ts +1 -0
  18. package/dist/core/objects/materialized-view/materialized-view.diff.js +59 -59
  19. package/dist/core/objects/table/changes/table.alter.d.ts +1 -0
  20. package/dist/core/objects/table/changes/table.alter.js +8 -0
  21. package/dist/core/objects/view/view.diff.d.ts +1 -0
  22. package/dist/core/objects/view/view.diff.js +35 -34
  23. package/dist/core/sort/graph-builder.js +6 -0
  24. package/package.json +1 -1
  25. package/src/cli/commands/catalog-export.ts +26 -1
  26. package/src/core/catalog.diff.test.ts +173 -0
  27. package/src/core/catalog.diff.ts +24 -3
  28. package/src/core/catalog.filter.ts +96 -0
  29. package/src/core/catalog.model.ts +10 -2
  30. package/src/core/expand-replace-dependencies.test.ts +282 -0
  31. package/src/core/expand-replace-dependencies.ts +165 -7
  32. package/src/core/integrations/supabase.test.ts +335 -0
  33. package/src/core/integrations/supabase.ts +102 -11
  34. package/src/core/objects/base.change.ts +15 -0
  35. package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +28 -2
  36. package/src/core/objects/foreign-data-wrapper/server/server.model.ts +18 -1
  37. package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +18 -1
  38. package/src/core/objects/materialized-view/materialized-view.diff.test.ts +3 -2
  39. package/src/core/objects/materialized-view/materialized-view.diff.ts +99 -92
  40. package/src/core/objects/table/changes/table.alter.ts +9 -0
  41. package/src/core/objects/view/view.diff.ts +67 -60
  42. package/src/core/sort/graph-builder.ts +6 -0
  43. package/src/core/sort/sort-changes.test.ts +73 -1
@@ -125,6 +125,30 @@ export const supabase: IntegrationDSL = {
125
125
  "trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS],
126
126
  },
127
127
  },
128
+ // Defensive fallback for dynamically-created pgmq queue /
129
+ // archive tables. `pgmq.q_<name>` and `pgmq.a_<name>` are
130
+ // materialized by `select pgmq.create('<name>')`, NOT by
131
+ // `CREATE EXTENSION pgmq`, so emitting a user trigger against
132
+ // them fails locally with
133
+ // `relation "pgmq.q_<name>" does not exist`. On a healthy
134
+ // install the trigger extractor's `extension_table_oids` join
135
+ // (packages/pg-delta/src/core/objects/trigger/trigger.model.ts)
136
+ // already drops these via the `pg_depend deptype='e'` row pgmq
137
+ // records during `pgmq.create()`; this rule covers projects
138
+ // where that row is missing (older pgmq, manual table
139
+ // rewrites, `pg_dump`/restore that loses extension deps, ...).
140
+ // pgmq 1.4.4 — the version Supabase Cloud currently ships —
141
+ // does not record the dependency at all.
142
+ {
143
+ not: {
144
+ and: [
145
+ { "trigger/schema": "pgmq" },
146
+ {
147
+ "trigger/table_name": { op: "regex", value: "^[qa]_" },
148
+ },
149
+ ],
150
+ },
151
+ },
128
152
  ],
129
153
  },
130
154
  // Exclude system objects
@@ -185,15 +209,25 @@ export const supabase: IntegrationDSL = {
185
209
  ],
186
210
  },
187
211
  // Platform-managed foreign data wrappers — Wasm-based FDWs
188
- // (e.g. `clerk`, `clerk_oauth`) whose handler/validator live in
189
- // the `extensions` schema. `CREATE FOREIGN DATA WRAPPER`
190
- // requires superuser, and Supabase Cloud provisions these via
191
- // `supabase_admin` at project creation; replaying the DDL
192
- // against a local image fails because the local environment
193
- // has no equivalent pre-step. We can't rely on the FDW owner
194
- // alone after a dump/restore the owner is often rewritten
195
- // away from `supabase_admin`so match on the function
196
- // reference instead.
212
+ // (e.g. `clerk`, `clerk_oauth`) provisioned via the `wrappers`
213
+ // extension. Supabase Cloud creates these as
214
+ // `CREATE FOREIGN DATA WRAPPER clerk_oauth HANDLER
215
+ // extensions.wasm_fdw_handler VALIDATOR
216
+ // extensions.wasm_fdw_validator` at project creation; replaying
217
+ // the DDL against a local image fails because the local
218
+ // environment has no equivalent pre-step. We can't rely on the
219
+ // FDW owner aloneafter a dump/restore the owner is often
220
+ // rewritten away from `supabase_admin` — so match on the shared
221
+ // Wasm handler/validator (`extensions.wasm_fdw_handler` /
222
+ // `extensions.wasm_fdw_validator`) instead.
223
+ //
224
+ // Matching the bare `extensions.*` namespace would be too broad:
225
+ // contrib FDWs like `postgres_fdw` also install their
226
+ // handler/validator into `extensions` on Supabase, and those ARE
227
+ // available in the local image, so a user-created `postgres_fdw`
228
+ // wrapper (and its servers/foreign tables/user mappings) must
229
+ // still roundtrip. Keying on the `wasm_fdw_*` function names
230
+ // targets only the platform Wasm wrappers.
197
231
  {
198
232
  and: [
199
233
  { objectType: "foreign_data_wrapper" },
@@ -202,13 +236,70 @@ export const supabase: IntegrationDSL = {
202
236
  {
203
237
  "foreign_data_wrapper/handler": {
204
238
  op: "regex",
205
- value: "^extensions\\.",
239
+ value: "^extensions\\.wasm_fdw_handler$",
206
240
  },
207
241
  },
208
242
  {
209
243
  "foreign_data_wrapper/validator": {
210
244
  op: "regex",
211
- value: "^extensions\\.",
245
+ value: "^extensions\\.wasm_fdw_validator$",
246
+ },
247
+ },
248
+ ],
249
+ },
250
+ ],
251
+ },
252
+ // Platform-managed Wasm FDW dependents (CLI-1470 follow-up).
253
+ // Suppressing the wrapper DDL alone leaves `CREATE SERVER` /
254
+ // `CREATE FOREIGN TABLE` / `CREATE USER MAPPING` that reference
255
+ // a wrapper local Docker never provisions (`clerk_oauth`, etc.).
256
+ // Match on the parent wrapper's Wasm handler/validator
257
+ // (`extensions.wasm_fdw_handler` / `extensions.wasm_fdw_validator`,
258
+ // joined at extract time) — the same discriminator used for the
259
+ // wrapper itself above. A bare `extensions.*` match would also
260
+ // drop user-created `postgres_fdw` servers/foreign tables/user
261
+ // mappings (whose handler installs into `extensions` but which
262
+ // the local image CAN provision), so keep it scoped to the Wasm
263
+ // function names. Server _privilege_ scope is excluded here —
264
+ // `GRANT/REVOKE ON SERVER` does not require superuser and remains
265
+ // user-declarative state (see CLI-1469 companion test).
266
+ {
267
+ and: [
268
+ { objectType: "server" },
269
+ { not: { scope: "privilege" } },
270
+ {
271
+ or: [
272
+ {
273
+ "{server,foreign_table,user_mapping}/wrapper_handler": {
274
+ op: "regex",
275
+ value: "^extensions\\.wasm_fdw_handler$",
276
+ },
277
+ },
278
+ {
279
+ "{server,foreign_table,user_mapping}/wrapper_validator": {
280
+ op: "regex",
281
+ value: "^extensions\\.wasm_fdw_validator$",
282
+ },
283
+ },
284
+ ],
285
+ },
286
+ ],
287
+ },
288
+ {
289
+ and: [
290
+ { objectType: ["foreign_table", "user_mapping"] },
291
+ {
292
+ or: [
293
+ {
294
+ "{server,foreign_table,user_mapping}/wrapper_handler": {
295
+ op: "regex",
296
+ value: "^extensions\\.wasm_fdw_handler$",
297
+ },
298
+ },
299
+ {
300
+ "{server,foreign_table,user_mapping}/wrapper_validator": {
301
+ op: "regex",
302
+ value: "^extensions\\.wasm_fdw_validator$",
212
303
  },
213
304
  },
214
305
  ],
@@ -51,6 +51,21 @@ export abstract class BaseChange {
51
51
  return [];
52
52
  }
53
53
 
54
+ /**
55
+ * Stable identifiers this change invalidates in place.
56
+ *
57
+ * Unlike `drops`, the object keeps its identity. This is an ordering-only
58
+ * signal for mutations that rewrite an existing object in a way that requires
59
+ * dependents bound to the old definition to be dropped before the mutation
60
+ * and rebuilt afterward.
61
+ *
62
+ * Defaults to an empty array. Override in subclasses that invalidate
63
+ * dependents without dropping the object.
64
+ */
65
+ get invalidates(): string[] {
66
+ return [];
67
+ }
68
+
54
69
  /**
55
70
  * Stable identifiers this change requires to exist beforehand.
56
71
  *
@@ -36,6 +36,9 @@ const foreignTablePropsSchema = z.object({
36
36
  columns: z.array(columnPropsSchema),
37
37
  privileges: z.array(privilegePropsSchema),
38
38
  security_labels: z.array(securityLabelPropsSchema).default([]).optional(),
39
+ // Parent FDW handler/validator — filter metadata only, not in dataFields.
40
+ wrapper_handler: z.string().nullable().optional(),
41
+ wrapper_validator: z.string().nullable().optional(),
39
42
  });
40
43
 
41
44
  type ForeignTablePrivilegeProps = PrivilegeProps;
@@ -51,6 +54,8 @@ export class ForeignTable extends BasePgModel implements TableLikeObject {
51
54
  public readonly columns: ForeignTableProps["columns"];
52
55
  public readonly privileges: ForeignTablePrivilegeProps[];
53
56
  public readonly security_labels: SecurityLabelProps[];
57
+ public readonly wrapper_handler: ForeignTableProps["wrapper_handler"];
58
+ public readonly wrapper_validator: ForeignTableProps["wrapper_validator"];
54
59
 
55
60
  constructor(props: ForeignTableProps) {
56
61
  super();
@@ -67,6 +72,8 @@ export class ForeignTable extends BasePgModel implements TableLikeObject {
67
72
  this.columns = props.columns;
68
73
  this.privileges = props.privileges;
69
74
  this.security_labels = props.security_labels ?? [];
75
+ this.wrapper_handler = props.wrapper_handler ?? null;
76
+ this.wrapper_validator = props.wrapper_validator ?? null;
70
77
  }
71
78
 
72
79
  get stableId(): `foreignTable:${string}` {
@@ -146,12 +153,22 @@ export async function extractForeignTables(
146
153
  c.relowner::regrole::text as owner,
147
154
  quote_ident(srv.srvname) as server,
148
155
  coalesce(ft.ftoptions, array[]::text[]) as options,
149
- c.oid as oid
156
+ c.oid as oid,
157
+ case
158
+ when fdw.fdwhandler = 0 then null
159
+ else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
160
+ end as wrapper_handler,
161
+ case
162
+ when fdw.fdwvalidator = 0 then null
163
+ else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
164
+ end as wrapper_validator
150
165
  from
151
166
  pg_class c
152
167
  inner join pg_foreign_table ft on ft.ftrelid = c.oid
153
168
  inner join pg_foreign_server srv on srv.oid = ft.ftserver
154
169
  inner join pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
170
+ left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
171
+ left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
155
172
  left outer join extension_oids e1 on c.oid = e1.objid
156
173
  where
157
174
  c.relkind = 'f'
@@ -165,6 +182,8 @@ export async function extractForeignTables(
165
182
  ft.owner,
166
183
  ft.server,
167
184
  ft.options,
185
+ ft.wrapper_handler,
186
+ ft.wrapper_validator,
168
187
  obj_description(ft.oid, 'pg_class') as comment,
169
188
  coalesce(json_agg(
170
189
  case when a.attname is not null then
@@ -250,7 +269,14 @@ export async function extractForeignTables(
250
269
  left join pg_attrdef ad on a.attrelid = ad.adrelid and a.attnum = ad.adnum
251
270
  left join pg_type ty on ty.oid = a.atttypid
252
271
  group by
253
- ft.oid, ft.schema, ft.name, ft.owner, ft.server, ft.options
272
+ ft.oid,
273
+ ft.schema,
274
+ ft.name,
275
+ ft.owner,
276
+ ft.server,
277
+ ft.options,
278
+ ft.wrapper_handler,
279
+ ft.wrapper_validator
254
280
  order by
255
281
  ft.schema, ft.name
256
282
  `);
@@ -26,6 +26,9 @@ const serverPropsSchema = z.object({
26
26
  options: z.array(z.string()).nullable(),
27
27
  comment: z.string().nullable(),
28
28
  privileges: z.array(privilegePropsSchema),
29
+ // Parent FDW handler/validator — filter metadata only, not in dataFields.
30
+ wrapper_handler: z.string().nullable().optional(),
31
+ wrapper_validator: z.string().nullable().optional(),
29
32
  });
30
33
 
31
34
  type ServerPrivilegeProps = PrivilegeProps;
@@ -40,6 +43,8 @@ export class Server extends BasePgModel {
40
43
  public readonly options: ServerProps["options"];
41
44
  public readonly comment: ServerProps["comment"];
42
45
  public readonly privileges: ServerPrivilegeProps[];
46
+ public readonly wrapper_handler: ServerProps["wrapper_handler"];
47
+ public readonly wrapper_validator: ServerProps["wrapper_validator"];
43
48
 
44
49
  constructor(props: ServerProps) {
45
50
  super();
@@ -55,6 +60,8 @@ export class Server extends BasePgModel {
55
60
  this.options = props.options;
56
61
  this.comment = props.comment;
57
62
  this.privileges = props.privileges;
63
+ this.wrapper_handler = props.wrapper_handler ?? null;
64
+ this.wrapper_validator = props.wrapper_validator ?? null;
58
65
  }
59
66
 
60
67
  get stableId(): `server:${string}` {
@@ -112,10 +119,20 @@ export async function extractServers(pool: Pool): Promise<Server[]> {
112
119
  )
113
120
  from lateral aclexplode(srv.srvacl) as x(grantor, grantee, privilege_type, is_grantable)
114
121
  ), '[]'
115
- ) as privileges
122
+ ) as privileges,
123
+ case
124
+ when fdw.fdwhandler = 0 then null
125
+ else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
126
+ end as wrapper_handler,
127
+ case
128
+ when fdw.fdwvalidator = 0 then null
129
+ else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
130
+ end as wrapper_validator
116
131
  from
117
132
  pg_catalog.pg_foreign_server srv
118
133
  inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
134
+ left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
135
+ left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
119
136
  where
120
137
  not fdw.fdwname like any(array['pg\\_%'])
121
138
  order by
@@ -18,6 +18,9 @@ const userMappingPropsSchema = z.object({
18
18
  user: z.string(),
19
19
  server: z.string(),
20
20
  options: z.array(z.string()).nullable(),
21
+ // Parent FDW handler/validator — filter metadata only, not in dataFields.
22
+ wrapper_handler: z.string().nullable().optional(),
23
+ wrapper_validator: z.string().nullable().optional(),
21
24
  });
22
25
 
23
26
  export type UserMappingProps = z.infer<typeof userMappingPropsSchema>;
@@ -26,6 +29,8 @@ export class UserMapping extends BasePgModel {
26
29
  public readonly user: UserMappingProps["user"];
27
30
  public readonly server: UserMappingProps["server"];
28
31
  public readonly options: UserMappingProps["options"];
32
+ public readonly wrapper_handler: UserMappingProps["wrapper_handler"];
33
+ public readonly wrapper_validator: UserMappingProps["wrapper_validator"];
29
34
 
30
35
  constructor(props: UserMappingProps) {
31
36
  super();
@@ -36,6 +41,8 @@ export class UserMapping extends BasePgModel {
36
41
 
37
42
  // Data fields
38
43
  this.options = props.options;
44
+ this.wrapper_handler = props.wrapper_handler ?? null;
45
+ this.wrapper_validator = props.wrapper_validator ?? null;
39
46
  }
40
47
 
41
48
  get stableId(): `userMapping:${string}:${string}` {
@@ -74,11 +81,21 @@ export async function extractUserMappings(pool: Pool): Promise<UserMapping[]> {
74
81
  else um.umuser::regrole::text
75
82
  end as user,
76
83
  quote_ident(srv.srvname) as server,
77
- coalesce(um.umoptions, array[]::text[]) as options
84
+ coalesce(um.umoptions, array[]::text[]) as options,
85
+ case
86
+ when fdw.fdwhandler = 0 then null
87
+ else p_handler.pronamespace::regnamespace::text || '.' || quote_ident(p_handler.proname)
88
+ end as wrapper_handler,
89
+ case
90
+ when fdw.fdwvalidator = 0 then null
91
+ else p_validator.pronamespace::regnamespace::text || '.' || quote_ident(p_validator.proname)
92
+ end as wrapper_validator
78
93
  from
79
94
  pg_catalog.pg_user_mapping um
80
95
  inner join pg_catalog.pg_foreign_server srv on srv.oid = um.umserver
81
96
  inner join pg_catalog.pg_foreign_data_wrapper fdw on fdw.oid = srv.srvfdw
97
+ left join pg_catalog.pg_proc p_handler on p_handler.oid = fdw.fdwhandler
98
+ left join pg_catalog.pg_proc p_validator on p_validator.oid = fdw.fdwvalidator
82
99
  where
83
100
  not fdw.fdwname like any(array['pg\\_%'])
84
101
  order by
@@ -106,7 +106,7 @@ describe.concurrent("materialized-view.diff", () => {
106
106
  expect(changes[0]).toBeInstanceOf(AlterMaterializedViewChangeOwner);
107
107
  });
108
108
 
109
- test("drop + create on non-alterable change", () => {
109
+ test("drop + create with metadata on non-alterable change", () => {
110
110
  const main = new MaterializedView(base);
111
111
  const branch = new MaterializedView({ ...base, definition: "select 2" });
112
112
  const changes = diffMaterializedViews(
@@ -114,9 +114,10 @@ describe.concurrent("materialized-view.diff", () => {
114
114
  { [main.stableId]: main },
115
115
  { [branch.stableId]: branch },
116
116
  );
117
- expect(changes).toHaveLength(2);
117
+ expect(changes).toHaveLength(3);
118
118
  expect(changes[0]).toBeInstanceOf(DropMaterializedView);
119
119
  expect(changes[1]).toBeInstanceOf(CreateMaterializedView);
120
+ expect(changes[2]).toBeInstanceOf(AlterMaterializedViewChangeOwner);
120
121
  });
121
122
 
122
123
  test("alter storage parameters: set and reset", () => {
@@ -30,117 +30,126 @@ import {
30
30
  import type { MaterializedViewChange } from "./changes/materialized-view.types.ts";
31
31
  import type { MaterializedView } from "./materialized-view.model.ts";
32
32
 
33
- /**
34
- * Diff two sets of materialized views from main and branch catalogs.
35
- *
36
- * @param ctx - Context containing version, currentUser, and defaultPrivilegeState
37
- * @param main - The materialized views in the main catalog.
38
- * @param branch - The materialized views in the branch catalog.
39
- * @returns A list of changes to apply to main to make it match branch.
40
- */
41
- export function diffMaterializedViews(
33
+ export function buildCreateMaterializedViewChanges(
42
34
  ctx: Pick<
43
35
  ObjectDiffContext,
44
36
  "version" | "currentUser" | "defaultPrivilegeState"
45
37
  >,
46
- main: Record<string, MaterializedView>,
47
- branch: Record<string, MaterializedView>,
38
+ mv: MaterializedView,
48
39
  ): MaterializedViewChange[] {
49
- const { created, dropped, altered } = diffObjects(main, branch);
50
-
51
- const changes: MaterializedViewChange[] = [];
40
+ const changes: MaterializedViewChange[] = [
41
+ new CreateMaterializedView({
42
+ materializedView: mv,
43
+ }),
44
+ ];
52
45
 
53
- for (const materializedViewId of created) {
54
- const mv = branch[materializedViewId];
46
+ // OWNER: If the materialized view should be owned by someone other than the current user,
47
+ // emit ALTER MATERIALIZED VIEW ... OWNER TO after creation
48
+ if (mv.owner !== ctx.currentUser) {
55
49
  changes.push(
56
- new CreateMaterializedView({
50
+ new AlterMaterializedViewChangeOwner({
57
51
  materializedView: mv,
52
+ owner: mv.owner,
58
53
  }),
59
54
  );
55
+ }
60
56
 
61
- // OWNER: If the materialized view should be owned by someone other than the current user,
62
- // emit ALTER MATERIALIZED VIEW ... OWNER TO after creation
63
- if (mv.owner !== ctx.currentUser) {
57
+ // Materialized view comment on creation
58
+ if (mv.comment !== null) {
59
+ changes.push(
60
+ new CreateCommentOnMaterializedView({
61
+ materializedView: mv,
62
+ }),
63
+ );
64
+ }
65
+ // Column comments on creation
66
+ for (const col of mv.columns) {
67
+ if (col.comment !== null) {
64
68
  changes.push(
65
- new AlterMaterializedViewChangeOwner({
69
+ new CreateCommentOnMaterializedViewColumn({
66
70
  materializedView: mv,
67
- owner: mv.owner,
71
+ column: col,
68
72
  }),
69
73
  );
70
74
  }
75
+ }
71
76
 
72
- // Note: RLS (row_security, force_row_security) is a non-alterable property for materialized views.
73
- // If RLS needs to be enabled, the materialized view must be dropped and recreated, which is
74
- // handled in the "altered" section when non-alterable properties change.
77
+ // Security labels on the matview itself (columns of matviews are not
78
+ // supported targets of SECURITY LABEL, so we only label the relation).
79
+ for (const label of mv.security_labels) {
80
+ changes.push(
81
+ new CreateSecurityLabelOnMaterializedView({
82
+ materializedView: mv,
83
+ securityLabel: label,
84
+ }),
85
+ );
86
+ }
75
87
 
76
- // Materialized view comment on creation
77
- if (mv.comment !== null) {
78
- changes.push(
79
- new CreateCommentOnMaterializedView({
80
- materializedView: mv,
81
- }),
82
- );
83
- }
84
- // Column comments on creation
85
- for (const col of mv.columns) {
86
- if (col.comment !== null) {
87
- changes.push(
88
- new CreateCommentOnMaterializedViewColumn({
89
- materializedView: mv,
90
- column: col,
91
- }),
92
- );
93
- }
94
- }
88
+ // PRIVILEGES: For created objects, compare against default privileges state
89
+ // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
90
+ // so objects are created with the default privileges state in effect.
91
+ // We compare default privileges against desired privileges to generate REVOKE/GRANT statements
92
+ // needed to reach the final desired state.
93
+ const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(
94
+ ctx.currentUser,
95
+ "materialized_view",
96
+ mv.schema ?? "",
97
+ );
98
+ const creatorFilteredDefaults =
99
+ mv.owner !== ctx.currentUser
100
+ ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
101
+ : effectiveDefaults;
102
+ const desiredPrivileges = mv.privileges;
103
+ // Filter out owner privileges - owner always has ALL privileges implicitly
104
+ // and shouldn't be compared. Use the materialized view owner as the reference.
105
+ const privilegeResults = diffPrivileges(
106
+ creatorFilteredDefaults,
107
+ desiredPrivileges,
108
+ mv.owner,
109
+ );
95
110
 
96
- // Security labels on the matview itself (columns of matviews are not
97
- // supported targets of SECURITY LABEL, so we only label the relation).
98
- for (const label of mv.security_labels) {
99
- changes.push(
100
- new CreateSecurityLabelOnMaterializedView({
101
- materializedView: mv,
102
- securityLabel: label,
103
- }),
104
- );
105
- }
111
+ changes.push(
112
+ ...(emitColumnPrivilegeChanges(
113
+ privilegeResults,
114
+ mv,
115
+ mv,
116
+ "materializedView",
117
+ {
118
+ Grant: GrantMaterializedViewPrivileges,
119
+ Revoke: RevokeMaterializedViewPrivileges,
120
+ RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges,
121
+ },
122
+ effectiveDefaults,
123
+ ctx.version,
124
+ ) as MaterializedViewChange[]),
125
+ );
106
126
 
107
- // PRIVILEGES: For created objects, compare against default privileges state
108
- // The migration script will run ALTER DEFAULT PRIVILEGES before CREATE (via constraint spec),
109
- // so objects are created with the default privileges state in effect.
110
- // We compare default privileges against desired privileges to generate REVOKE/GRANT statements
111
- // needed to reach the final desired state.
112
- const effectiveDefaults = ctx.defaultPrivilegeState.getEffectiveDefaults(
113
- ctx.currentUser,
114
- "materialized_view",
115
- mv.schema ?? "",
116
- );
117
- const creatorFilteredDefaults =
118
- mv.owner !== ctx.currentUser
119
- ? effectiveDefaults.filter((p) => p.grantee !== ctx.currentUser)
120
- : effectiveDefaults;
121
- const desiredPrivileges = mv.privileges;
122
- // Filter out owner privileges - owner always has ALL privileges implicitly
123
- // and shouldn't be compared. Use the materialized view owner as the reference.
124
- const privilegeResults = diffPrivileges(
125
- creatorFilteredDefaults,
126
- desiredPrivileges,
127
- mv.owner,
128
- );
127
+ return changes;
128
+ }
129
+
130
+ /**
131
+ * Diff two sets of materialized views from main and branch catalogs.
132
+ *
133
+ * @param ctx - Context containing version, currentUser, and defaultPrivilegeState
134
+ * @param main - The materialized views in the main catalog.
135
+ * @param branch - The materialized views in the branch catalog.
136
+ * @returns A list of changes to apply to main to make it match branch.
137
+ */
138
+ export function diffMaterializedViews(
139
+ ctx: Pick<
140
+ ObjectDiffContext,
141
+ "version" | "currentUser" | "defaultPrivilegeState"
142
+ >,
143
+ main: Record<string, MaterializedView>,
144
+ branch: Record<string, MaterializedView>,
145
+ ): MaterializedViewChange[] {
146
+ const { created, dropped, altered } = diffObjects(main, branch);
129
147
 
148
+ const changes: MaterializedViewChange[] = [];
149
+
150
+ for (const materializedViewId of created) {
130
151
  changes.push(
131
- ...(emitColumnPrivilegeChanges(
132
- privilegeResults,
133
- mv,
134
- mv,
135
- "materializedView",
136
- {
137
- Grant: GrantMaterializedViewPrivileges,
138
- Revoke: RevokeMaterializedViewPrivileges,
139
- RevokeGrantOption: RevokeGrantOptionMaterializedViewPrivileges,
140
- },
141
- effectiveDefaults,
142
- ctx.version,
143
- ) as MaterializedViewChange[]),
152
+ ...buildCreateMaterializedViewChanges(ctx, branch[materializedViewId]),
144
153
  );
145
154
  }
146
155
 
@@ -180,9 +189,7 @@ export function diffMaterializedViews(
180
189
  // Replace the entire materialized view (drop + create)
181
190
  changes.push(
182
191
  new DropMaterializedView({ materializedView: mainMaterializedView }),
183
- new CreateMaterializedView({
184
- materializedView: branchMaterializedView,
185
- }),
192
+ ...buildCreateMaterializedViewChanges(ctx, branchMaterializedView),
186
193
  );
187
194
  } else {
188
195
  // Only alterable properties changed - check each one
@@ -651,6 +651,15 @@ export class AlterTableAlterColumnType extends AlterTableChange {
651
651
  ];
652
652
  }
653
653
 
654
+ get invalidates() {
655
+ // ALTER COLUMN ... TYPE rewrites the column in place. The column keeps its
656
+ // identity, but anything bound to its old type (views, rules, etc.) must be
657
+ // dropped before the rewrite and rebuilt after, so report it as invalidated.
658
+ return [
659
+ stableId.column(this.table.schema, this.table.name, this.column.name),
660
+ ];
661
+ }
662
+
654
663
  serialize(_options?: SerializeOptions): string {
655
664
  // previousColumn is optional so direct serializer tests/fixtures can keep
656
665
  // emitting canonical ALTER TYPE SQL without forcing a USING expression.