@supabase/pg-delta 1.0.0-alpha.26 → 1.0.0-alpha.27

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.
@@ -0,0 +1,96 @@
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
+
16
+ import { diffCatalogs } from "./catalog.diff.ts";
17
+ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
18
+ import { compileFilterDSL, type FilterDSL } from "./integrations/filter/dsl.ts";
19
+
20
+ export async function filterCatalog(
21
+ catalog: Catalog,
22
+ filter: FilterDSL,
23
+ ): Promise<Catalog> {
24
+ if (
25
+ typeof filter === "object" &&
26
+ filter !== null &&
27
+ (filter as Record<string, unknown>).cascade === true
28
+ ) {
29
+ throw new Error(
30
+ "Filter DSL `cascade: true` is not supported by catalog-export: " +
31
+ "scoped snapshots are intentionally partial. Out-of-scope owners, " +
32
+ "roles, and types must exist on the target DB at apply time.",
33
+ );
34
+ }
35
+
36
+ const empty = await createEmptyCatalog(catalog.version, catalog.currentUser);
37
+ const changes = diffCatalogs(empty, catalog);
38
+ const filterFn = compileFilterDSL(filter);
39
+
40
+ const keep = new Set<string>();
41
+ for (const change of changes) {
42
+ if (!filterFn(change)) continue;
43
+ for (const id of change.creates ?? []) keep.add(id);
44
+ }
45
+
46
+ return pruneCatalog(catalog, keep);
47
+ }
48
+
49
+ function filterRecord<T>(
50
+ record: Record<string, T>,
51
+ keep: ReadonlySet<string>,
52
+ ): Record<string, T> {
53
+ return Object.fromEntries(
54
+ Object.entries(record).filter(([id]) => keep.has(id)),
55
+ );
56
+ }
57
+
58
+ function pruneCatalog(catalog: Catalog, keep: ReadonlySet<string>): Catalog {
59
+ const tables = filterRecord(catalog.tables, keep);
60
+ const materializedViews = filterRecord(catalog.materializedViews, keep);
61
+
62
+ return new Catalog({
63
+ aggregates: filterRecord(catalog.aggregates, keep),
64
+ collations: filterRecord(catalog.collations, keep),
65
+ compositeTypes: filterRecord(catalog.compositeTypes, keep),
66
+ domains: filterRecord(catalog.domains, keep),
67
+ enums: filterRecord(catalog.enums, keep),
68
+ extensions: filterRecord(catalog.extensions, keep),
69
+ procedures: filterRecord(catalog.procedures, keep),
70
+ indexes: filterRecord(catalog.indexes, keep),
71
+ materializedViews,
72
+ subscriptions: filterRecord(catalog.subscriptions, keep),
73
+ publications: filterRecord(catalog.publications, keep),
74
+ rlsPolicies: filterRecord(catalog.rlsPolicies, keep),
75
+ roles: filterRecord(catalog.roles, keep),
76
+ schemas: filterRecord(catalog.schemas, keep),
77
+ sequences: filterRecord(catalog.sequences, keep),
78
+ tables,
79
+ triggers: filterRecord(catalog.triggers, keep),
80
+ eventTriggers: filterRecord(catalog.eventTriggers, keep),
81
+ rules: filterRecord(catalog.rules, keep),
82
+ ranges: filterRecord(catalog.ranges, keep),
83
+ views: filterRecord(catalog.views, keep),
84
+ foreignDataWrappers: filterRecord(catalog.foreignDataWrappers, keep),
85
+ servers: filterRecord(catalog.servers, keep),
86
+ userMappings: filterRecord(catalog.userMappings, keep),
87
+ foreignTables: filterRecord(catalog.foreignTables, keep),
88
+ depends: catalog.depends.filter(
89
+ (d) =>
90
+ keep.has(d.dependent_stable_id) && keep.has(d.referenced_stable_id),
91
+ ),
92
+ indexableObjects: { ...tables, ...materializedViews },
93
+ version: catalog.version,
94
+ currentUser: catalog.currentUser,
95
+ });
96
+ }
@@ -182,8 +182,10 @@ let _pg1516Baseline: Catalog | null = null;
182
182
  let _pg17Baseline: Catalog | null = null;
