packages/pg-meta/README.md
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).
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 synthetic stress schema (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.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.
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
})
})
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. See TINY_NON_SCALING_CATALOGS.pg_constraint.confrelid, so the incoming-FK half of a relationships lookup
must seq scan; a per-schema listing can't prune pg_class because there is no
index on relnamespace alone. Each such scan carries a reason string and a
max node count.Budget judgment:
pg_class
has no relnamespace-only index) — allow it with a reason and keep a time
bound.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