Database Migrations
Writing, testing, and applying database migrations -- naming conventions, local testing, type regeneration, and common gotchas.
Database Migrations
All schema changes go through Supabase migrations stored in supabase/migrations/. The platform uses a baseline migration plus forward-only migrations.
Migration file structure
supabase/migrations/
00000000000000_baseline.sql # Full initial schema
20260301_001_add_widget.sql # Forward migration
20260305_002_add_index.sql # Another migrationNaming convention
YYYYMMDD_NNN_descriptive_name.sqlYYYYMMDD-- date the migration was createdNNN-- sequence number for that day (001, 002, etc.)descriptive_name-- what the migration does, in snake_case
Examples:
20260315_001_add_comments_table.sql20260315_002_add_entity_type_slug_index.sql20260320_001_create_workflow_runs.sql
Writing a migration
Basic table creation
-- Create the new table
CREATE TABLE IF NOT EXISTS public.widgets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES public.tenants(id),
name text NOT NULL,
config jsonb DEFAULT '{}'::jsonb,
created_by uuid REFERENCES auth.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Index the tenant_id (required for RLS performance)
CREATE INDEX IF NOT EXISTS idx_widgets_tenant_id ON public.widgets(tenant_id);
-- Enable RLS
ALTER TABLE public.widgets ENABLE ROW LEVEL SECURITY;
-- RLS policies (use (SELECT auth.uid()) pattern)
CREATE POLICY "Tenant members can read widgets"
ON public.widgets FOR SELECT
USING (
tenant_id IN (
SELECT tenant_id FROM public.user_tenants
WHERE user_id = (SELECT auth.uid())
)
);
CREATE POLICY "Editors can manage widgets"
ON public.widgets FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.user_permissions
WHERE user_id = (SELECT auth.uid())
AND tenant_id = widgets.tenant_id
AND permission = 'entities.team.create'
)
);
-- Enable audit tracking (optional)
SELECT enable_audit_tracking('widgets');
-- Down migration (comment for reference):
-- DROP POLICY IF EXISTS "Editors can manage widgets" ON public.widgets;
-- DROP POLICY IF EXISTS "Tenant members can read widgets" ON public.widgets;
-- DROP TABLE IF EXISTS public.widgets;Adding columns
ALTER TABLE public.entities
ADD COLUMN IF NOT EXISTS priority integer DEFAULT 0;
-- Down: ALTER TABLE public.entities DROP COLUMN IF EXISTS priority;Adding indexes
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_entities_priority
ON public.entities(tenant_id, priority);Use CONCURRENTLY for indexes on large tables to avoid locking.
Testing locally
Always test migrations locally before pushing to production:
# Reset local database and re-run all migrations from scratch
pnpm db:reset
# Verify the migration applied correctly
pnpm db:types # Should regenerate types without errors
pnpm typecheck # Should pass with the new types
pnpm test # Should pass
pnpm build # Should passpnpm db:reset drops and recreates the local database, runs all migrations in order, and re-seeds data. This is the definitive test that your migration is correct and compatible with all previous migrations.
Applying to production
# Push pending migrations to the remote Supabase project
pnpm db:pushThis connects to the remote project (identified by NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY) and applies any migrations that haven't been run yet.
After applying:
# Regenerate TypeScript types from the remote schema
pnpm db:typesThis runs:
npx supabase gen types typescript --project-id mhfzqccnyqxyteedsrdi > lib/supabase/database.types.tsCommit the regenerated types file. The build will fail if types are out of sync with the database schema.
Generating a migration from changes
If you've made schema changes directly in the Supabase dashboard (not recommended for production, but useful for prototyping):
pnpm db:diffThis compares the local schema against the remote and generates a migration SQL file. Review the output carefully before committing.
Key gotchas
entity_types.id has no default
The entity_types table does not have a default UUID generator on the id column. You must always provide an explicit ID:
const id = crypto.randomUUID();
await supabase.from("entity_types").insert({ id, slug: "...", name: "..." });This catches accidental inserts without an ID at the application level rather than silently generating one.
auth.uid() in RLS policies
Always wrap auth.uid() in a subquery:
-- Good: evaluated once, cached
WHERE user_id = (SELECT auth.uid())
-- Bad: re-evaluated per row
WHERE user_id = auth.uid()user_tenants, not tenant_members
The table is called user_tenants. There is no tenant_members table. Using the wrong name will cause silent failures in RLS policies.
messages, not chat_messages
Chat messages are stored in the messages table, not chat_messages. The messages table has a parts JSONB column for AI SDK v6 rich content.
Tenant-scoped tables
Every new table that holds user data must:
- Have a
tenant_idcolumn with a foreign key totenants(id) - Have an index on
tenant_id - Have RLS enabled
- Have RLS policies that check tenant membership via
user_tenants
Reversibility
Include down migration SQL as comments at the bottom of every migration file. While there is no automated rollback mechanism, these comments document how to manually reverse the change if needed.
Dumping the schema
To create a fresh baseline dump of the current schema:
pnpm db:dumpThis generates supabase/baseline/schema.sql from the current database state. This is useful for documentation but is not used in the migration pipeline.
Migration checklist
Before committing a migration:
- File follows naming convention:
YYYYMMDD_NNN_descriptive_name.sql -
pnpm db:resetpasses locally (all migrations apply cleanly) - New tables have
tenant_id, indexes, RLS enabled, and RLS policies - RLS policies use
(SELECT auth.uid())pattern - Down migration documented in comments
-
pnpm db:typesregenerates types successfully -
pnpm typecheckpasses with new types -
pnpm buildpasses