@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
@@ -6,8 +6,8 @@ import { type Collation } from "./objects/collation/collation.model.ts";
6
6
  import type { Domain } from "./objects/domain/domain.model.ts";
7
7
  import { type EventTrigger } from "./objects/event-trigger/event-trigger.model.ts";
8
8
  import { type Extension } from "./objects/extension/extension.model.ts";
9
- import { type ForeignDataWrapper } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts";
10
- import { type ForeignTable } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts";
9
+ import { ForeignDataWrapper } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts";
10
+ import { ForeignTable } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts";
11
11
  import { Server } from "./objects/foreign-data-wrapper/server/server.model.ts";
12
12
  import { UserMapping } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts";
13
13
  import { type Index } from "./objects/index/index.model.ts";
@@ -5,8 +5,9 @@ import { extractCollations, } from "./objects/collation/collation.model.js";
5
5
  import { extractDomains } from "./objects/domain/domain.model.js";
6
6
  import { extractEventTriggers, } from "./objects/event-trigger/event-trigger.model.js";
7
7
  import { extractExtensions, } from "./objects/extension/extension.model.js";
8
- import { extractForeignDataWrappers, } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js";
9
- import { extractForeignTables, } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.js";
8
+ import { extractForeignDataWrappers, ForeignDataWrapper, } from "./objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js";
9
+ import { extractForeignTables, ForeignTable, } from "./objects/foreign-data-wrapper/foreign-table/foreign-table.model.js";
10
+ import { redactSensitiveOptionPairs } from "./objects/foreign-data-wrapper/sensitive-options.js";
10
11
  import { extractServers, Server, } from "./objects/foreign-data-wrapper/server/server.model.js";
11
12
  import { extractUserMappings, UserMapping, } from "./objects/foreign-data-wrapper/user-mapping/user-mapping.model.js";
12
13
  import { extractIndexes } from "./objects/index/index.model.js";
@@ -264,27 +265,44 @@ function listToRecord(list) {
264
265
  return Object.fromEntries(list.map((item) => [item.stableId, item]));
265
266
  }
266
267
  function normalizeCatalog(catalog) {
268
+ const foreignDataWrappers = mapRecord(catalog.foreignDataWrappers, (fdw) => new ForeignDataWrapper({
269
+ name: fdw.name,
270
+ owner: fdw.owner,
271
+ handler: fdw.handler,
272
+ validator: fdw.validator,
273
+ options: redactSensitiveOptionPairs(fdw.options),
274
+ comment: fdw.comment,
275
+ privileges: fdw.privileges,
276
+ }));
267
277
  const servers = mapRecord(catalog.servers, (server) => {
268
- const maskedOptions = maskOptions(server.options);
269
278
  return new Server({
270
279
  name: server.name,
271
280
  owner: server.owner,
272
281
  foreign_data_wrapper: server.foreign_data_wrapper,
273
282
  type: server.type,
274
283
  version: server.version,
275
- options: maskedOptions,
284
+ options: redactSensitiveOptionPairs(server.options),
276
285
  comment: server.comment,
277
286
  privileges: server.privileges,
278
287
  });
279
288
  });
280
289
  const userMappings = mapRecord(catalog.userMappings, (mapping) => {
281
- const maskedOptions = maskOptions(mapping.options);
282
290
  return new UserMapping({
283
291
  user: mapping.user,
284
292
  server: mapping.server,
285
- options: maskedOptions,
293
+ options: redactSensitiveOptionPairs(mapping.options),
286
294
  });
287
295
  });
296
+ const foreignTables = mapRecord(catalog.foreignTables, (foreignTable) => new ForeignTable({
297
+ schema: foreignTable.schema,
298
+ name: foreignTable.name,
299
+ owner: foreignTable.owner,
300
+ server: foreignTable.server,
301
+ columns: foreignTable.columns,
302
+ options: redactSensitiveOptionPairs(foreignTable.options),
303
+ comment: foreignTable.comment,
304
+ privileges: foreignTable.privileges,
305
+ }));
288
306
  const subscriptions = mapRecord(catalog.subscriptions, (subscription) => {
289
307
  return new Subscription({
290
308
  name: subscription.name,
@@ -330,29 +348,16 @@ function normalizeCatalog(catalog) {
330
348
  rules: catalog.rules,
331
349
  ranges: catalog.ranges,
332
350
  views: catalog.views,
333
- foreignDataWrappers: catalog.foreignDataWrappers,
351
+ foreignDataWrappers,
334
352
  servers,
335
353
  userMappings,
336
- foreignTables: catalog.foreignTables,
354
+ foreignTables,
337
355
  depends: catalog.depends,
338
356
  indexableObjects: catalog.indexableObjects,
339
357
  version: catalog.version,
340
358
  currentUser: catalog.currentUser,
341
359
  });
342
360
  }
343
- function maskOptions(options) {
344
- if (!options || options.length === 0)
345
- return options;
346
- const masked = [];
347
- for (let i = 0; i < options.length; i += 2) {
348
- const key = options[i];
349
- const value = options[i + 1];
350
- if (key === undefined || value === undefined)
351
- continue;
352
- masked.push(key, `__OPTION_${key.toUpperCase()}__`);
353
- }
354
- return masked.length > 0 ? masked : null;
355
- }
356
361
  function mapRecord(record, mapper) {
357
362
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, mapper(value)]));
358
363
  }
