@supabase/pg-delta 1.0.0-alpha.23 → 1.0.0-alpha.25

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 (66) hide show
  1. package/dist/core/catalog.model.d.ts +2 -2
  2. package/dist/core/catalog.model.js +26 -21
  3. package/dist/core/integrations/supabase.js +84 -0
  4. package/dist/core/objects/aggregate/changes/aggregate.privilege.js +21 -9
  5. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.js +4 -1
  6. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.js +6 -3
  7. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.d.ts +11 -0
  8. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js +11 -0
  9. package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.js +4 -1
  10. package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.js +6 -3
  11. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +11 -0
  12. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +11 -0
  13. package/dist/core/objects/foreign-data-wrapper/sensitive-options.d.ts +32 -0
  14. package/dist/core/objects/foreign-data-wrapper/sensitive-options.js +129 -0
  15. package/dist/core/objects/foreign-data-wrapper/server/changes/server.alter.js +4 -1
  16. package/dist/core/objects/foreign-data-wrapper/server/changes/server.create.js +6 -3
  17. package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +10 -0
  18. package/dist/core/objects/foreign-data-wrapper/server/server.model.js +10 -0
  19. package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.js +4 -1
  20. package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.js +6 -3
  21. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +10 -0
  22. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +10 -0
  23. package/dist/core/objects/rls-policy/rls-policy.model.d.ts +2 -2
  24. package/dist/core/objects/table/table.model.js +7 -2
  25. package/dist/core/post-diff-normalization.d.ts +7 -0
  26. package/dist/core/post-diff-normalization.js +33 -4
  27. package/dist/core/sort/cycle-breakers.js +139 -17
  28. package/package.json +1 -1
  29. package/src/core/catalog.model.ts +36 -20
  30. package/src/core/integrations/supabase.test.ts +198 -0
  31. package/src/core/integrations/supabase.ts +84 -0
  32. package/src/core/objects/aggregate/changes/aggregate.privilege.test.ts +79 -0
  33. package/src/core/objects/aggregate/changes/aggregate.privilege.ts +22 -9
  34. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts +34 -4
  35. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts +5 -1
  36. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts +34 -0
  37. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts +7 -5
  38. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts +11 -0
  39. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts +25 -4
  40. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts +5 -1
  41. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts +54 -0
  42. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts +7 -5
  43. package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +11 -0
  44. package/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts +98 -0
  45. package/src/core/objects/foreign-data-wrapper/sensitive-options.ts +133 -0
  46. package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts +39 -4
  47. package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts +5 -1
  48. package/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts +36 -0
  49. package/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts +7 -5
  50. package/src/core/objects/foreign-data-wrapper/server/server.model.ts +10 -0
  51. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts +39 -6
  52. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts +5 -1
  53. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts +38 -2
  54. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts +7 -5
  55. package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +10 -0
  56. package/src/core/objects/table/table.model.ts +7 -2
  57. package/src/core/plan/sql-format/format-off.test.ts +4 -4
  58. package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +4 -4
  59. package/src/core/plan/sql-format/format-pretty-narrow.test.ts +5 -4
  60. package/src/core/plan/sql-format/format-pretty-preserve.test.ts +4 -4
  61. package/src/core/plan/sql-format/format-pretty-upper.test.ts +4 -4
  62. package/src/core/post-diff-normalization.test.ts +123 -0
  63. package/src/core/post-diff-normalization.ts +40 -4
  64. package/src/core/sort/cycle-breakers.test.ts +236 -2
  65. package/src/core/sort/cycle-breakers.ts +184 -24
  66. package/src/core/sort/sort-changes.test.ts +317 -0