183
183
 
184
184
  async function loadBaselineJson(): Promise<Record<string, unknown>> {
185
- const mod =
186
- await import("./fixtures/empty-catalogs/postgres-15-16-baseline.json");
185
+ const mod = await import(
186
+ "./fixtures/empty-catalogs/postgres-15-16-baseline.json",
187
+ { with: { type: "json" } }
188
+ );
187
189
  return mod.default as Record<string, unknown>;
188
190
  }
189
191
 
@@ -447,6 +449,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
447
449
  options: redactSensitiveOptionPairs(server.options),
448
450
  comment: server.comment,
449
451
  privileges: server.privileges,
452
+ wrapper_handler: server.wrapper_handler,
453
+ wrapper_validator: server.wrapper_validator,
450
454
  });
451
455
  });
452
456
 
@@ -455,6 +459,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
455
459
  user: mapping.user,
456
460
  server: mapping.server,
457
461
  options: redactSensitiveOptionPairs(mapping.options),
462
+ wrapper_handler: mapping.wrapper_handler,
463
+ wrapper_validator: mapping.wrapper_validator,
458
464
  });
459
465
  });
460
466
 
@@ -470,6 +476,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
470
476
  options: redactSensitiveOptionPairs(foreignTable.options),
471
477
  comment: foreignTable.comment,
472
478
  privileges: foreignTable.privileges,
479
+ wrapper_handler: foreignTable.wrapper_handler,
480
+ wrapper_validator: foreignTable.wrapper_validator,
473
481
  }),
474
482
  );
475
483
 
@@ -55,6 +55,86 @@ function fdwPrivilegeChange(fdw: { name: string; owner: string }): Change {
55
55
  } as unknown as Change;
56
56
  }
57
57
 
