Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/pgmq-queue-trigger-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@supabase/pg-delta": patch
---

fix(pg-delta): suppress user triggers on pgmq queue/archive tables in supabase integration

Follow-up to the Wasm FDW dependents fix. `pgmq.q_<name>` and `pgmq.a_<name>` are materialized lazily by `select pgmq.create('<name>')`, not by `CREATE EXTENSION pgmq`. The trigger extractor already drops these via the `pg_depend deptype='e'` row that pgmq records, but real-world cloud projects can lose that row (older pgmq versions — pgmq `1.4.4` which Supabase Cloud currently ships never records it — manual `pg_dump`/restore that strips extension deps, etc.), so `supabase db reset` aborts at the trigger statement with `relation "pgmq.q_<name>" does not exist`. Add a defensive name-match fallback in the supabase integration filter so the trigger is dropped even when the principled signal is missing.
7 changes: 7 additions & 0 deletions .changeset/wasm-fdw-dependents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@supabase/pg-delta": patch
---

fix(pg-delta): suppress Wasm FDW servers, foreign tables, and user mappings in supabase integration

Follow-up to CLI-1470. Also suppress SERVER (object/comment/security-label scopes), FOREIGN TABLE, and USER MAPPING changes whose parent wrapper handler or validator lives in `extensions.*`, so `db pull` no longer emits `CREATE SERVER clerk_oauth_server` for platform Wasm FDWs that local Docker cannot provision. Server _privilege_ scope is intentionally preserved — `GRANT/REVOKE ON SERVER` does not require superuser, and user `postgres_fdw` servers (whose handler installs into `extensions`) carry legitimate user ACL that must roundtrip.
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,10 @@ docs/api-reference/
# ===================
# tldr ignore patterns
# ===================
.tldr/
.tldr/

# Verdaccio local registry state (created by `bun run verdaccio:start`)
.verdaccio/storage/
.verdaccio/htpasswd
.verdaccio/plugins/
verdaccio/.verdaccio/
691 changes: 612 additions & 79 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"docs:pg-delta": "bun run --filter '@supabase/pg-delta' docs",
"test:pg-delta": "bun run --filter '@supabase/pg-delta' test",
"test:pg-topo": "bun run --filter '@supabase/pg-topo' test",
"verdaccio:start": "verdaccio --config ./verdaccio/config.yaml",
"pg-delta:publish-local": "bun scripts/verdaccio-publish-pg-delta.ts",
"version": "changeset version"
},
"devDependencies": {
Expand All @@ -23,7 +25,8 @@
"nyc": "^18.0.0",
"oxfmt": "0.51.0",
"oxlint": "1.66.0",
"oxlint-tsgolint": "0.23.0"
"oxlint-tsgolint": "0.23.0",
"verdaccio": "^6.0.5"
},
"overrides": {
"cpu-features": "file:./.stubs/cpu-features"
Expand Down
6 changes: 6 additions & 0 deletions packages/pg-delta/src/core/catalog.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
options: redactSensitiveOptionPairs(server.options),
comment: server.comment,
privileges: server.privileges,
wrapper_handler: server.wrapper_handler,
wrapper_validator: server.wrapper_validator,
});
});

Expand All @@ -455,6 +457,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
user: mapping.user,
server: mapping.server,
options: redactSensitiveOptionPairs(mapping.options),
wrapper_handler: mapping.wrapper_handler,
wrapper_validator: mapping.wrapper_validator,
});
});

Expand All @@ -470,6 +474,8 @@ function normalizeCatalog(catalog: Catalog): Catalog {
options: redactSensitiveOptionPairs(foreignTable.options),
comment: foreignTable.comment,
privileges: foreignTable.privileges,
wrapper_handler: foreignTable.wrapper_handler,
wrapper_validator: foreignTable.wrapper_validator,
}),
);

Expand Down
253 changes: 253 additions & 0 deletions packages/pg-delta/src/core/integrations/supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,86 @@ function fdwPrivilegeChange(fdw: { name: string; owner: string }): Change {
} as unknown as Change;
}

function serverChange(
operation: "create" | "alter" | "drop",
server: {
name: string;
owner: string;
foreign_data_wrapper: string;
wrapper_handler: string | null;
wrapper_validator: string | null;
},
): Change {
return {
objectType: "server",
operation,
scope: "object",
server: {
type: null,
version: null,
options: null,
comment: null,
privileges: [],
...server,
},
requires: [],
creates: [],
drops: [],
} as unknown as Change;
}

function foreignTableChange(
operation: "create" | "alter" | "drop",
foreignTable: {
schema: string;
name: string;
owner: string;
server: string;
wrapper_handler: string | null;
wrapper_validator: string | null;
},
): Change {
return {
objectType: "foreign_table",
operation,
scope: "object",
foreignTable: {
options: null,
comment: null,
columns: [],
privileges: [],
security_labels: [],
...foreignTable,
},
requires: [],
creates: [],
drops: [],
} as unknown as Change;
}

function userMappingChange(
operation: "create" | "alter" | "drop",
userMapping: {
user: string;
server: string;
wrapper_handler: string | null;
wrapper_validator: string | null;
},
): Change {
return {
objectType: "user_mapping",
operation,
scope: "object",
userMapping: {
options: null,
...userMapping,
},
requires: [],
creates: [],
drops: [],
} as unknown as Change;
}