@@ -94,6 +94,34 @@ export const supabase = {
94
94
  operation: "drop",
95
95
  scope: "object",
96
96
  },
97
+ // Include user-attached triggers on tables in Supabase-managed schemas.
98
+ //
99
+ // Triggers live in the schema of the table they fire on, so a user
100
+ // trigger on `auth.users` reports `trigger/schema = auth` and is
101
+ // otherwise indistinguishable from Supabase's own triggers via the
102
+ // schema-level deny list. Triggers also have no real owner — pg-delta
103
+ // surfaces the parent table's owner as `trigger/owner`, which for
104
+ // `auth.users` and `storage.objects` is always a Supabase system role,
105
+ // so the owner-level deny list catches them too.
106
+ //
107
+ // The trigger function, however, is genuinely user-owned: a customer
108
+ // who wants to run code on an auth event creates a function in
109
+ // `public` (or any non-managed schema) and points the trigger at it.
110
+ // Supabase's own auth/storage triggers either come from extensions
111
+ // (already filtered out at extract time via `pg_depend`) or call
112
+ // functions inside the same managed schema, so `function_schema`
113
+ // outside the managed list is a reliable user-defined marker.
114
+ {
115
+ and: [
116
+ { objectType: "trigger" },
117
+ { "trigger/schema": [...SUPABASE_SYSTEM_SCHEMAS] },
118
+ {
119
+ not: {
120
+ "trigger/function_schema": [...SUPABASE_SYSTEM_SCHEMAS],
121
+ },
122
+ },
123
+ ],
124
+ },
97
125
  // Exclude system objects
98
126
  {
99
127
  not: {
@@ -126,6 +154,62 @@ export const supabase = {
126
154
  },
127
155
  ],
128
156
  },
