Skip to main content
The Tuturuuu platform uses Supabase migrations to manage database schema changes with version control and reproducibility.

Declarative adoption preparation

Declarative schemas are opt-in preparation. Existing migrations remain the source of truth; this foundation does not enable an engine, export a baseline, change deployment gates, or regenerate database types.
Run after workspace dependency setup. The checker prints migration/schema filename and byte-hash inventories, config/lock identities, the PostgreSQL major, and bounded help probes of the installed bundled Supabase CLI. It installs nothing and makes no database connections. It rejects linked targets, database URLs, apply/overwrite/experimental flags, and unknown arguments before inspection. Exit 2 means adoption is held; exit 1 means inspection failed; --help exits 0. A schema tree plus supported commands cannot produce a ready result. Keep the report in ignored task evidence. The current Supabase guide distinguishes pg-delta db schema declarative generate/sync from legacy migra db diff and ordered schema_paths. Check exact installed help first. Root owns any CLI upgrade choice. Do not use a pg-delta export with legacy diffing, add a partial tree, or silently change the engine/config or existing sb:diff command. Baseline approval requires a fresh source/config/lock/version binding, explicit reconciliation of pending migration owners, complete disposable Linux replay, and full ledger comparison. Export DDL only from that task-owned database, review for embedded secrets, then prove a no-op declarative diff against the complete history and catalog/security parity from a second disposable replay. A production dump can omit pending migrations and is not an approved baseline. Never reset foreign databases or rewrite deployed history. Keep DML/backfills, environment operations, and diff-engine unsupported objects in reviewed imperative migrations with an explicit exception inventory. Verify RLS, public/private grants and default privileges, service-role functions, security-definer search paths, extension versions, Storage policies, and application customizations of managed auth/storage/realtime schemas separately. A no-op diff cannot prove objects the engine excludes. Keep declarations below the authored 700-LOC ceiling, use the shared resource queue, and prove closure of all task-owned containers, volumes, ports, and scopes. The detailed repository procedure is plugins/tuturuuu/skills/tuturuuu-database/references/declarative-schema-adoption.md. Until those independently reviewed receipts exist, adoption remains preparation. After approval, use the qualified engine to propose forward migrations, review all generated SQL, replay and test security, then run typegen on the applied schema. Production application remains separately authorized.

Declarative parity pack

The parity pack hashes schema state so two databases can be compared without exporting definitions. It covers relations, columns, constraints, indexes, functions, policies (with restrictive account_required_mfa policies in their own category), raw ACLs, default privileges, triggers, extensions, publications, types and cron.job. It also runs has_*_privilege probes for anon, authenticated, service_role and PUBLIC over the API schemas plus private.
Each category query lives in apps/database/verification/declarative-parity/. PostgreSQL hashes the payloads, and the runner rejects any output row that isn’t an identity, a SHA-256 digest or a count. Reports therefore hold names, counts and digests only, never qual text, function bodies or row data. The pack runs in one repeatable read read only transaction with default_transaction_read_only=on, statement_timeout=60s and lock_timeout=1s. For a URL connection, set DECLARATIVE_PARITY_DATABASE_URL (or name another variable with --database-url-env). The URL never goes in argv, it can’t carry query parameters, psql runs with a minimal environment (no inherited PG* service, password-file or hostaddr settings) and errors are redacted. The Docker path unsets the container’s own PG* variables before psql starts. Docker and loopback targets count as local. Any other host is refused unless the run uses --hashes-only and --ca-file (or DECLARATIVE_PARITY_CA_FILE) pins a CA for sslmode=verify-full, with channel_binding=require and SCRAM-SHA-256 authentication. The CA must be a regular file (not a symlink), owned and readable by the runner, and not group- or world-writable. Its directory must be owned by the runner or root and not group- or world-writable. Reports carry target: local|remote. The prod workflow passes --prod-parity (refused unless the target is remote, the run is --hashes-only and --expect names a full report), then asserts the result with --assert-prod-report <report.json>. That assertion fails unless the report has this pack’s format, ok: true, a comparison and comparison.equal: true. --compare and --expect refuse any report without this pack’s format and well-formed SHA-256 digests. Exit 1 means a must-be-zero check failed or --expect found a mismatch. The checks cover application SECURITY DEFINER functions. A function is managed, and excluded, when a Supabase role (supabase_*) owns it, it belongs to an extension, or it lives in storage or graphql_public. Schema name alone does not exclude it, because postgres can create functions in schemas such as extensions. The checks flag a NULL proacl, EXECUTE for PUBLIC or anon, and a search_path that fails the security-definer-search-paths.sql rule. --hashes-only --expect <full.json> is the form for the one approved prod-parity check. It prints category digests plus the identities of mismatched objects only. It never reads tables, so cron_jobs (a cron.job read that pg_cron RLS can silently filter) is reported as skipped by design rather than hashed. A hashes-only run doesn’t even resolve cron.job, so a role without USAGE on cron works. Cron job identities include the owning username. Comparisons ignore a skipped cron_jobs and fail on any other skipped category. Agents must never point the pack at production. Fixture tests run with node --test apps/database/scripts/verify-declarative-parity.test.mjs.

