-- Create users tableCREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, email TEXT NOT NULL UNIQUE, full_name TEXT, avatar_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());-- Create index for faster lookupsCREATE INDEX idx_users_email ON public.users(email);-- Create trigger for updated_atCREATE TRIGGER update_users_updated_at BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Drop triggerDROP TRIGGER IF EXISTS update_users_updated_at ON public.users;-- Drop indexDROP INDEX IF EXISTS idx_users_email;-- Drop tableDROP TABLE IF EXISTS public.users;
4
Test the migration
# Apply migrationmake db-migrate-up# Verify it workedmake db-migrate-status# Test rollbackmake db-migrate-down# Re-applymake db-migrate-up
# Create new migrationmake db-create-migration name=migration_name# Apply all pending migrationsmake db-migrate-up# Rollback last migrationmake db-migrate-down# Show migration statusmake db-migrate-status# Force specific version (use with caution)make db-migrate-force version=20260103120000# Seed database with initial datamake db-seed
# Create new migrationmake migrate-create name=migration_name# Apply all pending migrationsmake migrate-up# Rollback last migrationmake migrate-down# Rollback ALL migrations (destructive!)make migrate-down-all# Show current migration versionmake migrate-version# Show migration statusmake migrate-status# Force migration to specific versionmake migrate-force version=20260103120000
-- Create organizations tableCREATE TABLE IF NOT EXISTS public.organizations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, avatar_url TEXT, plan_type TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());-- Create indexesCREATE INDEX idx_organizations_slug ON public.organizations(slug);CREATE INDEX idx_organizations_plan_type ON public.organizations(plan_type);-- Enable RLSALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;-- Add triggerCREATE TRIGGER update_organizations_updated_at BEFORE UPDATE ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Add is_verified column to usersALTER TABLE public.usersADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT false;-- Create index for common queriesCREATE INDEX IF NOT EXISTS idx_users_is_verified ON public.users(is_verified);
-- Policy: Users can view their own dataCREATE POLICY "Users can view own data" ON public.users FOR SELECT USING (auth.uid() = id);-- Policy: Users can update their own dataCREATE POLICY "Users can update own data" ON public.users FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);-- Policy: Service role can do anythingCREATE POLICY "Service role has full access" ON public.users FOR ALL USING (auth.role() = 'service_role');
-- Create reusable function for updated_atCREATE OR REPLACE FUNCTION public.update_updated_at_column()RETURNS TRIGGERLANGUAGE plpgsqlSECURITY DEFINER SET search_path = ''AS $$BEGIN NEW.updated_at = NOW(); RETURN NEW;END;$$;-- Create function to handle new user creationCREATE OR REPLACE FUNCTION public.handle_new_user()RETURNS TRIGGERLANGUAGE plpgsqlSECURITY DEFINER SET search_path = ''AS $$BEGIN INSERT INTO public.users (id, email, full_name, avatar_url) VALUES ( NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'avatar_url' ); RETURN NEW;END;$$;-- Create trigger on auth.usersCREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Create enum type for organization plansDO $$ BEGIN CREATE TYPE public.plan_type AS ENUM ('free', 'personal', 'team', 'enterprise');EXCEPTION WHEN duplicate_object THEN null;END $$;-- Add column using the enumALTER TABLE public.organizationsADD COLUMN IF NOT EXISTS plan public.plan_type NOT NULL DEFAULT 'free';
Always use conditional statements to make migrations idempotent:
CREATE TABLE IF NOT EXISTS ...ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...DROP TABLE IF EXISTS ...CREATE INDEX IF NOT EXISTS ...
2
Add indexes for foreign keys
Always index columns used in JOINs and WHERE clauses:
CREATE INDEX idx_organization_members_user_idON organization_members(user_id);CREATE INDEX idx_organization_members_org_idON organization_members(organization_id);
3
Use transactions when needed
For complex migrations, wrap in a transaction:
BEGIN;-- Multiple related changesALTER TABLE users ADD COLUMN ...;CREATE INDEX ...;UPDATE users SET ...;COMMIT;
4
Set appropriate defaults
When adding columns, provide defaults for existing rows:
ALTER TABLE users ADD COLUMN is_verified BOOLEAN NOT NULL DEFAULT false;
5
Test rollbacks
Always test your down migration:
make db-migrate-up # Applymake db-migrate-down # Rollbackmake db-migrate-up # Re-apply
If the migration partially applied, manually fix the database state:
# Connect to databasepsql $DATABASE_URL# Inspect what was applied\dt # List tables\d table_name # Describe table# Manually fix or rollback changes
3
Force version
Only use after manually fixing the database state!
# Force migration to specific versionmake db-migrate-force version=20260103120000# Or mark as clean at current versionmake db-migrate-force version=<current_version>
Always enable RLS on tables and create appropriate policies:
-- Enable RLSALTER TABLE public.users ENABLE ROW LEVEL SECURITY;-- Create policiesCREATE POLICY "Users can view own data" ON public.users FOR SELECT USING (auth.uid() = id);
-- Create avatars storage bucketINSERT INTO storage.buckets (id, name, public)VALUES ('avatars', 'avatars', true)ON CONFLICT (id) DO NOTHING;-- Set up RLS for bucketCREATE POLICY "Avatar images are publicly accessible" ON storage.objects FOR SELECT USING (bucket_id = 'avatars');