157
+ // Platform-managed foreign data wrapper ACL.
158
+ // `GRANT`/`REVOKE ... ON FOREIGN DATA WRAPPER` requires
159
+ // superuser. On Supabase Cloud `postgres` has the elevated
160
+ // rights to make this work, but the local Docker image does
161
+ // not, so `supabase db reset` aborts with
162
+ // `permission denied for foreign-data wrapper`. The
163
+ // `*/owner` rule above already covers wrappers owned by
164
+ // `supabase_admin`, but `pg_dump` rewrites OWNER TO clauses
165
+ // to whoever the dump runs under, so after a restore the
166
+ // FDW typically ends up owned by `postgres` and slips past
167
+ // the owner gate. A non-superuser `postgres` still can't
168
+ // grant on a FDW (this is true regardless of who owns the
169
+ // wrapper locally), so the ACL diff is not user-replayable.
170
+ // We don't apply the same blanket rule to `FOREIGN SERVER`:
171
+ // server GRANT/REVOKE doesn't require superuser, and
172
+ // user-created servers (e.g. a `dblink` server pointing to
173
+ // a peer DB) carry legitimate user ACL that should
174
+ // roundtrip — the existing `*/owner` rule already drops
175
+ // platform-managed servers.
176
+ {
177
+ and: [
178
+ { objectType: "foreign_data_wrapper" },
179
+ { scope: "privilege" },
180
+ ],
181
+ },
182
+ // Platform-managed foreign data wrappers — Wasm-based FDWs
183
+ // (e.g. `clerk`, `clerk_oauth`) whose handler/validator live in
184
+ // the `extensions` schema. `CREATE FOREIGN DATA WRAPPER`
185
+ // requires superuser, and Supabase Cloud provisions these via
186
+ // `supabase_admin` at project creation; replaying the DDL
187
+ // against a local image fails because the local environment
188
+ // has no equivalent pre-step. We can't rely on the FDW owner
189
+ // alone — after a dump/restore the owner is often rewritten
190
+ // away from `supabase_admin` — so match on the function
191
+ // reference instead.
192
+ {
193
+ and: [
194
+ { objectType: "foreign_data_wrapper" },
195
+ {
196
+ or: [
197
+ {
198
+ "foreign_data_wrapper/handler": {
199
+ op: "regex",
200
+ value: "^extensions\\.",
201
+ },
202
+ },
203
+ {
204
+ "foreign_data_wrapper/validator": {
205
+ op: "regex",
206
+ value: "^extensions\\.",
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ ],
212
+ },
129
213
  ],
130
214
  },
131
215
  },
@@ -1,6 +1,24 @@
1
1
  import { formatObjectPrivilegeList, getObjectKindPrefix, } from "../../base.privilege.js";
2
2
  import { stableId } from "../../utils.js";
3
3
  import { AlterAggregateChange } from "./aggregate.base.js";
4
+ /**
5
+ * Build the signature `<schema>.<name>(<argtypes>)` for use inside
6
+ * `GRANT`/`REVOKE ... ON FUNCTION (...)`.
7
+ *
8
+ * The aggregate's `identityArguments` (from
9
+ * `pg_get_function_identity_arguments`) embeds `ORDER BY` for ordered-set
10
+ * and hypothetical-set aggregates (`aggkind` of `o`/`h`) and `VARIADIC`
11
+ * for variadic aggregates — both of which the GRANT parser rejects with
12
+ * a syntax error. PostgreSQL resolves the aggregate from the positional
13
+ * argument types alone, so use `argument_types` here regardless of
14
+ * `aggkind`. Other aggregate DDL (`ALTER AGGREGATE`, `COMMENT ON
15
+ * AGGREGATE`, `SECURITY LABEL ON AGGREGATE`, `DROP AGGREGATE`) accepts
16
+ * the identity form and keeps using it.
17
+ */
18
+ function aggregateGrantSignature(aggregate) {
19
+ const args = (aggregate.argument_types ?? []).join(", ");
20
+ return `${aggregate.schema}.${aggregate.name}(${args})`;
21
+ }
4
22
  export class GrantAggregatePrivileges extends AlterAggregateChange {
5
23
  aggregate;
6
24
  grantee;
@@ -30,9 +48,7 @@ export class GrantAggregatePrivileges extends AlterAggregateChange {
30
48
  const kindPrefix = getObjectKindPrefix("FUNCTION");
31
49
  const list = this.privileges.map((p) => p.privilege);
32
50
  const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
33
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
34
- const signature = this.aggregate.identityArguments;
35
- const qualified = `${aggregateName}(${signature})`;
51
+ const qualified = aggregateGrantSignature(this.aggregate);
36
52
  return `GRANT ${privSql} ${kindPrefix} ${qualified} TO ${this.grantee}${withGrant}`;
37
53
  }
38
54
  }
@@ -65,9 +81,7 @@ export class RevokeAggregatePrivileges extends AlterAggregateChange {
65
81
  const kindPrefix = getObjectKindPrefix("FUNCTION");
66
82
  const list = this.privileges.map((p) => p.privilege);
67
83
  const privSql = formatObjectPrivilegeList("FUNCTION", list, this.version);
68
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
69
- const signature = this.aggregate.identityArguments;
70
- const qualified = `${aggregateName}(${signature})`;
84
+ const qualified = aggregateGrantSignature(this.aggregate);
71
85
  return `REVOKE ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
72
86
  }
73
87
  }
@@ -94,9 +108,7 @@ export class RevokeGrantOptionAggregatePrivileges extends AlterAggregateChange {
94
108
  serialize(_options) {
95
109
  const kindPrefix = getObjectKindPrefix("FUNCTION");
96
110
  const privSql = formatObjectPrivilegeList("FUNCTION", this.privilegeNames, this.version);
97
- const aggregateName = `${this.aggregate.schema}.${this.aggregate.name}`;
98
- const signature = this.aggregate.identityArguments;
99
- const qualified = `${aggregateName}(${signature})`;
111
+ const qualified = aggregateGrantSignature(this.aggregate);
100
112
  return `REVOKE GRANT OPTION FOR ${privSql} ${kindPrefix} ${qualified} FROM ${this.grantee}`;
101
113
  }
102
114
  }
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.js";
4
5
  /**
5
6
  * ALTER FOREIGN DATA WRAPPER ... OWNER TO ...
@@ -47,7 +48,9 @@ export class AlterForeignDataWrapperSetOptions extends AlterForeignDataWrapperCh
47
48
  optionParts.push(`DROP ${opt.option}`);
48
49
  }
49
50
  else {
50
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
51
+ const value = opt.value !== undefined
52
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
53
+ : "''";
51
54
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
52
55
  }
53
56
  }
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { CreateForeignDataWrapperChange } from "./foreign-data-wrapper.base.js";
4
5
  /**
5
6
  * Create a foreign data wrapper.
@@ -65,9 +66,11 @@ export class CreateForeignDataWrapper extends CreateForeignDataWrapperChange {
65
66
  this.foreignDataWrapper.options.length > 0) {
66
67
  const optionPairs = [];
67
68
  for (let i = 0; i < this.foreignDataWrapper.options.length; i += 2) {
68
- if (i + 1 < this.foreignDataWrapper.options.length) {
69
- optionPairs.push(`${this.foreignDataWrapper.options[i]} ${quoteLiteral(this.foreignDataWrapper.options[i + 1])}`);
70
- }
69
+ const key = this.foreignDataWrapper.options[i];
70
+ const value = this.foreignDataWrapper.options[i + 1];
71
+ if (key === undefined || value === undefined)
72
+ continue;
73
+ optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
71
74
  }
72
75
  if (optionPairs.length > 0) {
73
76
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -55,5 +55,16 @@ export declare class ForeignDataWrapper extends BasePgModel {
55
55
  }[];
56
56
  };
57
57
  }
58
+ /**
59
+ * Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
60
+ *
61
+ * The returned models carry option values **verbatim** from
62
+ * `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
63
+ * shared credentials (`password`, `api_key`, …) would expose them
64
+ * cleartext in memory. Always route through `extractCatalog` (which
65
+ * calls `normalizeCatalog`) before emitting options to any output
66
+ * channel — see CLI-1467 and
67
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
68
+ */
58
69
  export declare function extractForeignDataWrappers(pool: Pool): Promise<ForeignDataWrapper[]>;
59
70
  export {};
@@ -60,6 +60,17 @@ export class ForeignDataWrapper extends BasePgModel {
60
60
  };
61
61
  }
62
62
  }
