Database Migrations & Seeding
The schema is defined only by the ordered, append-only SQL files in supabase/migrations/. There is no ORM and no declarative schema. Migrations own structure and reference data, and seeds own content data. The rules are configured in the [db.migrations] and [db.seed] blocks of supabase/config.toml.
Overview
Section titled “Overview”- Migration-first.
schema_pathsis deliberately empty. Every change arrives as a timestampedYYYYMMDDHHMMSS_description.sqlfile, applied in lexicographic order. Related migrations in one batch get manually spaced timestamps (…000000,…000001) so their order is deterministic. - Applied migrations are immutable. Fixes are new corrective migrations, never edits to old files. For example, the May 2026 security wave corrected its own first attempt one migration later, and the August deletion cascades had four same-day follow-ups.
- Reference data lives in migrations. Lookup tables (
member_roles,sdgs,article_types,resource_categories,project_types, …) are created and populated inside migrations, so every environment has them. - Triggers follow the
db-triggerconventions:SECURITY DEFINERonly where needed, a pinnedsearch_path, and revoked default grants.CLAUDE.mdpoints authors to thedb-triggerskill rather than hand-writing these.
Migration History
Section titled “Migration History”The history falls into a few eras. Read the files themselves for detail. The headers of the larger migrations explain their reasoning at length and are worth reading before touching the same area.
| Era | What changed |
|---|---|
| Feb–Apr 2026: foundation | Remote-schema import, the ozeaondb_v2 tables and RLS, lookup seeding, the article schema, project child tables, and the first fix_* hardening pair. See Data Model & Database Schema. |
| Late Apr–May: organisations | The org MVP schema, the per-operation permissions matrix, in-app (non-email) invites, and member triggers |
| May: security wave | Linter clean-up, REVOKE … FROM PUBLIC, dropping pg_graphql, and moving RLS helpers into a private schema |
| May–Jul: content plumbing | article_images/article_documents replacing article_attachments, ON DELETE SET NULL image FKs, engagement scores and profile fields |
| Aug: ownership & deletion | Explicit project and article ownership (owner_id XOR organization_id), shared private RLS helpers, comment soft-delete and two-level nesting, the account and org deletion cascades with delete_user_account()/delete_organization(), the moderation log and the alpha badge |
| Sep: notifications | The new notifications table, trigger-only delivery and narrowed grants. See Notifications. |
Seeding
Section titled “Seeding”supabase db reset applies every migration, then seeds from sql_paths. That list contains only the committed synthetic fixture, supabase/seeds/10-preview-fixture.sql. The reasoning, from the config.toml comments:
supabase/seed.sqlis the real-data dump (pnpm db:seed-dump). It is gitignored, so the Supabase GitHub integration can’t see it, and preview branches used to come up empty. The fixture gives every preview branch usable content.seeds/00-truncate.sqlis deliberately not insql_paths. It exists only to clear migration-inserted rows that would collide with the real dump. Without the dump, it would delete the reference data the fixture depends on.
To work against real data locally, run pnpm db:reset-real. It resets, then applies 00-truncate.sql and seed.sql with psql -v ON_ERROR_STOP=1. It needs jq and psql on your PATH.
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- A migration changes what the fixture writes: update
10-preview-fixture.sqlin the same PR, or preview deployments break. - Editing an applied migration: environments that already ran it won’t re-run it and silently diverge. Always append a corrective migration.
- Adding
00-truncate.sqltosql_paths: every reset then loses its reference data and the fixture fails on foreign keys. - Revoking from
anon/authenticatedalone is a no-op while Postgres’ defaultPUBLICEXECUTE grant stands. Revoke fromPUBLIC, as the May security wave learned. - Supabase default privileges can widen column-level grants. The notifications grant fix had to revoke defaults first.
Operational Notes
Section titled “Operational Notes”- After any migration, run
pnpm db:gento regeneratedocs/db/schema.sqlandsrc/types/supabase.ts, and commit both. See Type System & Generated Types. - Every PR gets its own Supabase branch seeded from the fixture. See CI/CD Workflows.
Extension Points
Section titled “Extension Points”- Schema change: add a new timestamped file to
supabase/migrations/. Never edit an applied one. - Committable seed content: add a numerically prefixed file under
supabase/seeds/and list it in[db.seed].sql_paths. Files apply in list order. - Local-only data: keep it in the gitignored
seed.sqland out ofsql_paths.