Database Ops
SABLE's operational database work is almost entirely in sable-agents-demo/supabase/migrations/
— there is no separate ORM migration tool. This page collects the patterns worth knowing before
writing a new migration or debugging a slow query against the larger tables.
Migrations are the only path to schema change
Per the production boundary (see Developer Onboarding), schema
changes are new migration files reviewed in a PR — never a direct supabase db push from a
workstation. supabase migration list (from sable-agents-demo/supabase/) confirms every local
migration is applied remotely; supabase db diff shows what a linked project would need to catch
up to local migration files.
Read the comment lines — they're documentation
Migration files in this repository are written with the reasoning inline, not just the SQL. For
example, 20260909200000_index_inactive_staged_rows.sql opens with the actual production incident
that motivated it — a cleanup query hitting the 8-second statement_timeout 235 times in twenty
hours against an 18 GB, 3.1-million-row spreadsheet_rows table because nothing indexed the filter
it scanned on. That kind of context does not survive in the schema alone; read the file, not just
\d table_name.
The largest tables and why they need care
| Table | Scale (as of writing) | Operational note |
|---|---|---|
spreadsheet_rows | ~18 GB, 3.1M rows | Ingested spreadsheet content staged for indexing; index_active = false rows are the exception (superseded by a re-index) and should be deleted within a day — see the cleanup pattern below. |
knowledge_chunks | ~1.8 GB | Same staged/superseded cleanup path as spreadsheet_rows; both got a partial index on (indexed_at) where index_active = false so the cleanup query is an index lookup, not a full scan. |
The batch-delete pattern: server-side, not client-paginated
discard_stale_staged_rows(p_table, p_cutoff, p_limit) is a SECURITY DEFINER function, callable
only by service_role, that deletes up to p_limit stale staged rows from an explicitly
whitelisted table (spreadsheet_rows or knowledge_chunks — the table name is never interpolated
from caller input). This exists because deleting by a client-supplied list of ids puts the ids in
the request: 2,000 UUIDs is roughly 75 KB, and the HTTP/2 path in front of PostgREST rejects
requests well under that (fathom-sync's 400-id in() filter failed at roughly 15 KB in
production on 2026-09-09). One server-side call, one bounded statement, no oversized URL.
The general shape — index the exact predicate a scheduled cleanup filters on, then delete through a small whitelisted RPC rather than a client-built query — is the pattern to reach for the next time a "delete the stale rows" job needs to scale past a few hundred rows.
Locking discipline
supabase db push runs each migration inside a transaction, so a new index build takes a SHARE
lock that blocks writes to that table for the duration of the scan (reads continue uninterrupted).
CREATE INDEX CONCURRENTLY is not available inside a transactional migration, so when a table only
receives writes during a specific, known window (document indexing, for spreadsheet_rows and
knowledge_chunks), the tradeoff is explicit in the migration comment: merge and deploy when
nobody is actively indexing, rather than paying for CONCURRENTLY's extra complexity.
Advisors, not guesswork
Two hardening migrations in September 2026 (see
Security Posture) came directly from Supabase's built-in advisor
findings, and just as importantly, two other advisor suggestions were deliberately not
applied because the reviewer traced through what they would actually break (moving the vector
extension out of public would stop a search_path-pinned function from resolving the vector
type; pg_net isn't relocatable). Run the advisors before assuming a schema change is safe, and
read every suggestion's actual effect before applying it — "the linter said so" is not sufficient
justification on its own.
Local helper tests
cd sable-agents-demo/supabase
npm test # node --test tests/*.mjs
npm run seed # node seed.mjs
Where the code lives
sable-agents-demo/supabase/migrations/*.sql— every schema change, in order, with reasoning.sable-agents-demo/supabase/tests/*.mjs— backend helper tests.sable-agents-demo/supabase/seed.mjs— the seed script.sable-agents-demo/trigger/documents.ts— the staged-generation cleanup task that motivated the partial indexes above.