Migration Workflow

Creating Migrations

Method 1: Generate from Studio Changes

Best for: Visual schema editing, quick table creation
  1. Make changes in Supabase Studio:
  2. Generate migration:
  3. Review generated file:

Method 2: Manual SQL Migration

Best for: Complex changes, custom functions, data migrations
  1. Create blank migration:
  2. Edit migration file:

Migration File Structure

Naming Convention

Examples:
Keep new migration timestamps ahead of the latest migration already on main, staging, and production. Supabase refuses to apply a local migration that sorts before the last remote migration unless the operator passes --include-all. Staging and production CI intentionally run supabase db push --include-all so each project can converge after migration history changes, but ordinary forward deploys should still keep timestamps monotonic. After a rebase, merge, or hotfix, re-list apps/database/supabase/migrations and rename any still-unapplied local migration forward instead of treating --include-all as permission to edit or replay old migration history. PR CI enforces this: scripts/check-migration-timestamps.js fails when any migration a pull request adds or renames sorts at or before the newest migration on its base branch, and names the offending files and the base timestamp. Run it locally (or through bun check:changed) to compare against your local origin/main.

File Template

Common Migration Patterns

Creating Tables

Adding Columns

Modifying Columns

Creating Indexes

Creating Functions

Creating Triggers

Creating Enums

Data Migrations

Safe Data Migration Pattern

Batch Processing Large Migrations

Testing Migrations

Local Testing

Test RLS Policies

Verify Indexes

SQL grant guard

The guard checks all migrations, rollbacks and schema SQL against the declarative grant policy. Historical findings are digest-bound debt, not safe exceptions; there is no date cutoff. Dynamic SQL requires statement-adjacent security review. Settings of standard_conforming_strings are forbidden, and set_config('search_path', …) fails closed. Dollar-quoted bodies cannot supply review markers.

SQL grant end-state guard

