diff --git a/apps/cli-go/pkg/migration/queries/drop.sql b/apps/cli-go/pkg/migration/queries/drop.sql index bbcf56edc7..568e15eef0 100644 --- a/apps/cli-go/pkg/migration/queries/drop.sql +++ b/apps/cli-go/pkg/migration/queries/drop.sql @@ -4,8 +4,8 @@ begin -- schemas for rec in select pn.* - from pg_namespace pn - left join pg_depend pd on pd.objid = pn.oid + from pg_catalog.pg_namespace pn + left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass where pd.deptype is null and not pn.nspname like any(array['information\_schema', 'pg\_%', '\_analytics', '\_realtime', '\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\_migrations', 'vault', 'extensions', 'public']) and pn.nspowner::regrole::text != 'supabase_admin' diff --git a/apps/cli-go/pkg/migration/queries/list.sql b/apps/cli-go/pkg/migration/queries/list.sql index 33b7be176f..7ab6201174 100644 --- a/apps/cli-go/pkg/migration/queries/list.sql +++ b/apps/cli-go/pkg/migration/queries/list.sql @@ -2,8 +2,8 @@ -- Extension created schemas -- Supabase managed schemas select pn.nspname -from pg_namespace pn -left join pg_depend pd on pd.objid = pn.oid +from pg_catalog.pg_namespace pn +left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass where pd.deptype is null and not pn.nspname like any($1) and pn.nspowner::regrole::text != 'supabase_admin' diff --git a/apps/cli/src/legacy/commands/db/lint/lint.lint-sql.ts b/apps/cli/src/legacy/commands/db/lint/lint.lint-sql.ts index dc864431c6..5aaa1875dd 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.lint-sql.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.lint-sql.ts @@ -6,10 +6,15 @@ * - `LEGACY_CHECK_SCHEMA_SCRIPT` — the per-schema `plpgsql_check_function` * mass-check. * - `LEGACY_LIST_SCHEMAS_SQL` + `LEGACY_MANAGED_SCHEMAS` — lists user - * schemas, used when `--schema` is omitted. The `\_` / `pg\_%` escapes - * are preserved exactly — they are `LIKE` patterns. + * schemas, used when `--schema` is omitted. The query is shared with the + * migra bash fallback and defined once in `db/shared/legacy-migra.ts` + * (`legacyListSchemasSql`), re-exported here under this module's + * established constant name. The `\_` / `pg\_%` escapes are preserved + * exactly — they are `LIKE` patterns. */ +export { legacyListSchemasSql as LEGACY_LIST_SCHEMAS_SQL } from "../shared/legacy-migra.ts"; + export const LEGACY_ENABLE_PGSQL_CHECK = "CREATE EXTENSION IF NOT EXISTS plpgsql_check"; export const LEGACY_CHECK_SCHEMA_SCRIPT = `-- Ref: https://github.com/okbob/plpgsql_check#mass-check @@ -20,17 +25,6 @@ JOIN pg_catalog.pg_language l ON p.prolang = l.oid WHERE l.lanname = 'plpgsql' AND p.prorettype <> 2279 AND n.nspname = $1::text; `; -export const LEGACY_LIST_SCHEMAS_SQL = `-- List user defined schemas, excluding --- Extension created schemas --- Supabase managed schemas -select pn.nspname -from pg_namespace pn -left join pg_depend pd on pd.objid = pn.oid -where pd.deptype is null - and not pn.nspname like any($1) - and pn.nspowner::regrole::text != 'supabase_admin' -order by pn.nspname`; - /** * Postgres-managed schemas excluded from the user-schema listing. These are * `LIKE` patterns bound as the `$1` text[] parameter — the `\_` / `pg\_%` diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5b2dfb6b5e..7e84908337 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -80,7 +80,7 @@ child) is fully native as of CLI-1958. | Statement | When | | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | -| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `legacyDropObjectsSql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | | `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | | schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) | | migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` | diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts index 88d394498c..adfa1a19a4 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -2,168 +2,25 @@ import { Effect } from "effect"; import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDropObjectsSql } from "../../../shared/legacy-drop-objects.ts"; /** - * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` - * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, - * extensions, public-schema objects, and non-managed publications, then - * truncates the auth / supabase_functions / supabase_migrations tables. Run as a - * single simple-query statement, matching Go's one-statement `ExecBatch`. - */ -const DROP_OBJECTS = `do $$ declare - rec record; -begin - -- schemas - for rec in - select pn.* - from pg_namespace pn - left join pg_depend pd on pd.objid = pn.oid - where pd.deptype is null - and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) - and pn.nspowner::regrole::text != 'supabase_admin' - loop - -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also - -- drop the extension. But if an extension creates its own schema, dropping the schema will - -- throw an error. Hence, we drop schemas first while excluding those created by extensions. - raise notice 'dropping schema: %', rec.nspname; - execute format('drop schema if exists %I cascade', rec.nspname); - end loop; - - -- extensions - for rec in - select * - from pg_extension p - where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') - loop - raise notice 'dropping extension: %', rec.extname; - execute format('drop extension if exists %I cascade', rec.extname); - end loop; - - -- functions - for rec in - select * - from pg_proc p - where p.pronamespace::regnamespace::name = 'public' - loop - -- supports aggregate, function, and procedure - raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; - execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); - end loop; - - -- views (necessary for views referencing objects in Supabase-managed schemas) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 'v' - loop - raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 'm' - loop - raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- tables (cascade to dependent objects) - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind not in ('c', 'S', 'v', 'm') - order by c.relkind desc - loop - -- supports all table like relations, except views, complex types, and sequences - raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- truncate tables in auth, webhooks, and migrations schema - for rec in - select * - from pg_class c - where - (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' - or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' - or c.relnamespace::regnamespace::name = 'supabase_migrations') - and c.relkind = 'r' - loop - raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- sequences - for rec in - select * - from pg_class c - where - c.relnamespace::regnamespace::name = 'public' - and c.relkind = 's' - loop - raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; - execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); - end loop; - - -- types - for rec in - select * - from pg_type t - where - t.typnamespace::regnamespace::name = 'public' - and typtype != 'b' - loop - raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; - execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); - end loop; - - -- policies - for rec in - select * - from pg_policies p - loop - raise notice 'dropping policy: %', rec.policyname; - execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); - end loop; - - -- publications - for rec in - select * - from pg_publication p - where - not p.pubname like any(array['supabase\\_realtime%', 'realtime\\_messages%']) - loop - raise notice 'dropping publication: %', rec.pubname; - execute format('drop publication if exists %I', rec.pubname); - end loop; -end $$;`; - -/** - * Drops all user-created database objects, mirroring Go's - * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` - * block runs as a single transactional statement (no migration-history row). + * Drops all user-created database objects for `db reset`'s remote (`--db-url`) + * path: runs the shared `legacyDropObjectsSql` `DO` block inside an + * explicit transaction, mapping failures through the caller's error + * constructor (no migration-history row). */ export const legacyDropUserSchemas = ( session: LegacyDbSession, mapError: (message: string) => E, ): Effect.Effect => Effect.gen(function* () { - // Go's `DropUserSchemas` runs only `drop.sql` via `ExecBatch` (drop.go:34-38) — - // no `RESET ALL`. Resetting here would clear caller-supplied DB URL runtime - // params (e.g. `options=-c statement_timeout=…`) before the destructive drop, so - // the remote `db reset --db-url` path must NOT reset (matches Go's ExecBatch). + // No `RESET ALL` before the drop: resetting would clear caller-supplied DB + // URL runtime params (e.g. `options=-c statement_timeout=…`) on the remote + // `db reset --db-url` path before the destructive statement runs. yield* session.exec("BEGIN"); yield* session - .exec(DROP_OBJECTS) + .exec(legacyDropObjectsSql) .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); yield* session.exec("COMMIT"); }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts index f6e758efcb..358c4be931 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts @@ -2,11 +2,13 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { legacyDropObjectsSql } from "../../../shared/legacy-drop-objects.ts"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { legacyMigraDiffScript, legacyMigraDiffShellScript, } from "./legacy-migra.deno-templates.ts"; +import { legacyListSchemasSql } from "./legacy-migra.ts"; // Resolve the Go template sources relative to this file so the byte-equality // assertion fails loudly if the embedded copies drift from upstream. @@ -25,3 +27,25 @@ describe("embedded migra templates", () => { expect(legacyMigraDiffScript).toContain(LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL); }); }); + +describe("embedded user-schema queries", () => { + // An unscoped pg_depend anti-join hid user schemas whose oid collided with a + // row in another catalog (supabase/cli#6375). + it.each([ + ["legacyListSchemasSql", legacyListSchemasSql], + ["legacyDropObjectsSql", legacyDropObjectsSql], + ])( + "%s constrains the pg_depend anti-join to pg_namespace rows (supabase/cli#6375)", + (_name, sql) => { + // normalize whitespace so a cosmetic re-wrap of the join cannot fail this + const normalized = sql.replaceAll(/\s+/gu, " "); + const joins = normalized.match(/pd\.objid = pn\.oid/gu) ?? []; + const constrained = + normalized.match( + /pd\.objid = pn\.oid and pd\.classid = 'pg_catalog\.pg_namespace'::regclass/gu, + ) ?? []; + expect(joins.length).toBeGreaterThan(0); + expect(constrained).toHaveLength(joins.length); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts index 55fae1c7e1..32641fe8ef 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -84,13 +84,20 @@ const LEGACY_LIST_SCHEMAS_EXCLUDE: ReadonlyArray = [ "vault", ]; -/** Verbatim from Go's `migration.ListSchemas` (`pkg/migration/queries/list.sql`). */ -const LEGACY_LIST_SCHEMAS_SQL = `-- List user defined schemas, excluding +/** + * Lists user-defined schemas, excluding extension-created ones via a + * `pg_depend` anti-join scoped by `classid` to `pg_namespace` rows (an oid + * collision with another catalog must not hide a schema — supabase/cli#6375), + * Supabase-managed names via the `$1` LIKE patterns, and schemas owned by + * `supabase_admin`. Shared by the migra bash fallback and `db lint` + * (`lint.lint-sql.ts`). + */ +export const legacyListSchemasSql = `-- List user defined schemas, excluding -- Extension created schemas -- Supabase managed schemas select pn.nspname -from pg_namespace pn -left join pg_depend pd on pd.objid = pn.oid +from pg_catalog.pg_namespace pn +left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass where pd.deptype is null and not pn.nspname like any($1) and pn.nspowner::regrole::text != 'supabase_admin' @@ -162,16 +169,14 @@ const loadTargetUserSchemas = Effect.fnUntraced(function* ( }), ), ); - const rows = yield* session - .query(LEGACY_LIST_SCHEMAS_SQL, [LEGACY_LIST_SCHEMAS_EXCLUDE]) - .pipe( - Effect.mapError( - (cause) => - new LegacyMigraSchemaLoadError({ - message: `failed to list schemas: ${cause.message}`, - }), - ), - ); + const rows = yield* session.query(legacyListSchemasSql, [LEGACY_LIST_SCHEMAS_EXCLUDE]).pipe( + Effect.mapError( + (cause) => + new LegacyMigraSchemaLoadError({ + message: `failed to list schemas: ${cause.message}`, + }), + ), + ); return rows.map((row) => String(row["nspname"])); }), ); diff --git a/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md index 32973e489a..233d19b047 100644 --- a/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/down/SIDE_EFFECTS.md @@ -41,7 +41,7 @@ ### `--output-format text` Prints `Resetting database to version: ` to stderr, then drops every -user schema/object (the bundled `drop.sql` DO-block), upserts `[db.vault]` +user schema/object (the embedded `legacyDropObjectsSql` DO-block), upserts `[db.vault]` secrets, and re-applies local migrations `<= version` plus seed files (each gated on `db.migrations.enabled` / `db.seed.enabled`). Nothing is written to stdout. diff --git a/apps/cli/src/legacy/shared/legacy-drop-objects.ts b/apps/cli/src/legacy/shared/legacy-drop-objects.ts index 16ce9ddb2d..d92f1bb0a3 100644 --- a/apps/cli/src/legacy/shared/legacy-drop-objects.ts +++ b/apps/cli/src/legacy/shared/legacy-drop-objects.ts @@ -7,7 +7,7 @@ import { } from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -/** Dropping the user schemas failed (`DropUserSchemas` error). */ +/** Dropping the user-created database objects failed. */ export class LegacyMigrationDropError extends Data.TaggedError("LegacyMigrationDropError")<{ readonly message: string; }> { @@ -17,19 +17,23 @@ export class LegacyMigrationDropError extends Data.TaggedError("LegacyMigrationD } /** - * The embedded `DO $$ ... $$` block from `pkg/migration/queries/drop.sql`, - * bundled verbatim. `migration.DropUserSchemas` runs this single statement to - * drop every user-created schema/extension/object in `public` and truncate the - * managed `auth` / `supabase_functions` / `supabase_migrations` tables. + * A single PL/pgSQL `DO` block that drops user schemas, non-managed + * extensions, and `public`-schema objects, drops every RLS policy and every + * non-Supabase publication database-wide (not just in `public`), then + * truncates the managed `auth` / `supabase_functions` / `supabase_migrations` + * tables. The schema loop anti-joins `pg_depend` scoped by `classid` to + * `pg_namespace` rows so an oid collision with another catalog cannot hide a + * user schema (supabase/cli#6375). Shared by `migration down` and `db reset` + * (`legacy-drop-schemas.ts`). */ -const LEGACY_DROP_OBJECTS_SQL = `do $$ declare +export const legacyDropObjectsSql = `do $$ declare rec record; begin -- schemas for rec in select pn.* - from pg_namespace pn - left join pg_depend pd on pd.objid = pn.oid + from pg_catalog.pg_namespace pn + left join pg_catalog.pg_depend pd on pd.objid = pn.oid and pd.classid = 'pg_catalog.pg_namespace'::regclass where pd.deptype is null and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) and pn.nspowner::regrole::text != 'supabase_admin' @@ -161,11 +165,10 @@ end $$; `; /** - * Drops every user-created object, matching `migration.DropUserSchemas`: - * one batched DO-block statement (a single + * Drops every user-created object as one DO-block statement (a single * statement is atomic in Postgres, so no explicit transaction is needed). */ export const legacyDropUserSchemas = (session: LegacyDbSession) => session - .exec(LEGACY_DROP_OBJECTS_SQL) + .exec(legacyDropObjectsSql) .pipe(Effect.mapError((cause) => new LegacyMigrationDropError({ message: cause.message })));