function serverPrivilegeChange(server: {
name: string;
owner: string;
Expand All @@ -71,6 +151,33 @@ function serverPrivilegeChange(server: {
} as unknown as Change;
}

/**
* Build a synthetic trigger change shaped like what `flattenChange` consumes.
* The flattener emits keys `trigger/schema`, `trigger/table_name`,
* `trigger/function_schema`, etc. by walking the nested `trigger` model.
*/
function triggerChange(
operation: "create" | "alter" | "drop",
trigger: {
schema: string;
name: string;
table_name: string;
function_schema: string;
function_name: string;
owner: string;
},
): Change {
return {
objectType: "trigger",
operation,
scope: "object",
trigger,
requires: [],
creates: [],
drops: [],
} as unknown as Change;
}

describe("supabase integration filter — foreign data wrappers", () => {
// Regression for CLI-1470. Wasm-based foreign data wrappers on Supabase
// (e.g. `clerk`, `clerk_oauth`) are provisioned at project creation by
Expand Down Expand Up @@ -196,3 +303,149 @@ describe("supabase integration filter — foreign data wrapper / server ACLs", (
expect(evaluatePattern(filter, change)).toBe(true);
});
});

describe("supabase integration filter — Wasm FDW dependents", () => {
const wasmWrapper = {
wrapper_handler: "extensions.wasm_fdw_handler",
wrapper_validator: "extensions.wasm_fdw_validator",
} as const;

const userWrapper = {
wrapper_handler: "public.postgres_fdw_handler",
wrapper_validator: "public.postgres_fdw_validator",
} as const;

test("suppresses CREATE SERVER bound to extensions.* Wasm FDW", () => {
const change = serverChange("create", {
name: "clerk_oauth_server",
owner: "postgres",
foreign_data_wrapper: "clerk_oauth",
...wasmWrapper,
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("suppresses DROP FOREIGN TABLE bound to extensions.* Wasm FDW", () => {
const change = foreignTableChange("drop", {
schema: "public",
name: "clerk_oauth",
owner: "postgres",
server: "clerk_oauth_server",
...wasmWrapper,
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("suppresses ALTER FOREIGN TABLE bound to extensions.* Wasm FDW", () => {
const change = foreignTableChange("alter", {
schema: "public",
name: "clerk_oauth",
owner: "postgres",
server: "clerk_oauth_server",
...wasmWrapper,
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("suppresses DROP USER MAPPING bound to extensions.* Wasm FDW", () => {
const change = userMappingChange("drop", {
user: "postgres",
server: "clerk_server",
...wasmWrapper,
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("suppresses CREATE USER MAPPING when only wrapper validator is in extensions", () => {
const change = userMappingChange("create", {
user: "postgres",
server: "clerk_server",
wrapper_handler: null,
wrapper_validator: "extensions.wasm_fdw_validator",
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("preserves CREATE SERVER bound to user postgres_fdw wrapper", () => {
const change = serverChange("create", {
name: "live_risk_server",
owner: "postgres",
foreign_data_wrapper: "postgres_fdw",
...userWrapper,
});
expect(evaluatePattern(filter, change)).toBe(true);
});

test("preserves server ACL when postgres_fdw handler lives in extensions", () => {
const change = serverPrivilegeChange({
name: "user_server",
owner: "postgres",
});
(change as unknown as { server: Record<string, unknown> }).server = {
name: "user_server",
owner: "postgres",
wrapper_handler: "extensions.postgres_fdw_handler",
wrapper_validator: "extensions.postgres_fdw_validator",
};
expect(evaluatePattern(filter, change)).toBe(true);
});

test("preserves CREATE FOREIGN TABLE on user postgres_fdw server", () => {
const change = foreignTableChange("create", {
schema: "live_risk",
name: "devices",
owner: "postgres",
server: "live_risk_server",
...userWrapper,
});
expect(evaluatePattern(filter, change)).toBe(true);
});
});

describe("supabase integration filter — pgmq queue triggers", () => {
// Regression for the pgmq-1.4.4 cloud projects. `pgmq.create('<name>')`
// materializes `pgmq.q_<name>` and `pgmq.a_<name>` at runtime — they are
// NOT created by `CREATE EXTENSION pgmq`. On a healthy install the trigger
// extractor's `extension_table_oids` join already drops these via the
// `pg_depend deptype='e'` row that newer pgmq versions record, but on
// pgmq 1.4.4 that row is never recorded, so user triggers on the queue
// tables leak into the diff and break `supabase db reset` with
// `relation "pgmq.q_<name>" does not exist`. The filter must drop them
// at the supabase-integration level too, regardless of pg_depend state.

test("suppresses CREATE trigger on pgmq.q_<name> calling a public function", () => {
const change = triggerChange("create", {
schema: "pgmq",
name: "after_insert_processed_milestones_queue",
table_name: "q_processed_milestones_queue",
function_schema: "public",
function_name: "move_data_from_queue",
owner: "postgres",
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("suppresses DROP trigger on pgmq.a_<name> calling a public function", () => {
const change = triggerChange("drop", {
schema: "pgmq",
name: "after_insert_archive",
table_name: "a_processed_milestones_queue",
function_schema: "public",
function_name: "archive_handler",
owner: "postgres",
});
expect(evaluatePattern(filter, change)).toBe(false);
});

test("preserves CREATE trigger on auth.users calling a public function", () => {
const change = triggerChange("create", {
schema: "auth",
name: "on_auth_user_created",
table_name: "users",
function_schema: "public",
function_name: "handle_new_user",
owner: "supabase_auth_admin",
});
expect(evaluatePattern(filter, change)).toBe(true);
});
});
Loading
Loading