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.--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 restrictiveaccount_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.
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-
Make changes in Supabase Studio:
-
Generate migration:
-
Review generated file:
Method 2: Manual SQL Migration
Best for: Complex changes, custom functions, data migrations-
Create blank migration:
-
Edit migration file:
Migration File Structure
Naming Convention
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 ofstandard_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 isscripts/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.
-
Public or anon grants. Any
GRANT(functionEXECUTE, table or view privileges, schema usage, role membership) orALTER DEFAULT PRIVILEGES ... GRANTwhose grantees includepublicoranon, inapps/database/supabase/migrations/*.sqlorapps/database/rollbacks/*.sql. Rollbacks must not restore an open grant that the forward migration closed; grant toauthenticatedorservice_roleinstead. -
Unrevoked
SECURITY DEFINERfunctions (migrations only). EveryCREATE [OR REPLACE] FUNCTION ... SECURITY DEFINERneeds a revoke for the same name and argument types from bothpublicandanonafter 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 than20261029030400(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 topublicoranon(anallow-anon-grantmarker 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: -
Inherited ACL (frozen allowlist).
CREATE OR REPLACEkeeps the ACL of the existing function, so six definers already onmainwhose earlier revoke predates their latest replacement pass by inheritance. The allowlist inscripts/ci/sql-grant-frozen-definers.jsis 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 priorREVOKEfrompublicandanon(and fromauthenticated, which current policy requires for every entry excepttransfer_workspace_ownership, which keepsauthenticatedEXECUTE by design) and nothing after it drops, renames or moves the function (a signature-freeDROP,ALTER ... RENAME TOorSET SCHEMAorGRANTnames every overload of that name), recreates it with a plainCREATE, or grants it topublic/anon(or toauthenticatedwhere that is required closed). The guard prints the migration and line that evidence each inherited pass.
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/anongrants and everySECURITY DEFINERfunction revoked frompublic, 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:- Create migration file
- Test locally with
bun sb:reset - Run
bun sb:typegen - Commit files
- Instruct user to run
bun sb:push
- 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
-
Use descriptive names
-
Add indexes for foreign keys
-
Enable RLS on all tables
-
Add comments for clarity
-
Test migrations locally first
-
Materialize replacement-table rows before rewiring child foreign keys
❌ DON’T
-
Don’t skip RLS
-
Don’t use DROP without backup
-
Don’t modify old migrations
-
Don’t deploy without testing
Related Documentation
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) andALTER DEFAULT PRIVILEGESgrants toPUBLIC,anon, orauthenticatedalways fail, even with a marker. - A grant to
authenticatedrequires its own statement-adjacent review marker naming the exact canonical signature. - A grant to
anonorPUBLICadditionally requiresanon-requiredin that marker. The legacyallow-anon-grantmarker cannot satisfy this routine fence.
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 inBEGIN/COMMIT or add SET LOCAL timeouts, the path
depends on which record pins it.
- Definer verified records (
scripts/ci/security-definer-verified-findings.json). Use the attestedsourceDigestrebind described above. Keep the record identical apart fromsourceDigest, regenerate the digest withnode scripts/ci/security-definer-admission.js digest <base-ledger> <head-ledger>, and get a release-botsecurity/definer-record-admissionstatus whose description isdefiner-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. - 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 insecurity-checks.yamlloads 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 throughfileSha256, 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. - 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 (splitStatementsinscripts/ci/sql-grant-guard-lexer.js, blockrawanddigestinscripts/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. WriteBEGIN;-- comment, notBEGIN; -- comment, and add wrapper lines without adding or removing lines in the middle of the file. Records that carry aline(verified records, reviewed exceptions) also break on any line shift. Check by running the guards locally and confirming no pinned digest or line changed.