63
+ /**
64
+ * Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
65
+ *
66
+ * The returned models carry option values **verbatim** from
67
+ * `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
68
+ * shared credentials (`password`, `api_key`, …) would expose them
69
+ * cleartext in memory. Always route through `extractCatalog` (which
70
+ * calls `normalizeCatalog`) before emitting options to any output
71
+ * channel — see CLI-1467 and
72
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
73
+ */
63
74
  export async function extractForeignDataWrappers(pool) {
64
75
  const { rows: fdwRows } = await pool.query(sql `
65
76
  with extension_oids as (
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { AlterForeignTableChange } from "./foreign-table.base.js";
4
5
  /**
5
6
  * ALTER FOREIGN TABLE ... OWNER TO ...
@@ -234,7 +235,9 @@ export class AlterForeignTableSetOptions extends AlterForeignTableChange {
234
235
  optionParts.push(`DROP ${opt.option}`);
235
236
  }
236
237
  else {
237
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
238
+ const value = opt.value !== undefined
239
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
240
+ : "''";
238
241
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
239
242
  }
240
243
  }
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { CreateForeignTableChange } from "./foreign-table.base.js";
4
5
  /**
5
6
  * Create a foreign table.
@@ -51,9 +52,11 @@ export class CreateForeignTable extends CreateForeignTableChange {
51
52
  if (this.foreignTable.options && this.foreignTable.options.length > 0) {
52
53
  const optionPairs = [];
53
54
  for (let i = 0; i < this.foreignTable.options.length; i += 2) {
54
- if (i + 1 < this.foreignTable.options.length) {
55
- optionPairs.push(`${this.foreignTable.options[i]} ${quoteLiteral(this.foreignTable.options[i + 1])}`);
56
- }
55
+ const key = this.foreignTable.options[i];
56
+ const value = this.foreignTable.options[i + 1];
57
+ if (key === undefined || value === undefined)
58
+ continue;
59
+ optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
57
60
  }
58
61
  if (optionPairs.length > 0) {
59
62
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -135,5 +135,16 @@ export declare class ForeignTable extends BasePgModel implements TableLikeObject
135
135
  };
136
136
  };
137
137
  }
138
+ /**
139
+ * Extract `pg_foreign_table` rows into `ForeignTable` models.
140
+ *
141
+ * The returned models carry option values **verbatim** from
142
+ * `pg_foreign_table.ftoptions`, which means a wrapper that puts
143
+ * credentials at the table level (uncommon but possible) would expose
144
+ * them cleartext in memory. Always route through `extractCatalog`
145
+ * (which calls `normalizeCatalog`) before emitting options to any
146
+ * output channel — see CLI-1467 and
147
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
148
+ */
138
149
  export declare function extractForeignTables(pool: Pool): Promise<ForeignTable[]>;
139
150
  export {};
@@ -89,6 +89,17 @@ export class ForeignTable extends BasePgModel {
89
89
  };
90
90
  }
91
91
  }
92
+ /**
93
+ * Extract `pg_foreign_table` rows into `ForeignTable` models.
94
+ *
95
+ * The returned models carry option values **verbatim** from
96
+ * `pg_foreign_table.ftoptions`, which means a wrapper that puts
97
+ * credentials at the table level (uncommon but possible) would expose
98
+ * them cleartext in memory. Always route through `extractCatalog`
99
+ * (which calls `normalizeCatalog`) before emitting options to any
100
+ * output channel — see CLI-1467 and
101
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
102
+ */
92
103
  export async function extractForeignTables(pool) {
93
104
  const { rows: tableRows } = await pool.query(sql `
94
105
  with extension_oids as (
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sensitive-option redaction for foreign-data-wrapper objects.
3
+ *
4
+ * Foreign servers (`pg_foreign_server.srvoptions`) and user mappings
5
+ * (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext.
6
+ * Any code path that emits these option values verbatim — plan SQL, catalog
7
+ * snapshots, declarative export, fingerprints — leaks the credentials to
8
+ * disk, stdout, CI logs, and version control.
9
+ *
10
+ * The redaction policy is **allowlist-based**: replace every option value
11
+ * with `__OPTION_<KEY>__` unless the option key appears in
12
+ * {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan
13
+ * shows the placeholder instead of the real value" — annoying, but safe;
14
+ * a denylist's failure mode was secrets leaking, which is the bug we are
15
+ * fixing (CLI-1467).
16
+ *
17
+ * Match is case-insensitive but exact — substrings do not match, so an
18
+ * option key like `password_validator_extension` will be redacted unless
19
+ * explicitly allowlisted. When a new wrapper introduces a non-credential
20
+ * key we want to surface in plans, add it here.
21
+ */
22
+ export declare function redactOptionValue(key: string, value: string): string;
23
+ /**
24
+ * Redact non-allowlisted values in a flat `[key, value, key, value, ...]`
25
+ * options array — the shape used by the {@link Server},
26
+ * {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable}
27
+ * models.
28
+ *
29
+ * Returns `null` for `null` input, and otherwise returns an array of the
30
+ * same length as the input with sensitive values replaced.
31
+ */
32
+ export declare function redactSensitiveOptionPairs(options: readonly string[] | null): string[] | null;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Sensitive-option redaction for foreign-data-wrapper objects.
3
+ *
4
+ * Foreign servers (`pg_foreign_server.srvoptions`) and user mappings
5
+ * (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext.
6
+ * Any code path that emits these option values verbatim — plan SQL, catalog
7
+ * snapshots, declarative export, fingerprints — leaks the credentials to
8
+ * disk, stdout, CI logs, and version control.
9
+ *
10
+ * The redaction policy is **allowlist-based**: replace every option value
11
+ * with `__OPTION_<KEY>__` unless the option key appears in
12
+ * {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan
13
+ * shows the placeholder instead of the real value" — annoying, but safe;
14
+ * a denylist's failure mode was secrets leaking, which is the bug we are
15
+ * fixing (CLI-1467).
16
+ *
17
+ * Match is case-insensitive but exact — substrings do not match, so an
18
+ * option key like `password_validator_extension` will be redacted unless
19
+ * explicitly allowlisted. When a new wrapper introduces a non-credential
20
+ * key we want to surface in plans, add it here.
21
+ */
22
+ const SAFE_OPTION_KEYS = new Set([
23
+ // libpq connection params (non-credential subset).
24
+ // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
25
+ "host",
26
+ "hostaddr",
27
+ "port",
28
+ "dbname",
29
+ "user",
30
+ "sslmode",
31
+ "sslcompression",
32
+ "sslcert",
33
+ "sslkey",
34
+ "sslrootcert",
35
+ "sslcrl",
36
+ "sslcrldir",
37
+ "sslsni",
38
+ "requirepeer",
39
+ "krbsrvname",
40
+ "gsslib",
41
+ "sspi",
42
+ "gssencmode",
43
+ "gssdelegation",
44
+ "channel_binding",
45
+ "target_session_attrs",
46
+ "application_name",
47
+ "fallback_application_name",
48
+ "connect_timeout",
49
+ "client_encoding",
50
+ "options",
51
+ "keepalives",
52
+ "keepalives_idle",
53
+ "keepalives_interval",
54
+ "keepalives_count",
55
+ "tcp_user_timeout",
56
+ "replication",
57
+ "load_balance_hosts",
58
+ // postgres_fdw behavior tuning.
59
+ // https://www.postgresql.org/docs/current/postgres-fdw.html#POSTGRES-FDW-OPTIONS-CONNECTION
60
+ "use_remote_estimate",
61
+ "fdw_startup_cost",
62
+ "fdw_tuple_cost",
63
+ "fetch_size",
64
+ "batch_size",
65
+ "async_capable",
66
+ "analyze_sampling",
67
+ "parallel_commit",
68
+ "parallel_abort",
69
+ "extensions",
70
+ "updatable",
71
+ "truncatable",
72
+ "schema_name",
73
+ "table_name",
74
+ "column_name",
75
+ // Common shape for table-like FDWs (file_fdw, cloud-storage wrappers).
76
+ "schema",
77
+ "database",
78
+ "table",
79
+ "format",
80
+ "header",
81
+ "delimiter",
82
+ "quote",
83
+ "escape",
84
+ "encoding",
85
+ "compression",
86
+ // Cloud / Supabase Wrappers non-credential shape.
87
+ // https://github.com/supabase/wrappers
88
+ "region",
89
+ "endpoint",
90
+ "bucket",
91
+ "prefix",
92
+ "location",
93
+ "project_id",
94
+ "dataset_id",
95
+ "dataset",
96
+ "workspace",
97
+ "organization",
98
+ "api_version",
99
+ ]);
100
+ function redactedOptionPlaceholder(key) {
101
+ return `__OPTION_${key.toUpperCase()}__`;
102
+ }
103
+ export function redactOptionValue(key, value) {
104
+ return SAFE_OPTION_KEYS.has(key.toLowerCase())
105
+ ? value
106
+ : redactedOptionPlaceholder(key);
107
+ }
108
+ /**
109
+ * Redact non-allowlisted values in a flat `[key, value, key, value, ...]`
110
+ * options array — the shape used by the {@link Server},
111
+ * {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable}
112
+ * models.
113
+ *
114
+ * Returns `null` for `null` input, and otherwise returns an array of the
115
+ * same length as the input with sensitive values replaced.
116
+ */
117
+ export function redactSensitiveOptionPairs(options) {
118
+ if (options === null)
119
+ return null;
120
+ const result = [];
121
+ for (let i = 0; i < options.length; i += 2) {
122
+ const key = options[i];
123
+ const value = options[i + 1];
124
+ if (key === undefined || value === undefined)
125
+ continue;
126
+ result.push(key, redactOptionValue(key, value));
127
+ }
128
+ return result;
129
+ }
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { AlterServerChange } from "./server.base.js";
4
5
  /**
5
6
  * ALTER SERVER ... OWNER TO ...
@@ -70,7 +71,9 @@ export class AlterServerSetOptions extends AlterServerChange {
70
71
  optionParts.push(`DROP ${opt.option}`);
71
72
  }
72
73
  else {
73
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
74
+ const value = opt.value !== undefined
75
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
76
+ : "''";
74
77
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
75
78
  }
76
79
  }
@@ -1,5 +1,6 @@
1
1
  import { quoteLiteral } from "../../../base.change.js";
2
2
  import { stableId } from "../../../utils.js";
3
+ import { redactOptionValue } from "../../sensitive-options.js";
3
4
  import { CreateServerChange } from "./server.base.js";
4
5
  /**
5
6
  * Create a server.
@@ -49,9 +50,11 @@ export class CreateServer extends CreateServerChange {
49
50
  if (this.server.options && this.server.options.length > 0) {
50
51
  const optionPairs = [];
51
52
  for (let i = 0; i < this.server.options.length; i += 2) {
52
- if (i + 1 < this.server.options.length) {
53
- optionPairs.push(`${this.server.options[i]} ${quoteLiteral(this.server.options[i + 1])}`);
54
- }
53
+ const key = this.server.options[i];
54
+ const value = this.server.options[i + 1];
55
+ if (key === undefined || value === undefined)
56
+ continue;
57
+ optionPairs.push(`${key} ${quoteLiteral(redactOptionValue(key, value))}`);
55
58
  }
56
59
  if (optionPairs.length > 0) {
57
60
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -58,5 +58,15 @@ export declare class Server extends BasePgModel {
58
58
  }[];
59
59
  };
60
60
  }
61
+ /**
62
+ * Extract `pg_foreign_server` rows into `Server` models.
63
+ *
64
+ * The returned models carry option values **verbatim** from
65
+ * `pg_foreign_server.srvoptions`, which means cleartext secrets like
66
+ * `password` are present in memory. Always route through
67
+ * `extractCatalog` (which calls `normalizeCatalog`) before emitting
68
+ * options to any output channel — see CLI-1467 and
69
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
70
+ */
61
71
  export declare function extractServers(pool: Pool): Promise<Server[]>;
62
72
  export {};
@@ -64,6 +64,16 @@ export class Server extends BasePgModel {
64
64
  };
65
65
  }
66
66
  }
67
+ /**
68
+ * Extract `pg_foreign_server` rows into `Server` models.
69
+ *
70
+ * The returned models carry option values **verbatim** from
71
+ * `pg_foreign_server.srvoptions`, which means cleartext secrets like
72
+ * `password` are present in memory. Always route through
73
+ * `extractCatalog` (which calls `normalizeCatalog`) before emitting
74
+ * options to any output channel — see CLI-1467 and
75
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
76
+ */
67
77
  export async function extractServers(pool) {
68
78
  const { rows: serverRows } = await pool.query(sql `
69
79
  select