The ACL-replay companion to the guard above is scripts/ci/sql-grant-end-state-guard.js. It owns no baseline: the frozen inherited-ACL list, the source-bound exception and the end-state rules below live only there, and the security definer guard imports them. The baseline guard and this one share no entries. scripts/ci/sql-grant-end-state-guard.js runs with the root script tests (bun test:scripts, CI job “Root script tests”) and rejects two patterns in migrations and rollbacks whose version prefix is 20261028000000 or later. Unversioned files are always checked.
  1. Public or anon grants. Any GRANT (function EXECUTE, table or view privileges, schema usage, role membership) or ALTER DEFAULT PRIVILEGES ... GRANT whose grantees include public or anon, in apps/database/supabase/migrations/*.sql or apps/database/rollbacks/*.sql. Rollbacks must not restore an open grant that the forward migration closed; grant to authenticated or service_role instead.
  2. Unrevoked SECURITY DEFINER functions (migrations only). Every CREATE [OR REPLACE] FUNCTION ... SECURITY DEFINER needs a revoke for the same name and argument types from both public and anon after its creation. The end-state rule: the revoke may sit in the same file or in any later timestamped forward migration, but a migration newer than 20261029030400 (the newest allowlisted file below) must carry the revoke in the same file, after the creation. The cutoff restricts only where the qualifying revoke may come from; later migrations are still replayed, so a later grant to public or anon (an allow-anon-grant marker does not exempt the definer rule) fails the definer. An earlier revoke does not satisfy a replacement. Later EXECUTE grants invalidate the corresponding revoke; another overload, a different signature or a manual rollback cannot supply proof. The guard prints every pass that rests on a revoke in another file:
  3. Inherited ACL (frozen allowlist). CREATE OR REPLACE keeps the ACL of the existing function, so six definers already on main whose earlier revoke predates their latest replacement pass by inheritance. The allowlist in scripts/ci/sql-grant-frozen-definers.js is the single frozen source (never add entries or copy it; new definers carry their own revoke) and each entry is tied to its migration file: Inheritance holds only while the exact signature has a prior REVOKE from public and anon (and from authenticated, which current policy requires for every entry except transfer_workspace_ownership, which keeps authenticated EXECUTE by design) and nothing after it drops, renames or moves the function (a signature-free DROP, ALTER ... RENAME TO or SET SCHEMA or GRANT names every overload of that name), recreates it with a plain CREATE, or grants it to public/anon (or to authenticated where that is required closed). The guard prints the migration and line that evidence each inherited pass.
Reviewed, source-bound exception. scripts/ci/sql-grant-reviewed-exceptions.js holds the one security-reviewed exception to the definer rule: the four invoice-total definers in 20261029040800_finance_workspace_boundaries.sql, whose revoke sits in a pre-creation dynamic DO block (CREATE OR REPLACE preserves the ACL; bodies gate on auth.role() = 'service_role'). It applies only while the exact path, the file’s sha256, a per-finding digest (rule, creation line and signature, for each of the four findings) and a DO block whose normalized content digest matches all hold, so any byte change to the file, another file, another line or another signature gets nothing, and a later grant to public or anon still fails. An internal security review verified the end state is service_role-only (proacl of exactly postgres and service_role), covered by supabase/tests/finance-invoice-totals-definer-acl.sql. The guard prints each covered signature as a reviewed exception. Never add entries without a security review. Default EXECUTE on new private functions. A function that postgres creates in private is closed to public and anon but open to authenticated and service_role, through the global default privilege that 20261027151200_restrict_anon_definer_execute.sql sets for role postgres. The schema-scoped ALTER DEFAULT PRIVILEGES ... IN SCHEMA private REVOKE EXECUTE in 20261029030500_align_cross_app_token_grant_and_private_fence.sql adds nothing beyond it, because schema-scoped entries can only add privileges. That migration is applied history and is never edited; its comments are not authoritative, this paragraph is. 20261027151200 closes public and anon on new private functions, only authenticated and service_role keep default EXECUTE, and the guard’s explicit same-file REVOKE rule is the enforcement for a definer in private that must not reach authenticated. The pgTAP test apps/database/supabase/tests/private-schema-default-execute-limitation.sql asserts this default and must be updated if the global default changes. The guard ignores text in comments, string literals and dollar-quoted bodies, so dynamic EXECUTE 'grant ...' inside PL/pgSQL is not detected; review those by hand. Focused cases live in scripts/ci/sql-grant-end-state.test.js and scripts/ci/sql-grant-inherited-acl.test.js; the repository-wide test stays strict. Rollbacks are applied manually and never count toward the forward end state. For a reviewed exception, put a comment with a reason on the statement’s first line, any line of the statement, or the line before it:
node scripts/ci/sql-grant-end-state-guard.js --report lists the legacy findings in files before the cutoff by rule and by file. It never fails; use it to plan cleanup migrations.

Migration Checklist

Before committing a migration:
  • Migration file has descriptive name
  • All tables have RLS enabled
  • No public/anon grants and every SECURITY DEFINER function revoked from public, anon (SQL grant guard)
  • RLS policies are tested
  • Indexes added for foreign keys
  • Indexes added for frequently queried columns
  • Comments added for complex logic
  • Data migration is idempotent
  • Migration tested with bun sb:reset
  • Type generation updated (bun sb:typegen)
  • Breaking changes documented

Deploying Migrations

⚠️ AGENTS: NEVER RUN THESE COMMANDS

Agents should prepare migrations but NEVER deploy them. Only users should run:

Agent Workflow

Agents should:
  1. Create migration file
  2. Test locally with bun sb:reset
  3. Run bun sb:typegen
  4. Commit files
  5. Instruct user to run bun sb:push
Agents should NEVER:
  • Run bun sb:push
  • Run bun sb:linkpush
  • Deploy to production

Rollback Strategy

See the internal manual incident rollback runbook for the per-migration inventory, reverse run order, timeout/transaction rules and forward-only cross-app token containment. These manual files do not alter the hosted migration ledger.

Simple Rollback

Complex Rollback

Common Issues

Issue: Migration Fails on Production

Cause: Local Supabase version differs from production Solution:

Issue: Type Generation Fails

Cause: Migration has syntax errors Solution:

Issue: RLS Policy Conflicts

Cause: Multiple policies on same table/operation Solution:

Best Practices

✅ DO

  1. Use descriptive names
  2. Add indexes for foreign keys
  3. Enable RLS on all tables
  4. Add comments for clarity
  5. Test migrations locally first
  6. Materialize replacement-table rows before rewiring child foreign keys

❌ DON’T

  1. Don’t skip RLS
  2. Don’t use DROP without backup
  3. Don’t modify old migrations
  4. Don’t deploy without testing

External Resources

Declarative grant guard (P2-2)

The SQL grant guard checks all migrations, schemas and rollbacks, including on main. scripts/ci/sql-grant-guard.baseline.json records historical debt, not safe: one entry per existing diagnostic, with path, rule and SHA-256 of the statement or block. Shared file digests bind each entry to the complete file. The baseline needs independent review; it never declares historical SQL safe. A new diagnostic fails. Any change to a baselined file fails until its diagnostics are fixed and its debt entries removed. Entries that no longer match also fail and must be removed. The baseline can only shrink; additions, digest rewrites and stale metadata fail. CI resolves shallow history explicitly and checks main pushes against the pre-push commit. An unresolved base fails loudly. For the initial reviewed bootstrap, generate from a pinned main SHA with node scripts/ci/sql-grant-guard-baseline.js --generate <full-main-SHA>. The guard independently checks bootstrap entries against that source snapshot. Routine fixes remove debt entries rather than regenerating or growing the list. Use node scripts/ci/sql-grant-guard.js --report to inspect debt and new findings. Created functions and procedures require a subsequent matching-signature REVOKE ALL from PUBLIC, anon, authenticated, plus a grant to service_role or a same-file -- tuturuuu: no-grant (internal) or -- tuturuuu: no-grant (trigger) comment. The grant policy applies in every file, including later standalone migrations and schemas:
  • Grants without an explicit parseable argument list fail.
  • Schema-wide grants (ON ALL ... IN SCHEMA) and ALTER DEFAULT PRIVILEGES grants to PUBLIC, anon, or authenticated always fail, even with a marker.
  • A grant to authenticated requires its own statement-adjacent review marker naming the exact canonical signature.
  • A grant to anon or PUBLIC additionally requires anon-required in that marker. The legacy allow-anon-grant marker cannot satisfy this routine fence.
Use canonical signatures (public is explicit, argument names are omitted, and aliases such as int normalize to integer). Each target in a multi-target grant needs its own signature marker. A marker applies only on the statement’s lines or its immediately preceding line. A reviewed client grant satisfies intended access for a CREATE, but the subsequent matching REVOKE ALL is still required. Definers require pg_catalog first and pg_temp last, with no duplicate settings, empty paths (including search_path = '', which still searches pg_temp implicitly), user-dependent schemas or explicit temporary namespaces. Quoted identifier case is significant. The live pgTAP gate remains authoritative for schema CREATE privileges. ALTER routine security/configuration/owner mutations, DO/EXECUTE statements, complex routine/ACL/SET identifiers, role-membership grants and grant options require security review. Policy names containing spaces are accepted. Separate plain schema literals such as 'pg_catalog', 'pg_temp' are accepted for search_path; Unicode-escaped SET identifiers fail closed. Record reviewed statements with a real line comment on the statement or its immediately preceding line:
reviewed(dynamic-sql-review) also applies to EXECUTE and routine bodies; it never waives required ACLs. reviewed(quoted-identifier-review) waives only the complex-identifier review diagnostic. Strings, dollar bodies, block comments, unrelated statements, and other files cannot supply markers. Routine bodies must be dollar-quoted: a quoted body (AS '...', AS E'...', AS U&'...' or DO '...'), including one nested inside a dollar body, is refused with function-acl and no marker waives it. Migration, rollback, and schema SQL are covered by CODEOWNERS. Rollbacks also reject public/anonymous grants, TRUNCATE, MAINTAIN and default-grant restoration; their names must match a migration and an approved suffix. Run the sql-grant-guard*.test.js suites with node --test for parser, baseline and shallow-checkout regression coverage. The single historical-debt baseline is retained, including path/rule/block digest, occurrence, complete-file pins, stale-entry checks, and main’s pre-push comparison. The following five real debt items carry a class field. The 20261029012300 and 20261029030500 rollbacks were rewritten to restore only authenticated/service_role access, so their former items left the baseline. Multiple diagnostics for one statement receive the same class. These tags describe historical debt; forward fixes do not alter applied migration bytes or automatically clear entries. Regression evidence is in sql-grant-guard-security.test.js for signature review, escaped GUCs, reviewed statements and false positives, and in the repository suite for synthetic shallow history and the pre-push baseline fence. Source-bound security-review findings live separately in scripts/ci/sql-grant-guard-verified-findings.js. Each entry binds the path, statement line, rule, full-file SHA-256, finding digest and review reason. Changing any bound value leaves the diagnostic enforced; these entries do not expand the historical-debt baseline. A fixed-forward entry also requires its named requiredMigration file in the scanned tree. Missing fixes leave the original finding failing. Replace HARDENING_MIGRATION_FILENAME in that file with the actual migration basename after the hardening PR exists, merge its source, and rerun the guard. Never apply the time-tracking rollback after that hardening migration. Coverage: sql-grant-guard-verified-findings.test.js. The definer guard’s verified records (scripts/ci/security-definer-verified-findings.json) are admitted by scripts/ci/security-definer-admission.js, which CI loads from the base commit. Base records are append-only. A pull request may add a record only for a migration it adds. It may rebind a base record only by changing its sourceDigest, and only for a migration it modifies. Every added or rebound record must match a finding the trusted guard recomputes over the head bytes. A security/definer-record-admission success from tuturuuu-release-bot on the exact head SHA must carry the digest printed by node scripts/ci/security-definer-admission.js digest <base-ledger> <head-ledger>. Additions alone use definer-records sha256:…; any rebind switches to definer-rebinds sha256:… over both lists. Admitted rebinds join the trusted records the guard scans with. Coverage: security-definer-admission.test.js, security-definer-attestation.test.js and security-definer-rebind.test.js.

Changing a migration that security records pin

Several records pin a migration’s bytes. If a change must alter such a file, for example to wrap it in BEGIN/COMMIT or add SET LOCAL timeouts, the path depends on which record pins it.
  1. Definer verified records (scripts/ci/security-definer-verified-findings.json). Use the attested sourceDigest rebind described above. Keep the record identical apart from sourceDigest, regenerate the digest with node scripts/ci/security-definer-admission.js digest <base-ledger> <head-ledger>, and get a release-bot security/definer-record-admission status whose description is definer-rebinds sha256:… on the exact head SHA. Never use a rebind for a migration that has already been applied in production: those bytes are history and must not change. Add a new migration instead.
  2. Reviewed exceptions (scripts/ci/sql-grant-reviewed-exceptions.js) and the SQL grant historical baseline (scripts/ci/sql-grant-guard.baseline.json). The definer job in security-checks.yaml loads the reviewed exceptions from the base commit, and the grant guard validates the head’s baseline against the base copy, which may only shrink. A pull request cannot edit, regenerate or extend either for itself, so there is no in-PR path. A reviewed exception also binds the whole file through fileSha256, so any byte change to a pinned file invalidates it. Such a change needs a one-off, named excusal from the security reviewer for exactly those failing checks, plus the release authority’s admin-bypass GO; any other red check blocks the merge. Do not weaken the shrink-only checks to avoid it.
  3. Zero-line-shift rule. Keep every edit to a pinned file from moving bytes into the statement blocks that follow it. The grant guard splits a file at each ; and takes a finding’s digest over its statement block, which starts immediately after the previous ; and includes everything up to the next one, whitespace and comments included (splitStatements in scripts/ci/sql-grant-guard-lexer.js, block raw and digest in scripts/ci/sql-grant-guard-baseline.js). Bytes placed after a ; on a wrapper line therefore become the first bytes of the next block and change its digest. Write BEGIN;-- comment, not BEGIN; -- comment, and add wrapper lines without adding or removing lines in the middle of the file. Records that carry a line (verified records, reviewed exceptions) also break on any line shift. Check by running the guards locally and confirming no pinned digest or line changed.