npx claudepluginhub mickaelmamani/saas-toolkit --plugin saas-toolkitWant just this skill?
Add to a custom plugin, then install with one command.
Supabase database migrations. Use when creating tables, modifying schema, adding RLS policies, or writing seed data.
This skill is limited to using the following tools:
/db-migration — Supabase Migrations
Create and manage Supabase database migrations with proper patterns for SaaS applications.
Migration Workflow
All migrations target Supabase Cloud via MCP. Use mcp__supabase to run SQL directly on the cloud project. Never use supabase start or supabase db push.
1. Write the migration SQL
Write idempotent SQL that can be re-run safely. For local tracking, keep files in supabase/migrations/ but apply them via MCP.
2. Write idempotent SQL
Always write migrations that can be re-run safely:
-- Create table (idempotent)
CREATE TABLE IF NOT EXISTS public.my_table (...);
-- Add column (idempotent)
ALTER TABLE public.my_table ADD COLUMN IF NOT EXISTS new_column text;
-- Create index (idempotent)
CREATE INDEX IF NOT EXISTS idx_my_table_column ON public.my_table(column);
-- Enable RLS (idempotent)
ALTER TABLE public.my_table ENABLE ROW LEVEL SECURITY;
-- Create policy (drop + create for idempotency)
DROP POLICY IF EXISTS "policy_name" ON public.my_table;
CREATE POLICY "policy_name" ON public.my_table ...;
3. Apply migration via MCP
Run the SQL directly on the cloud project using mcp__supabase SQL execution tools. Do NOT use supabase db push or local development.
4. Generate types
npx supabase gen types typescript --project-id <project-id> > lib/database.types.ts
New Table Checklist
Every new table must have:
-
id uuid DEFAULT gen_random_uuid() PRIMARY KEY -
created_at timestamptz DEFAULT now() NOT NULL -
updated_at timestamptz DEFAULT now() NOT NULL - RLS enabled:
ALTER TABLE public.xxx ENABLE ROW LEVEL SECURITY; - RLS policies for SELECT, INSERT, UPDATE, DELETE
- Indexes on foreign keys
- Indexes on frequently queried columns
-
updated_attrigger
Updated_at trigger
-- Create the trigger function (once per project)
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to table
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON public.my_table
FOR EACH ROW
EXECUTE FUNCTION public.handle_updated_at();
RLS Policy Templates
Owner access (user-scoped data)
CREATE POLICY "Users can view own data" ON public.my_table
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can create own data" ON public.my_table
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own data" ON public.my_table
FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own data" ON public.my_table
FOR DELETE USING (auth.uid() = user_id);
Org member access (multi-tenant)
CREATE POLICY "Org members can view data" ON public.my_table
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.org_members
WHERE org_members.org_id = my_table.org_id
AND org_members.user_id = auth.uid()
)
);
Admin-only write access
CREATE POLICY "Only admins can modify" ON public.my_table
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.org_members
WHERE org_members.org_id = my_table.org_id
AND org_members.user_id = auth.uid()
AND org_members.role = 'admin'
)
);
Public read access
CREATE POLICY "Anyone can read" ON public.my_table
FOR SELECT USING (true);
SaaS Schema Patterns
Profiles (extends auth.users)
CREATE TABLE public.profiles (
id uuid REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
full_name text,
avatar_url text,
stripe_customer_id text UNIQUE,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE USING (auth.uid() = id);
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url)
VALUES (NEW.id, NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'avatar_url');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
Organizations
CREATE TABLE public.organizations (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
slug text UNIQUE NOT NULL,
stripe_customer_id text UNIQUE,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE public.org_members (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
org_id uuid REFERENCES public.organizations(id) ON DELETE CASCADE NOT NULL,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
created_at timestamptz DEFAULT now() NOT NULL,
UNIQUE(org_id, user_id)
);
CREATE INDEX idx_org_members_user_id ON public.org_members(user_id);
CREATE INDEX idx_org_members_org_id ON public.org_members(org_id);
Subscriptions (manual — only if NOT using stripe-sync-engine)
Note: If using @supabase/stripe-sync-engine (recommended), the stripe.* schema manages subscription data automatically. Only create this manual table if you have a specific reason not to use the sync engine.
CREATE TABLE public.subscriptions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
stripe_customer_id text NOT NULL,
stripe_subscription_id text UNIQUE,
stripe_price_id text,
status text NOT NULL DEFAULT 'inactive',
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean DEFAULT false,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_customer_id ON public.subscriptions(stripe_customer_id);
Audit Logs
CREATE TABLE public.audit_logs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL,
action text NOT NULL,
entity_type text NOT NULL,
entity_id uuid,
metadata jsonb DEFAULT '{}',
ip_address inet,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE INDEX idx_audit_logs_user_id ON public.audit_logs(user_id);
CREATE INDEX idx_audit_logs_org_id ON public.audit_logs(org_id);
CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at);
Seed Data
For local development, add seed data in supabase/seed.sql:
-- Only runs on local reset, not on production
INSERT INTO public.organizations (id, name, slug) VALUES
('00000000-0000-0000-0000-000000000001', 'Acme Corp', 'acme');
Rules
- Always write idempotent migrations
- Always enable RLS on new tables
- Always add indexes on foreign keys
- Always include
created_atandupdated_at - Always generate types after migration changes
- Use
timestamptznottimestamp - Use
textnotvarchar - Use
uuidprimary keys - All migrations target Supabase Cloud via
mcp__supabase— never local stripe.*schema is managed by stripe-sync-engine — only add RLS policies tostripe.*tables, never create/modify the tables themselves