## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? On a project whose database server statically checks `DO` blocks before running them (Multigres does), several Studio actions fail: - Creating a table shows "Failed to update API access privileges for new table", although the table is created. - Toggling Realtime for a table fails with `EXECUTE of a runtime-built statement inside a PL/pgSQL body is not supported`. - The same error blocks editing a role or schema (rename, owner, drop) and changing column privileges. All of these come from SQL that `@supabase/pg-meta` generates. Such a server only accepts a dynamic `EXECUTE` when it can prove the statement is fixed, and two shapes in our SQL prevent that: - parentheses around the payload: `execute(format(...))` - `%s` conversions in `format()` strings, which substitute raw text (for example `%s` fed by `<oid>::regclass`, by a `string_agg(...)` subselect, or by a variable holding a table list) ## What is the new behavior? `tablePrivileges.grant/revoke`, `columnPrivileges.grant/revoke`, `publications.update`, `roles.update/remove` and `schemas.update/remove` generate SQL with no parentheses around the payload and only `%I`/`%L` conversions: - Table and column privileges name the table with `%I.%I`, taking the schema and name from `pg_class`/`pg_namespace`. - The publication update loops over the tables to drop and to add, with the same `tables` semantics as before (`undefined` keeps the tables, `null` publishes all tables, a list replaces them, bare names resolve through the search path). - `schemas.remove` picks between `drop schema … cascade` and `… restrict` with two plain statements instead of a `%s` fed by a `CASE`. - The grant and revoke format strings are built with `literal()`, so a quote in a role name can't end the string early. The statements are equivalent on regular Postgres. ## Additional context - New tests: replacing, clearing and keeping a publication's tables, switching between a table list and all tables, and a test (no database needed) that fails if the generated SQL of any of these builders goes back to `execute(...)` or `%s`. - I ran the pg-meta tests against a local Postgres 17. A set of existing snapshot tests were recorded on Postgres 14 and fail there with and without this change (for example the new `MAINTAIN` privilege in table privileges), so CI is the real check for those. The tests covering the changed SQL pass. - Some other Studio SQL is blocked on such servers for different reasons (for example creating foreign servers, and `SET` inside a `DO` block). It isn't addressed here. --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
@supabase/pg-meta
SQL builders for Postgres catalog introspection, shared by Supabase Studio and
postgres-meta. Each builder in
src/sql/ returns a safe, parameterized SQL fragment (SafeSqlFragment) that a
caller executes against a user's live database to read schema metadata — tables,
columns, constraints, indexes, relationships, entity definitions, and so on.
The studio/ subtree holds the queries the Studio dashboard runs on every page
open (Table Editor, Database pages, entity lists, definitions).
Catalog query plan guard
Why this exists
These queries run against the user's live catalog, whose size we don't
control. A real production catalog had ~267K pg_class rows. Before
#47894, several CTEs in the
Table Editor query were unscoped: they scanned pg_index/pg_constraint
across the whole catalog regardless of which table was being opened. That turned
a single Table Editor open into O(catalog) sequential scans — 30–58s of work,
tripping statement timeouts, on large catalogs.
The fix scoped those CTEs to the requested table OID. To keep that class of
regression out for good, the package has a plan guard: a test suite that
builds a large synthetic catalog and asserts, via EXPLAIN (ANALYZE, FORMAT JSON), that each hot-path query's plan stays scoped.
test/db/stress-catalog.ts— builds a syntheticstressschema (default 2000 tables, plus a view, a materialized view, and a partitioned table).test/db/plan-guard.ts—explainAnalyze()+assertPlanWithinBudget()and the tolerated tiny-catalog set.test/sql/studio/catalog-plan-guard.test.ts— one budget per covered query.
THE RULE for new queries
Every new introspection query added under
src/sql/that runs on a user's live catalog must get a budget entry intest/sql/studio/catalog-plan-guard.test.ts.
Sequential scans over catalogs that scale with schema size — pg_class,
pg_attribute, pg_index, pg_constraint, pg_attrdef, pg_description,
pg_depend, pg_policy, pg_trigger, pg_rewrite, … — are only acceptable
with a written structural justification. An unscoped scan with no such
justification is a bug: scope the query to the requested OID/schema so it uses
an index instead.
How to add a budget entry
Run the query through the harness against the stress catalog and set a budget:
test('getMyNewSql: plan stays scoped', async () => {
const result = await explainAnalyze(db, getMyNewSql({ id: someTableId }))
assertPlanWithinBudget(result, {
// Omit allowedSeqScans entirely for a per-object query that must be fully
// index-scoped. Add an entry only for a structurally unavoidable scan:
allowedSeqScans: {
pg_constraint: {
max: 2,
reason: 'no index on pg_constraint.confrelid — incoming-FK lookup',
},
},
maxExecutionTimeMs: 1000, // default; loosen only with a comment
})
})
What's tolerated
- Tiny, fixed-size catalogs (
pg_namespace,pg_foreign_table,pg_foreign_server,pg_foreign_data_wrapper,pg_enum,pg_proc) are always tolerated. The planner full-scans them because they hold a handful of rows and don't grow with table count. SeeTINY_NON_SCALING_CATALOGS. - Justified structural scans on scaling catalogs — e.g. there is no index on
pg_constraint.confrelid, so the incoming-FK half of a relationships lookup must seq scan; a per-schema listing can't prunepg_classbecause there is no index onrelnamespacealone. Each such scan carries areasonstring and amaxnode count.
Budget judgment:
- Per-object queries (single id/table) allow no seq scans on scaling catalogs except structurally unavoidable ones (each with a reason).
- Per-schema / list queries may need one structural scan (e.g.
pg_classhas norelnamespace-only index) — allow it with a reason and keep a time bound.
Reproduce at scale locally
The default of 2000 tables keeps CI fast. To investigate closer to real incident scale, crank the table count:
PG_META_STRESS_TABLES=12000 pnpm --filter @supabase/pg-meta test catalog-plan-guard