@@ -0,0 +1,198 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Change } from "../change.types.ts";
3
+ import { evaluatePattern } from "./filter/dsl.ts";
4
+ import { supabase } from "./supabase.ts";
5
+
6
+ if (!supabase.filter) {
7
+ throw new Error("supabase integration is missing a filter");
8
+ }
9
+ const filter = supabase.filter;
10
+
11
+ /**
12
+ * Build a synthetic FDW change shaped like what `flattenChange` consumes.
13
+ * The change carries a `foreignDataWrapper` model whose `handler`/`validator`
14
+ * are schema-qualified function references (the form
15
+ * `extractForeignDataWrappers` produces).
16
+ */
17
+ function fdwChange(
18
+ operation: "create" | "alter" | "drop",
19
+ fdw: {
20
+ name: string;
21
+ owner: string;
22
+ handler: string | null;
23
+ validator: string | null;
24
+ },
25
+ ): Change {
26
+ return {
27
+ objectType: "foreign_data_wrapper",
28
+ operation,
29
+ scope: "object",
30
+ foreignDataWrapper: fdw,
31
+ requires: [],
32
+ creates: [],
33
+ drops: [],
34
+ } as unknown as Change;
35
+ }
36
+
37
+ /**
38
+ * Synthetic FDW privilege change. The three concrete privilege classes
39
+ * (`GrantForeignDataWrapperPrivileges`, `RevokeForeignDataWrapperPrivileges`,
40
+ * `RevokeGrantOptionForeignDataWrapperPrivileges`) all extend
41
+ * `AlterForeignDataWrapperChange`, so their `operation` is `"alter"` in
42
+ * production. The filter rule we exercise here keys off `scope` only,
43
+ * but pinning `operation: "alter"` keeps the synthetic shape honest.
44
+ */
45
+ function fdwPrivilegeChange(fdw: { name: string; owner: string }): Change {
46
+ return {
47
+ objectType: "foreign_data_wrapper",
48
+ operation: "alter",
49
+ scope: "privilege",
50
+ foreignDataWrapper: { ...fdw, handler: null, validator: null },
51
+ grantee: "postgres",
52
+ requires: [],
53
+ creates: [],
54
+ drops: [],
55
+ } as unknown as Change;
56
+ }
57
+
58
+ function serverPrivilegeChange(server: {
59
+ name: string;
60
+ owner: string;
61
+ }): Change {
62
+ return {
63
+ objectType: "server",
64
+ operation: "alter",
65
+ scope: "privilege",
66
+ server,
67
+ grantee: "postgres",
68
+ requires: [],
69
+ creates: [],
70
+ drops: [],
71
+ } as unknown as Change;
72
+ }
73
+
74
+ describe("supabase integration filter — foreign data wrappers", () => {
75
+ // Regression for CLI-1470. Wasm-based foreign data wrappers on Supabase
76
+ // (e.g. `clerk`, `clerk_oauth`) are provisioned at project creation by
77
+ // `supabase_admin` and their handler/validator live in `extensions.*`.
78
+ // pg-delta must not emit `CREATE/DROP/ALTER FOREIGN DATA WRAPPER` for
79
+ // them, even when the FDW owner has been rewritten away from
80
+ // `supabase_admin` (e.g. after a dump/restore).
81
+ test("suppresses CREATE for FDW with handler in extensions schema", () => {
82
+ const change = fdwChange("create", {
83
+ name: "clerk",
84
+ owner: "postgres",
85
+ handler: "extensions.wasm_fdw_handler",
86
+ validator: "extensions.wasm_fdw_validator",
87
+ });
88
+ expect(evaluatePattern(filter, change)).toBe(false);
89
+ });
90
+
91
+ test("suppresses DROP for FDW with handler in extensions schema", () => {
92
+ const change = fdwChange("drop", {
93
+ name: "clerk_oauth",
94
+ owner: "postgres",
95
+ handler: "extensions.wasm_fdw_handler",
96
+ validator: "extensions.wasm_fdw_validator",
97
+ });
98
+ expect(evaluatePattern(filter, change)).toBe(false);
99
+ });
100
+
101
+ test("suppresses ALTER for FDW with handler in extensions schema", () => {
102
+ const change = fdwChange("alter", {
103
+ name: "clerk",
104
+ owner: "postgres",
105
+ handler: "extensions.wasm_fdw_handler",
106
+ validator: "extensions.wasm_fdw_validator",
107
+ });
108
+ expect(evaluatePattern(filter, change)).toBe(false);
109
+ });
110
+
111
+ test("suppresses FDW when only the validator lives in extensions", () => {
112
+ const change = fdwChange("create", {
113
+ name: "partial_wasm",
114
+ owner: "postgres",
115
+ handler: null,
116
+ validator: "extensions.wasm_fdw_validator",
117
+ });
118
+ expect(evaluatePattern(filter, change)).toBe(false);
119
+ });
120
+
121
+ test("preserves user FDW whose handler lives outside extensions", () => {
122
+ const change = fdwChange("create", {
123
+ name: "user_fdw",
124
+ owner: "postgres",
125
+ handler: "public.my_fdw_handler",
126
+ validator: "public.my_fdw_validator",
127
+ });
128
+ expect(evaluatePattern(filter, change)).toBe(true);
129
+ });
130
+
131
+ test("preserves user FDW with no handler/validator", () => {
132
+ const change = fdwChange("create", {
133
+ name: "user_fdw_bare",
134
+ owner: "postgres",
135
+ handler: null,
136
+ validator: null,
137
+ });
138
+ expect(evaluatePattern(filter, change)).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe("supabase integration filter — foreign data wrapper / server ACLs", () => {
143
+ // Regression for CLI-1469. `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER`
144
+ // require superuser. On Supabase Cloud `postgres` has the elevated
145
+ // rights to make them work; the local Docker image does not, so
146
+ // `supabase db reset` aborts with `permission denied for foreign-data
147
+ // wrapper`. FDW ACL is platform-managed, not user-declarative state —
148
+ // suppress regardless of owner because `pg_dump` rewrites OWNER TO
149
+ // away from `supabase_admin`.
150
+ test("suppresses FDW ACL when owner=supabase_admin (existing */owner rule)", () => {
151
+ const change = fdwPrivilegeChange({
152
+ name: "dblink_fdw",
153
+ owner: "supabase_admin",
154
+ });
155
+ expect(evaluatePattern(filter, change)).toBe(false);
156
+ });
157
+
158
+ test("suppresses FDW ACL when owner=postgres (post-restore)", () => {
159
+ const change = fdwPrivilegeChange({
160
+ name: "dblink_fdw",
161
+ owner: "postgres",
162
+ });
163
+ expect(evaluatePattern(filter, change)).toBe(false);
164
+ });
165
+
166
+ // FOREIGN SERVER ACL is owner-scoped, not blanket-suppressed:
167
+ // server GRANT/REVOKE does not require superuser, so a user-owned
168
+ // server's ACL must roundtrip. The pre-existing `*/owner` rule
169
+ // already drops platform-managed servers (owner ∈ system roles).
170
+ test("suppresses server ACL when owner=supabase_admin (existing */owner rule)", () => {
171
+ const change = serverPrivilegeChange({
172
+ name: "platform_server",
173
+ owner: "supabase_admin",
174
+ });
175
+ expect(evaluatePattern(filter, change)).toBe(false);
176
+ });
177
+
178
+ test("preserves server ACL when owner=postgres", () => {
179
+ const change = serverPrivilegeChange({
180
+ name: "user_dblink_server",
181
+ owner: "postgres",
182
+ });
183
+ expect(evaluatePattern(filter, change)).toBe(true);
184
+ });
185
+
186
+ // Non-privilege FDW changes whose handler/validator aren't in
187
+ // `extensions.*` should still pass through (a user FDW is plain DDL,
188
+ // not the platform-managed flavor).
189
+ test("preserves non-privilege FDW changes for user wrappers", () => {
190
+ const change = fdwChange("create", {
191
+ name: "user_fdw",
192
+ owner: "postgres",
193
+ handler: "public.my_fdw_handler",
194
+ validator: null,
195
+ });
196
+ expect(evaluatePattern(filter, change)).toBe(true);
197
+ });
198
+ });
@@ -99,6 +99,34 @@ export const supabase: IntegrationDSL = {
99
99
  operation: "drop",
100
100
  scope: "object",
101
101
  },
102
+ // Include user-attached triggers on tables in Supabase-managed schemas.
103
+ //
104
+ // Triggers live in the schema of the table they fire on, so a user
105
+ // trigger on `auth.users` reports `trigger/schema = auth` and is
106
+ // otherwise indistinguishable from Supabase's own triggers via the
107
+ // schema-level deny list. Triggers also have no real owner — pg-delta
108
+ // surfaces the parent table's owner as `trigger/owner`, which for
109
+ // `auth.users` and `storage.objects` is always a Supabase system role,
110
+ // so the owner-level deny list catches them too.
111
+ //
112
+ // The trigger function, however, is genuinely user-owned: a customer
113
+ // who wants to run code on an auth event creates a function in
114
+ // `public` (or any non-managed schema) and points the trigger at it.
115
+ // Supabase's own auth/storage triggers either come from extensions
116
+ // (already filtered out at extract time via `pg_depend`) or call
117
+ // functions inside the same managed schema, so `function_schema`
118
+ // outside the managed list is a reliable user-defined marker.
119
+ {
120
+ and: [
121
+ { objectType: "trigger" },
122
+ { "trigger/schema": [...SUPABASE_SYSTEM_SCHEMAS] },
123
+ {
124
+ not: {
125
+ "trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS],
126
+ },
127
+ },
128
+ ],
129
+ },
102
130
  // Exclude system objects
103
131
  {
104
132
  not: {
@@ -131,6 +159,62 @@ export const supabase: IntegrationDSL = {
131
159
  },
132
160
  ],
133
161
  },
162
+ // Platform-managed foreign data wrapper ACL.
163
+ // `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` requires
164
+ // superuser. On Supabase Cloud `postgres` has the elevated
165
+ // rights to make this work, but the local Docker image does
166
+ // not, so `supabase db reset` aborts with
167
+ // `permission denied for foreign-data wrapper`. The
168
+ // `*/owner` rule above already covers wrappers owned by
169
+ // `supabase_admin`, but `pg_dump` rewrites OWNER TO clauses
170
+ // to whoever the dump runs under, so after a restore the
171
+ // FDW typically ends up owned by `postgres` and slips past
172
+ // the owner gate. A non-superuser `postgres` still can't
173
+ // grant on a FDW (this is true regardless of who owns the
174
+ // wrapper locally), so the ACL diff is not user-replayable.
175
+ // We don't apply the same blanket rule to `FOREIGN SERVER`:
176
+ // server GRANT/REVOKE doesn't require superuser, and
177
+ // user-created servers (e.g. a `dblink` server pointing to
178
+ // a peer DB) carry legitimate user ACL that should
179
+ // roundtrip — the existing `*/owner` rule already drops
180
+ // platform-managed servers.
181
+ {
182
+ and: [
183
+ { objectType: "foreign_data_wrapper" },
184
+ { scope: "privilege" },
185
+ ],
186
+ },
187
+ // 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.
197
+ {
198
+ and: [
199
+ { objectType: "foreign_data_wrapper" },
200
+ {
201
+ or: [
202
+ {
203
+ "foreign_data_wrapper/handler": {
204
+ op: "regex",
205
+ value: "^extensions\\.",
206
+ },
207
+ },
208
+ {
209
+ "foreign_data_wrapper/validator": {
210
+ op: "regex",
211
+ value: "^extensions\\.",
212
+ },
213
+ },
214
+ ],
215
+ },
216
+ ],
217
+ },
134
218
  ],
135
219
  },
136
220
  },
@@ -92,6 +92,85 @@ describe("aggregate.privilege", () => {
92
92
  );
93
93
  });