58
+ function serverChange(
59
+ operation: "create" | "alter" | "drop",
60
+ server: {
61
+ name: string;
62
+ owner: string;
63
+ foreign_data_wrapper: string;
64
+ wrapper_handler: string | null;
65
+ wrapper_validator: string | null;
66
+ },
67
+ ): Change {
68
+ return {
69
+ objectType: "server",
70
+ operation,
71
+ scope: "object",
72
+ server: {
73
+ type: null,
74
+ version: null,
75
+ options: null,
76
+ comment: null,
77
+ privileges: [],
78
+ ...server,
79
+ },
80
+ requires: [],
81
+ creates: [],
82
+ drops: [],
83
+ } as unknown as Change;
84
+ }
85
+
86
+ function foreignTableChange(
87
+ operation: "create" | "alter" | "drop",
88
+ foreignTable: {
89
+ schema: string;
90
+ name: string;
91
+ owner: string;
92
+ server: string;
93
+ wrapper_handler: string | null;
94
+ wrapper_validator: string | null;
95
+ },
96
+ ): Change {
97
+ return {
98
+ objectType: "foreign_table",
99
+ operation,
100
+ scope: "object",
101
+ foreignTable: {
102
+ options: null,
103
+ comment: null,
104
+ columns: [],
105
+ privileges: [],
106
+ security_labels: [],
107
+ ...foreignTable,
108
+ },
109
+ requires: [],
110
+ creates: [],
111
+ drops: [],
112
+ } as unknown as Change;
113
+ }
114
+
115
+ function userMappingChange(
116
+ operation: "create" | "alter" | "drop",
117
+ userMapping: {
118
+ user: string;
119
+ server: string;
120
+ wrapper_handler: string | null;
121
+ wrapper_validator: string | null;
122
+ },
123
+ ): Change {
124
+ return {
125
+ objectType: "user_mapping",
126
+ operation,
127
+ scope: "object",
128
+ userMapping: {
129
+ options: null,
130
+ ...userMapping,
131
+ },
132
+ requires: [],
133
+ creates: [],
134
+ drops: [],
135
+ } as unknown as Change;
136
+ }
137
+
58
138
  function serverPrivilegeChange(server: {
59
139
  name: string;
60
140
  owner: string;
@@ -71,6 +151,33 @@ function serverPrivilegeChange(server: {
71
151
  } as unknown as Change;
72
152
  }
73
153
 
154
+ /**
155
+ * Build a synthetic trigger change shaped like what `flattenChange` consumes.
156
+ * The flattener emits keys `trigger/schema`, `trigger/table_name`,
157
+ * `trigger/function_schema`, etc. by walking the nested `trigger` model.
158
+ */
159
+ function triggerChange(
160
+ operation: "create" | "alter" | "drop",
161
+ trigger: {
162
+ schema: string;
163
+ name: string;
164
+ table_name: string;
165
+ function_schema: string;
166
+ function_name: string;
167
+ owner: string;
168
+ },
169
+ ): Change {
170
+ return {
171
+ objectType: "trigger",
172
+ operation,
173
+ scope: "object",
174
+ trigger,
175
+ requires: [],
176
+ creates: [],
177
+ drops: [],
178
+ } as unknown as Change;
179
+ }
180
+
74
181
  describe("supabase integration filter — foreign data wrappers", () => {
75
182
  // Regression for CLI-1470. Wasm-based foreign data wrappers on Supabase
76
183
  // (e.g. `clerk`, `clerk_oauth`) are provisioned at project creation by
@@ -128,6 +235,35 @@ describe("supabase integration filter — foreign data wrappers", () => {
128
235
  expect(evaluatePattern(filter, change)).toBe(true);
129
236
  });
130
237
 
238
+ // `postgres_fdw` (and other contrib FDWs) install their handler/validator
239
+ // into `extensions` on Supabase, but they ARE available in the local image,
240
+ // so a user-created `postgres_fdw` wrapper must roundtrip. Only the Wasm
241
+ // `wasm_fdw_handler` / `wasm_fdw_validator` functions identify the
242
+ // platform-managed wrappers that local Docker cannot provision.
243
+ test("preserves user FDW whose handler is extensions.postgres_fdw_handler", () => {
244
+ const change = fdwChange("create", {
245
+ name: "postgres_fdw",
246
+ owner: "postgres",
247
+ handler: "extensions.postgres_fdw_handler",
248
+ validator: "extensions.postgres_fdw_validator",
249
+ });
250
+ expect(evaluatePattern(filter, change)).toBe(true);
251
+ });
252
+
253
+ // The Wasm discriminator must be an exact function-name match, not a
254
+ // prefix: a user function whose name merely starts with `wasm_fdw_handler`
255
+ // (e.g. `wasm_fdw_handler_custom`) is not the platform `wrappers` handler
256
+ // and must roundtrip.
257
+ test("preserves user FDW whose handler extends the wasm_fdw_handler prefix", () => {
258
+ const change = fdwChange("create", {
259
+ name: "custom_wasm",
260
+ owner: "postgres",
261
+ handler: "extensions.wasm_fdw_handler_custom",
262
+ validator: "extensions.wasm_fdw_validator_custom",
263
+ });
264
+ expect(evaluatePattern(filter, change)).toBe(true);
265
+ });
266
+
131
267
  test("preserves user FDW with no handler/validator", () => {
132
268
  const change = fdwChange("create", {
133
269
  name: "user_fdw_bare",
@@ -196,3 +332,202 @@ describe("supabase integration filter — foreign data wrapper / server ACLs", (
196
332
  expect(evaluatePattern(filter, change)).toBe(true);
197
333
  });
198
334
  });
335
+
336
+ describe("supabase integration filter — Wasm FDW dependents", () => {
337
+ const wasmWrapper = {
338
+ wrapper_handler: "extensions.wasm_fdw_handler",
339
+ wrapper_validator: "extensions.wasm_fdw_validator",
340
+ } as const;
341
+
342
+ const userWrapper = {
343
+ wrapper_handler: "public.postgres_fdw_handler",
344
+ wrapper_validator: "public.postgres_fdw_validator",
345
+ } as const;
346
+
347
+ // `postgres_fdw` installs its handler/validator into `extensions` on
348
+ // Supabase, but the contrib FDW IS available locally, so user-owned
349
+ // servers / foreign tables / user mappings built on it must roundtrip.
350
+ // Keying suppression on the bare `extensions.*` namespace would wrongly
351
+ // drop them; only the Wasm `wasm_fdw_*` functions mark platform wrappers.
352
+ const extensionsPgFdwWrapper = {
353
+ wrapper_handler: "extensions.postgres_fdw_handler",
354
+ wrapper_validator: "extensions.postgres_fdw_validator",
355
+ } as const;
356
+
357
+ test("suppresses CREATE SERVER bound to extensions.* Wasm FDW", () => {
358
+ const change = serverChange("create", {
359
+ name: "clerk_oauth_server",
360
+ owner: "postgres",
361
+ foreign_data_wrapper: "clerk_oauth",
362
+ ...wasmWrapper,
363
+ });
364
+ expect(evaluatePattern(filter, change)).toBe(false);
365
+ });
366
+
367
+ test("suppresses DROP FOREIGN TABLE bound to extensions.* Wasm FDW", () => {
368
+ const change = foreignTableChange("drop", {
369
+ schema: "public",
370
+ name: "clerk_oauth",
371
+ owner: "postgres",
372
+ server: "clerk_oauth_server",
373
+ ...wasmWrapper,
374
+ });
375
+ expect(evaluatePattern(filter, change)).toBe(false);
376
+ });
377
+
378
+ test("suppresses ALTER FOREIGN TABLE bound to extensions.* Wasm FDW", () => {
379
+ const change = foreignTableChange("alter", {
380
+ schema: "public",
381
+ name: "clerk_oauth",
382
+ owner: "postgres",
383
+ server: "clerk_oauth_server",
384
+ ...wasmWrapper,
385
+ });
386
+ expect(evaluatePattern(filter, change)).toBe(false);
387
+ });
388
+
389
+ test("suppresses DROP USER MAPPING bound to extensions.* Wasm FDW", () => {
390
+ const change = userMappingChange("drop", {
391
+ user: "postgres",
392
+ server: "clerk_server",
393
+ ...wasmWrapper,
394
+ });
395
+ expect(evaluatePattern(filter, change)).toBe(false);
396
+ });
397
+
398
+ test("suppresses CREATE USER MAPPING when only wrapper validator is in extensions", () => {
399
+ const change = userMappingChange("create", {
400
+ user: "postgres",
401
+ server: "clerk_server",
402
+ wrapper_handler: null,
403
+ wrapper_validator: "extensions.wasm_fdw_validator",
404
+ });
405
+ expect(evaluatePattern(filter, change)).toBe(false);
406
+ });
407
+
408
+ test("preserves CREATE SERVER bound to user postgres_fdw wrapper", () => {
409
+ const change = serverChange("create", {
410
+ name: "live_risk_server",
411
+ owner: "postgres",
412
+ foreign_data_wrapper: "postgres_fdw",
413
+ ...userWrapper,
414
+ });
415
+ expect(evaluatePattern(filter, change)).toBe(true);
416
+ });
417
+
418
+ test("preserves server ACL when postgres_fdw handler lives in extensions", () => {
419
+ const change = serverPrivilegeChange({
420
+ name: "user_server",
421
+ owner: "postgres",
422
+ });
423
+ (change as unknown as { server: Record<string, unknown> }).server = {
424
+ name: "user_server",
425
+ owner: "postgres",
426
+ wrapper_handler: "extensions.postgres_fdw_handler",
427
+ wrapper_validator: "extensions.postgres_fdw_validator",
428
+ };
429
+ expect(evaluatePattern(filter, change)).toBe(true);
430
+ });
431
+
432
+ test("preserves CREATE FOREIGN TABLE on user postgres_fdw server", () => {
433
+ const change = foreignTableChange("create", {
434
+ schema: "live_risk",
435
+ name: "devices",
436
+ owner: "postgres",
437
+ server: "live_risk_server",
438
+ ...userWrapper,
439
+ });
440
+ expect(evaluatePattern(filter, change)).toBe(true);
441
+ });
442
+
443
+ test("preserves CREATE SERVER when postgres_fdw handler lives in extensions", () => {
444
+ const change = serverChange("create", {
445
+ name: "user_pg_server",
446
+ owner: "postgres",
447
+ foreign_data_wrapper: "postgres_fdw",
448
+ ...extensionsPgFdwWrapper,
449
+ });
450
+ expect(evaluatePattern(filter, change)).toBe(true);
451
+ });
452
+
453
+ test("preserves CREATE FOREIGN TABLE when postgres_fdw handler lives in extensions", () => {
454
+ const change = foreignTableChange("create", {
455
+ schema: "user_fdw_test",
456
+ name: "remote_row",
457
+ owner: "postgres",
458
+ server: "user_pg_server",
459
+ ...extensionsPgFdwWrapper,
460
+ });
461
+ expect(evaluatePattern(filter, change)).toBe(true);
462
+ });
463
+
464
+ test("preserves CREATE USER MAPPING when postgres_fdw handler lives in extensions", () => {
465
+ const change = userMappingChange("create", {
466
+ user: "postgres",
467
+ server: "user_pg_server",
468
+ ...extensionsPgFdwWrapper,
469
+ });
470
+ expect(evaluatePattern(filter, change)).toBe(true);
471
+ });
472
+
473
+ // Exact-match guard at the dependent level too: a server bound to a wrapper
474
+ // whose handler merely shares the `wasm_fdw_handler` prefix must roundtrip.
475
+ test("preserves CREATE SERVER when wrapper handler extends the wasm_fdw_handler prefix", () => {
476
+ const change = serverChange("create", {
477
+ name: "custom_wasm_server",
478
+ owner: "postgres",
479
+ foreign_data_wrapper: "custom_wasm",
480
+ wrapper_handler: "extensions.wasm_fdw_handler_custom",
481
+ wrapper_validator: "extensions.wasm_fdw_validator_custom",
482
+ });
483
+ expect(evaluatePattern(filter, change)).toBe(true);
484
+ });
485
+ });
486
+
487
+ describe("supabase integration filter — pgmq queue triggers", () => {
488
+ // Regression for the pgmq-1.4.4 cloud projects. `pgmq.create('<name>')`
489
+ // materializes `pgmq.q_<name>` and `pgmq.a_<name>` at runtime — they are
490
+ // NOT created by `CREATE EXTENSION pgmq`. On a healthy install the trigger
491
+ // extractor's `extension_table_oids` join already drops these via the
492
+ // `pg_depend deptype='e'` row that newer pgmq versions record, but on
493
+ // pgmq 1.4.4 that row is never recorded, so user triggers on the queue
494
+ // tables leak into the diff and break `supabase db reset` with
495
+ // `relation "pgmq.q_<name>" does not exist`. The filter must drop them
496
+ // at the supabase-integration level too, regardless of pg_depend state.
497
+
498
+ test("suppresses CREATE trigger on pgmq.q_<name> calling a public function", () => {
499
+ const change = triggerChange("create", {
500
+ schema: "pgmq",
501
+ name: "after_insert_processed_milestones_queue",
502
+ table_name: "q_processed_milestones_queue",
503
+ function_schema: "public",
504
+ function_name: "move_data_from_queue",
505
+ owner: "postgres",
506
+ });
507
+ expect(evaluatePattern(filter, change)).toBe(false);
508
+ });
509
+
510
+ test("suppresses DROP trigger on pgmq.a_<name> calling a public function", () => {
511
+ const change = triggerChange("drop", {
512
+ schema: "pgmq",
513
+ name: "after_insert_archive",
514
+ table_name: "a_processed_milestones_queue",
515
+ function_schema: "public",
516
+ function_name: "archive_handler",
517
+ owner: "postgres",
518
+ });
519
+ expect(evaluatePattern(filter, change)).toBe(false);
520
+ });
521
+
522
+ test("preserves CREATE trigger on auth.users calling a public function", () => {
523
+ const change = triggerChange("create", {
524
+ schema: "auth",
525
+ name: "on_auth_user_created",
526
+ table_name: "users",
527
+ function_schema: "public",
528
+ function_name: "handle_new_user",
529
+ owner: "supabase_auth_admin",
530
+ });
531
+ expect(evaluatePattern(filter, change)).toBe(true);
532
+ });
533
+ });
@@ -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
  ],