Adds a standalone **Enable cleanup** button to the Cron Jobs page header so users can schedule the daily `delete-job-run-details` cleanup job proactively — previously this was only reachable inside the conditional "table too big" overflow dialog. Addresses [FE-3724](https://linear.app/supabase/issue/FE-3724/enable-pg-cron-cleanup-job-from-ui-and-api) (the UI half; the Management API half needs platform-side work). **Added:** - `Enable cleanup` button in the cron jobs header (left of Refresh), hidden while the existence check loads and whenever a `delete-job-run-details` job already exists - Confirmation dialog with a retention-period select (defaults to 7 days), live SQL preview, and telemetry (`cron_job_cleanup_enable_button_clicked` with `origin` + `retentionInterval`) - Component tests (MSW) for visibility gating and the schedule/cancel flows - E2E regression test for the full schedule → delete → button-reappears cycle **Fixed:** - Name-based `useCronJobQuery` lookup: the `queryFn` dropped the `name` param, and a not-found job returned `undefined` (rejected by react-query v5) — now passes `name` through and returns `CronJob | null` - Cache invalidation gaps: create/delete now invalidate the whole cron-jobs prefix (list, count, job details), so the footer count updates after create/delete and the button reappears after the cleanup job is deleted. The schedule mutation deliberately invalidates only the existence check + count (see inline comment) - Pre-existing e2e leak: the cleanup-workflow test left `delete-job-run-details` scheduled; it now cleans up after itself ## Screenshots | Header button | Dialog | | --- | --- | | <img width="890" height="325" alt="Screenshot 2026-07-22 at 9 44 40 PM" src="https://github.com/user-attachments/assets/966cd640-d8a6-4c8f-92e7-73151bf4de9c" /> | <img width="512" height="461" alt="fe3724-dialog" src="https://github.com/user-attachments/assets/6be1785f-cc7e-4048-a648-9ef260b0949f" /> | ## To test - Go to a project's Integrations → Cron → Jobs with pg_cron enabled and no `delete-job-run-details` job → the `Enable cleanup` button shows next to Refresh - Open the dialog, switch retention intervals → the SQL preview updates; confirm → success toast, the job appears in the grid (`0 12 * * *`), and the button disappears without a reload - Delete the `delete-job-run-details` job from the grid → the button reappears without a reload - Create then delete any other job → the footer `Total: N jobs` count updates both ways without a reload - Regression: with the high-query-cost banner forced (or via the e2e), the overflow dialog's "Schedule cleanup job" step still shows its success state — the dialog must not close mid-flow <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added an **Enable cleanup** action to the Cron Jobs tab header, including a retention selector and SQL preview. * Enabling schedules the daily cleanup, shows a success toast, updates the grid, and hides the enable button; **Cancel** closes the dialog without scheduling. * **Bug Fixes** * Improved cron job lookup to work by name when needed. * Refreshed related cron job data more reliably after scheduling and deletion. * **Telemetry** * Added an event for cleanup enable button clicks. * **Tests** * Added component and Playwright coverage for enable/cancel/schedule/delete and cleanup banner flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.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