94
94
 
95
+ // Regression for CLI-1471: ordered-set / hypothetical-set / variadic
96
+ // aggregates have `identity_arguments` that include `ORDER BY` or
97
+ // `VARIADIC` keywords. Those keywords are rejected by
98
+ // `GRANT ... ON FUNCTION (...)` (only positional argument types are
99
+ // accepted there), so the serializer must drop back to the
100
+ // `proargtypes`-derived `argument_types` list.
101
+ test("grant on ordered-set aggregate emits proargtypes signature", async () => {
102
+ const aggregate = new Aggregate({
103
+ ...base,
104
+ name: "os_last",
105
+ aggkind: "o",
106
+ identity_arguments: "anyelement ORDER BY anyelement",
107
+ argument_types: ["anyelement", "anyelement"],
108
+ return_type: "anyelement",
109
+ transition_function: "public.os_last_sfunc(anyelement,anyelement)",
110
+ state_data_type: "anyelement",
111
+ argument_count: 2,
112
+ });
113
+ const change = new GrantAggregatePrivileges({
114
+ aggregate,
115
+ grantee: "role_exec",
116
+ privileges: [{ privilege: "EXECUTE", grantable: false }],
117
+ version: 170000,
118
+ });
119
+ await assertValidSql(change.serialize());
120
+ expect(change.serialize()).toBe(
121
+ "GRANT ALL ON FUNCTION public.os_last(anyelement, anyelement) TO role_exec",
122
+ );
123
+ });
124
+
125
+ test("revoke on hypothetical-set aggregate emits proargtypes signature", async () => {
126
+ const aggregate = new Aggregate({
127
+ ...base,
128
+ name: "hyp_rank",
129
+ aggkind: "h",
130
+ identity_arguments: 'VARIADIC "any" ORDER BY VARIADIC "any"',
131
+ argument_types: ['"any"'],
132
+ return_type: "bigint",
133
+ transition_function:
134
+ 'pg_catalog.ordered_set_transition_multi(internal,"any")',
135
+ state_data_type: "internal",
136
+ argument_count: 1,
137
+ });
138
+ const change = new RevokeAggregatePrivileges({
139
+ aggregate,
140
+ grantee: "role_old",
141
+ privileges: [{ privilege: "EXECUTE", grantable: false }],
142
+ version: 170000,
143
+ });
144
+ await assertValidSql(change.serialize());
145
+ expect(change.serialize()).toBe(
146
+ 'REVOKE ALL ON FUNCTION public.hyp_rank("any") FROM role_old',
147
+ );
148
+ });
149
+
150
+ test("revoke grant option on ordered-set aggregate emits proargtypes signature", async () => {
151
+ const aggregate = new Aggregate({
152
+ ...base,
153
+ name: "os_last",
154
+ aggkind: "o",
155
+ identity_arguments: "anyelement ORDER BY anyelement",
156
+ argument_types: ["anyelement", "anyelement"],
157
+ return_type: "anyelement",
158
+ transition_function: "public.os_last_sfunc(anyelement,anyelement)",
159
+ state_data_type: "anyelement",
160
+ argument_count: 2,
161
+ });
162
+ const change = new RevokeGrantOptionAggregatePrivileges({
163
+ aggregate,
164
+ grantee: "role_with_option",
165
+ privilegeNames: ["EXECUTE"],
166
+ version: 170000,
167
+ });
168
+ await assertValidSql(change.serialize());
169
+ expect(change.serialize()).toBe(
170
+ "REVOKE GRANT OPTION FOR ALL ON FUNCTION public.os_last(anyelement, anyelement) FROM role_with_option",
171
+ );
172
+ });
173
+
95
174
  test("revoke privileges and grant option", async () => {
96
175
  const aggregate = new Aggregate(base);
97
176
  const revoke = new RevokeAggregatePrivileges({
@@ -12,6 +12,25 @@ export type AggregatePrivilege =
12
12
  | RevokeAggregatePrivileges
13
13
  | RevokeGrantOptionAggregatePrivileges;
14
14
 
15
+ /**
16
+ * Build the signature `<schema>.<name>(<argtypes>)` for use inside
17
+ * `GRANT`/`REVOKE ... ON FUNCTION (...)`.
18
+ *
19
+ * The aggregate's `identityArguments` (from
20
+ * `pg_get_function_identity_arguments`) embeds `ORDER BY` for ordered-set
21
+ * and hypothetical-set aggregates (`aggkind` of `o`/`h`) and `VARIADIC`
22
+ * for variadic aggregates — both of which the GRANT parser rejects with
23
+ * a syntax error. PostgreSQL resolves the aggregate from the positional
24
+ * argument types alone, so use `argument_types` here regardless of
25
+ * `aggkind`. Other aggregate DDL (`ALTER AGGREGATE`, `COMMENT ON
26
+ * AGGREGATE`, `SECURITY LABEL ON AGGREGATE`, `DROP AGGREGATE`) accepts
27
+ * the identity form and keeps using it.
28
+ */
29
+ function aggregateGrantSignature(aggregate: Aggregate): string {
30
+ const args = (aggregate.argument_types ?? []).join(", ");
31
+ return `${aggregate.schema}.${aggregate.name}(${args})`;
32
+ }
33
+
15
34
  export class GrantAggregatePrivileges extends AlterAggregateChange {
16
35
  public readonly aggregate: Aggregate;
17
36
  public readonly grantee: string;
@@ -52,9 +71,7 @@ export class GrantAggregatePrivileges extends AlterAggregateChange {
52
71
  const kindPrefix = getObjectKindPrefix("FUNCTION");
53
72
  const list = this.privileges.map((p) => p.privilege);
54
73
  const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
55
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
56
- const signature = this.aggregate.identityArguments;
57
- const qualified = `${aggregateName}(${signature})`;
74
+ const qualified = aggregateGrantSignature(this.aggregate);
58
75
  return `GRANT ${privSql} ${kindPrefix} ${qualified} TO ${this.grantee}${withGrant}`;
59
76
  }
60
77
  }
@@ -97,9 +114,7 @@ export class RevokeAggregatePrivileges extends AlterAggregateChange {
97
114
  const kindPrefix = getObjectKindPrefix("FUNCTION");
98
115
  const list = this.privileges.map((p) => p.privilege);
99
116
  const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
100
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
101
- const signature = this.aggregate.identityArguments;
102
- const qualified = `${aggregateName}(${signature})`;
117
+ const qualified = aggregateGrantSignature(this.aggregate);
103
118
  return `REVOKE ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
104
119
  }
105
120
  }
@@ -139,9 +154,7 @@ export class RevokeGrantOptionAggregatePrivileges extends AlterAggregateChange {
139
154
  this.privilegeNames,
140
155
  this.version,
141
156
  );
142
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
143
- const signature = this.aggregate.identityArguments;
144
- const qualified = `${aggregateName}(${signature})`;
157
+ const qualified = aggregateGrantSignature(this.aggregate);
145
158
  return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
146
159
  }
147
160
  }
@@ -120,17 +120,47 @@ describe.concurrent("foreign-data-wrapper", () => {
120
120
  const change = new AlterForeignDataWrapperSetOptions({
121
121
  foreignDataWrapper: fdw,
122
122
  options: [
123
- { action: "ADD", option: "new_option", value: "new_value" },
124
- { action: "SET", option: "existing_option", value: "updated_value" },
125
- { action: "DROP", option: "old_option" },
123
+ { action: "ADD", option: "use_remote_estimate", value: "true" },
124
+ { action: "SET", option: "fetch_size", value: "200" },
125
+ { action: "DROP", option: "fdw_tuple_cost" },
126
126
  ],
127
127
  });
128
128
 
129
129
  await assertValidSql(change.serialize());
130
130
 
131
131
  expect(change.serialize()).toBe(
132
- "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD new_option 'new_value', SET existing_option 'updated_value', DROP old_option)",
132
+ "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD use_remote_estimate 'true', SET fetch_size '200', DROP fdw_tuple_cost)",
133
133
  );
134
134
  });
135
+
136
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
137
+ const props: ForeignDataWrapperProps = {
138
+ name: "leaky_fdw",
139
+ owner: "postgres",
140
+ handler: null,
141
+ validator: null,
142
+ options: null,
143
+ comment: null,
144
+ privileges: [],
145
+ };
146
+ const fdw = new ForeignDataWrapper(props);
147
+ const change = new AlterForeignDataWrapperSetOptions({
148
+ foreignDataWrapper: fdw,
149
+ options: [
150
+ { action: "ADD", option: "password", value: "shared-fdw-secret" },
151
+ { action: "SET", option: "use_remote_estimate", value: "true" },
152
+ { action: "ADD", option: "api_key", value: "leaked-api-key" },
153
+ ],
154
+ });
155
+
156
+ await assertValidSql(change.serialize());
157
+
158
+ const sql = change.serialize();
159
+ expect(sql).not.toContain("shared-fdw-secret");
160
+ expect(sql).not.toContain("leaked-api-key");
161
+ expect(sql).toContain("SET use_remote_estimate 'true'");
162
+ expect(sql).toContain("ADD password '__OPTION_PASSWORD__'");
163
+ expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'");
164
+ });
135
165
  });
136
166
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts";
5
6
  import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts";
6
7
 
@@ -87,7 +88,10 @@ export class AlterForeignDataWrapperSetOptions extends AlterForeignDataWrapperCh
87
88
  if (opt.action === "DROP") {
88
89
  optionParts.push(`DROP ${opt.option}`);
89
90
  } else {
90
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
91
+ const value =
92
+ opt.value !== undefined
93
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
94
+ : "''";
91
95
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
92
96
  }
93
97
  }
@@ -157,4 +157,38 @@ describe("foreign-data-wrapper", () => {
157
157
  "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER extensions.iceberg_fdw_handler VALIDATOR extensions.iceberg_fdw_validator",
158
158
  );
159
159
  });
160
+
161
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
162
+ // FDW-level OPTIONS set defaults that flow down to every server using
163
+ // the wrapper, so a shared `password` or `api_key` here must redact.
164
+ const fdw = new ForeignDataWrapper({
165
+ name: "leaky_fdw",
166
+ owner: "postgres",
167
+ handler: null,
168
+ validator: null,
169
+ options: [
170
+ "use_remote_estimate",
171
+ "true",
172
+ "password",
173
+ "shared-fdw-secret",
174
+ "api_key",
175
+ "leaked-api-key",
176
+ ],
177
+ comment: null,
178
+ privileges: [],
179
+ });
180
+
181
+ const change = new CreateForeignDataWrapper({
182
+ foreignDataWrapper: fdw,
183
+ });
184
+
185
+ await assertValidSql(change.serialize());
186
+
187
+ const sql = change.serialize();
188
+ expect(sql).not.toContain("shared-fdw-secret");
189
+ expect(sql).not.toContain("leaked-api-key");
190
+ expect(sql).toContain("use_remote_estimate 'true'");
191
+ expect(sql).toContain("password '__OPTION_PASSWORD__'");
192
+ expect(sql).toContain("api_key '__OPTION_API_KEY__'");
193
+ });
160
194
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts";
5
6
  import { CreateForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts";
6
7
 
@@ -80,11 +81,12 @@ export class CreateForeignDataWrapper extends CreateForeignDataWrapperChange {
80
81
  ) {
81
82
  const optionPairs: string[] = [];
82
83
  for (let i = 0; i < this.foreignDataWrapper.options.length; i += 2) {
83
- if (i + 1 < this.foreignDataWrapper.options.length) {
84
- optionPairs.push(
85
- `${this.foreignDataWrapper.options[i]} ${quoteLiteral(this.foreignDataWrapper.options[i + 1])}`,
86
- );
87
- }
84
+ const key = this.foreignDataWrapper.options[i];
85
+ const value = this.foreignDataWrapper.options[i + 1];
86
+ if (key === undefined || value === undefined) continue;
87
+ optionPairs.push(
88
+ `${key} ${quoteLiteral(redactOptionValue(key, value))}`,
89
+ );
88
90
  }
89
91
  if (optionPairs.length > 0) {
90
92
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -78,6 +78,17 @@ export class ForeignDataWrapper extends BasePgModel {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
83
+ *
84
+ * The returned models carry option values **verbatim** from
85
+ * `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
86
+ * shared credentials (`password`, `api_key`, …) would expose them
87
+ * cleartext in memory. Always route through `extractCatalog` (which
88
+ * calls `normalizeCatalog`) before emitting options to any output
89
+ * channel — see CLI-1467 and
90
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
91
+ */
81
92
  export async function extractForeignDataWrappers(
82
93
  pool: Pool,
83
94
  ): Promise<ForeignDataWrapper[]> {
@@ -324,17 +324,38 @@ describe.concurrent("foreign-table", () => {
324
324
  const change = new AlterForeignTableSetOptions({
325
325
  foreignTable,
326
326
  options: [
327
- { action: "ADD", option: "new_option", value: "new_value" },
328
- { action: "SET", option: "existing_option", value: "updated_value" },
329
- { action: "DROP", option: "old_option" },
327
+ { action: "ADD", option: "schema_name", value: "remote_schema" },
328
+ { action: "SET", option: "table_name", value: "updated_table" },
329
+ { action: "DROP", option: "column_name" },
330
330
  ],
331
331
  });
332
332
 
333
333
  await assertValidSql(change.serialize());
334
334
 
335
335
  expect(change.serialize()).toBe(
336
- "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD new_option 'new_value', SET existing_option 'updated_value', DROP old_option)",
336
+ "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD schema_name 'remote_schema', SET table_name 'updated_table', DROP column_name)",
337
337
  );
338
338
  });
339
+
340
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
341
+ const foreignTable = new ForeignTable(baseTableProps);
342
+ const change = new AlterForeignTableSetOptions({
343
+ foreignTable,
344
+ options: [
345
+ { action: "ADD", option: "password", value: "table-shared-secret" },
346
+ { action: "SET", option: "schema_name", value: "remote_schema" },
347
+ { action: "ADD", option: "api_key", value: "leaked-api-key" },
348
+ ],
349
+ });
350
+
351
+ await assertValidSql(change.serialize());
352
+
353
+ const sql = change.serialize();
354
+ expect(sql).not.toContain("table-shared-secret");
355
+ expect(sql).not.toContain("leaked-api-key");
356
+ expect(sql).toContain("SET schema_name 'remote_schema'");
357
+ expect(sql).toContain("ADD password '__OPTION_PASSWORD__'");
358
+ expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'");
359
+ });
339
360
  });
340
361
  });
@@ -2,6 +2,7 @@ import type { SerializeOptions } from "../../../../integrations/serialize/serial
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import type { ColumnProps } from "../../../base.model.ts";
4
4
  import { stableId } from "../../../utils.ts";
5
+ import { redactOptionValue } from "../../sensitive-options.ts";
5
6
  import type { ForeignTable } from "../foreign-table.model.ts";
6
7
  import { AlterForeignTableChange } from "./foreign-table.base.ts";
7
8
 
@@ -327,7 +328,10 @@ export class AlterForeignTableSetOptions extends AlterForeignTableChange {
327
328
  if (opt.action === "DROP") {
328
329
  optionParts.push(`DROP ${opt.option}`);
329
330
  } else {
330
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
331
+ const value =
332
+ opt.value !== undefined
333
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
334
+ : "''";
331
335
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
332
336
  }
333
337
  }