skills/ballee/database-migration-manager/SKILL.md
Create production-ready Supabase migrations for Ballee following strict naming conventions, idempotent SQL, RLS patterns, and storage bucket policies; use when user requests schema changes, adding columns, RLS policies, database functions, or storage buckets
npx skillsauth add javeedishaq/ai-workflow-orchestrator database-migration-managerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill when the user requests:
_v2, _v3, _new, _old, _enhanced, _improved, _better, _optimized, _simplified, _modern, _updated, _modified, _refactored, _temp, _tmp, _draft, _test, _backup, _copyYYYYMMDDHHMMSS_descriptive_name.sql
Examples:
20251020143000_add_notes_to_events.sql20251020144500_create_rehearsals_table.sql20251020150000_update_events_rls_policies.sql20251020143000_add_notes_v2.sql (version suffix)migration_notes.sql (wrong format)add_notes.sql (missing timestamp)apps/web/supabase/migrations/
All DDL MUST use idempotent patterns:
-- ✅ CORRECT - Idempotent
ALTER TABLE events ADD COLUMN IF NOT EXISTS notes text;
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status);
DROP POLICY IF EXISTS events_select ON events;
-- ❌ WRONG - Not idempotent
ALTER TABLE events ADD COLUMN notes text;
CREATE INDEX idx_events_status ON events(status);
Always use proper RLS patterns from the codebase:
-- Enable RLS on new tables
ALTER TABLE "public"."table_name" ENABLE ROW LEVEL SECURITY;
-- Revoke default permissions
REVOKE ALL ON public.table_name FROM authenticated, service_role;
-- Grant specific permissions
GRANT select, insert, update, delete ON TABLE public.table_name TO authenticated;
-- Standard RLS policy pattern (drop/create for idempotency)
DROP POLICY IF EXISTS table_name_select ON public.table_name;
CREATE POLICY table_name_select ON public.table_name
FOR SELECT TO authenticated
USING (
public.is_super_admin() OR
account_id = (SELECT auth.uid())
);
Use public.is_super_admin() for admin operations:
-- SELECT: Super admin sees all, others see their own
CREATE POLICY table_select ON public.table_name
FOR SELECT TO authenticated
USING (
public.is_super_admin() OR
account_id = auth.uid()
);
-- INSERT/UPDATE/DELETE: Super admin can do anything, others restricted
CREATE POLICY table_insert ON public.table_name
FOR INSERT TO authenticated
WITH CHECK (
public.is_super_admin() OR
account_id = auth.uid()
);
-- =====================================================================================
-- {Brief description of what this migration does}
-- =====================================================================================
--
-- {Detailed explanation if needed}
-- {Why this change is being made}
-- {Business context}
--
-- =====================================================================================
-- =====================================================================================
-- STEP 1: {First logical group of changes}
-- =====================================================================================
-- Add column (idempotent)
ALTER TABLE public.table_name
ADD COLUMN IF NOT EXISTS column_name data_type;
-- Add NOT NULL constraint (with validation)
ALTER TABLE public.table_name
ALTER COLUMN column_name SET NOT NULL;
-- Add comment
COMMENT ON COLUMN public.table_name.column_name IS
'Description of what this column stores';
-- =====================================================================================
-- STEP 2: {Second logical group of changes}
-- =====================================================================================
-- Create index (idempotent, concurrent for large tables)
CREATE INDEX IF NOT EXISTS idx_table_column
ON public.table_name (column_name);
-- =====================================================================================
-- STEP 3: Update RLS policies
-- =====================================================================================
-- Drop existing policies (for idempotency)
DROP POLICY IF EXISTS table_select ON public.table_name;
DROP POLICY IF EXISTS table_insert ON public.table_name;
-- Create new policies
CREATE POLICY table_select ON public.table_name
FOR SELECT TO authenticated
USING (
public.is_super_admin() OR
account_id = auth.uid()
);
CREATE POLICY table_insert ON public.table_name
FOR INSERT TO authenticated
WITH CHECK (
public.is_super_admin() OR
account_id = auth.uid()
);
-- =====================================================================================
-- Create {bucket_name} storage bucket with RLS
-- =====================================================================================
-- Create the bucket (idempotent - checks if exists)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'my-documents', -- id (used in code)
'my-documents', -- name (display name)
false, -- public (false = requires signed URLs)
10485760, -- file_size_limit (10MB in bytes)
ARRAY['image/jpeg', 'image/png', 'application/pdf'] -- allowed types
)
ON CONFLICT (id) DO NOTHING;
-- =====================================================================================
-- RLS Policy for {bucket_name} bucket
-- =====================================================================================
-- Drop existing policy for idempotency
DROP POLICY IF EXISTS my_documents ON storage.objects;
-- Create RLS policy with super admin bypass
CREATE POLICY my_documents ON storage.objects FOR ALL USING (
bucket_id = 'my-documents'
AND (
-- Super admin can access all files
public.is_super_admin()
OR
-- User owns the file (path starts with their user_id)
(string_to_array(name, '/'))[1]::uuid = auth.uid()
)
)
WITH CHECK (
bucket_id = 'my-documents'
AND (
-- Super admin can upload/modify all files
public.is_super_admin()
OR
-- User can only upload to their own folder
(string_to_array(name, '/'))[1]::uuid = auth.uid()
)
);
Pattern 1: User-Based (path = user_id/...)
CREATE POLICY user_files ON storage.objects FOR ALL USING (
bucket_id = 'user-files'
AND (
public.is_super_admin() OR
(string_to_array(name, '/'))[1]::uuid = auth.uid()
)
)
WITH CHECK (
bucket_id = 'user-files'
AND (
public.is_super_admin() OR
(string_to_array(name, '/'))[1]::uuid = auth.uid()
)
);
Pattern 2: Account-Based (filename = account_uuid.ext)
CREATE POLICY account_files ON storage.objects FOR ALL USING (
bucket_id = 'account-files'
AND (
public.is_super_admin() OR
kit.get_storage_filename_as_uuid(name) = auth.uid() OR
public.has_role_on_account(kit.get_storage_filename_as_uuid(name))
)
)
WITH CHECK (
bucket_id = 'account-files'
AND (
public.is_super_admin() OR
kit.get_storage_filename_as_uuid(name) = auth.uid() OR
public.has_permission(auth.uid(), kit.get_storage_filename_as_uuid(name), 'settings.manage')
)
);
Pattern 3: Entity-Based (linked to database table)
CREATE POLICY entity_documents ON storage.objects FOR ALL USING (
bucket_id = 'entity-documents'
AND (
public.is_super_admin() OR
EXISTS (
SELECT 1 FROM entities e
WHERE e.id = (string_to_array(name, '/'))[1]::uuid
AND e.user_id = auth.uid()
)
)
)
WITH CHECK (
bucket_id = 'entity-documents'
AND (
public.is_super_admin() OR
EXISTS (
SELECT 1 FROM entities e
WHERE e.id = (string_to_array(name, '/'))[1]::uuid
AND e.user_id = auth.uid()
)
)
);
-- =====================================================================================
-- Add super admin bypass to {bucket_name} storage bucket
-- =====================================================================================
-- Drop existing policy
DROP POLICY IF EXISTS {bucket_name} ON storage.objects;
-- Create new policy with is_super_admin() bypass
CREATE POLICY {bucket_name} ON storage.objects FOR ALL USING (
bucket_id = '{bucket_name}'
AND (
public.is_super_admin() OR
-- existing conditions here
)
)
WITH CHECK (
bucket_id = '{bucket_name}'
AND (
public.is_super_admin() OR
-- existing conditions here
)
);
After creating a new bucket, add it to packages/shared/src/storage/storage-url.service.ts:
export const StorageBuckets = {
// ... existing buckets
MY_DOCUMENTS: 'my-documents', // Add new bucket constant
} as const;
When creating functions with elevated privileges:
-- NEVER create security definer without explicit access controls
CREATE OR REPLACE FUNCTION public.function_name(param_name param_type)
RETURNS return_type
LANGUAGE plpgsql
SECURITY DEFINER -- Elevated privileges
SET search_path = '' -- Prevent SQL injection
AS $$
BEGIN
-- CRITICAL: Validate permissions FIRST
IF NOT public.is_super_admin() THEN
RAISE EXCEPTION 'Unauthorized: Super admin access required';
END IF;
-- Additional validation
IF param_name IS NULL OR length(param_name) < 3 THEN
RAISE EXCEPTION 'Invalid parameter: %', param_name;
END IF;
-- Now safe to proceed with elevated privileges
-- ... function logic ...
END;
$$;
-- Grant to authenticated users only
GRANT EXECUTE ON FUNCTION public.function_name(param_type) TO authenticated;
-- Add nullable column (safe)
ALTER TABLE public.table_name
ADD COLUMN IF NOT EXISTS column_name text;
-- Add column with default (safe)
ALTER TABLE public.table_name
ADD COLUMN IF NOT EXISTS is_active boolean DEFAULT false NOT NULL;
-- ❌ UNSAFE: Adding non-null without default
-- ALTER TABLE public.table_name ADD COLUMN required_field text NOT NULL;
CREATE TABLE IF NOT EXISTS public.table_name (
id uuid PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
account_id uuid REFERENCES public.accounts(id) ON DELETE CASCADE NOT NULL,
name text NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
-- Enable RLS
ALTER TABLE "public"."table_name" ENABLE ROW LEVEL SECURITY;
-- Revoke defaults
REVOKE ALL ON public.table_name FROM authenticated, service_role;
-- Grant permissions
GRANT select, insert, update, delete ON TABLE public.table_name TO authenticated;
-- Add RLS policies (see template above)
-- Always drop before creating (idempotency)
DROP POLICY IF EXISTS policy_name ON public.table_name;
CREATE POLICY policy_name ON public.table_name
FOR operation TO authenticated
USING (condition);
YYYYMMDDHHMMSSapps/web/supabase/migrations/pnpm supabase:resetpnpm supabase:typegenBefore creating migration:
YYYYMMDDHHMMSSapps/web/supabase/migrations/IF NOT EXISTS, IF EXISTS, ON CONFLICT DO NOTHING)is_super_admin() where appropriateis_super_admin() bypassStorageBuckets in @kit/shared/storagepnpm supabase:resetpnpm supabase:typegen# Test migration locally (recommended)
pnpm supabase:reset
# Generate types from local database
pnpm supabase:typegen
# Validate migration syntax
pnpm supabase migration list
# Sync with production (check if migration exists on remote)
pnpm db:sync
Trigger: Push to main branch with migration files in apps/web/supabase/migrations/
Workflow: .github/workflows/deploy-migrations.yml
Process:
psql directlysupabase_migrations.schema_migrationsCommands:
# Commit migration
git add apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
git commit -m "feat(db): description of migration"
# Push to main (triggers auto-deployment)
git push origin main
Benefits:
Connection Details:
aws-1-eu-central-1.pooler.supabase.com (session mode pooler)5432 (session mode for complex migrations)supabase db push have limitations)Use when: Manual deployment needed (hotfix, testing)
Prerequisites:
# Get credentials from 1Password
export SUPABASE_PROJECT_ID="csjruhqyqzzqxnfeyiaf" # Production
export SUPABASE_DB_PASSWORD="<from-1password>"
export SUPABASE_ACCESS_TOKEN="<from-1password>"
Commands:
# Deploy to production
pnpm supabase:deploy:prod
# Or manually
supabase link --project-ref $SUPABASE_PROJECT_ID
supabase db push --password $SUPABASE_DB_PASSWORD
Known Issues:
link may fail with "Anon key not found" errorUse when: Testing migrations before production, or hotfixes to staging
Commands:
# Link to staging project
supabase link --project-ref hxpcknyqswetsqmqmeep
# Push migrations to staging
supabase db push
# Or use direct connection
supabase db push --db-url "postgresql://postgres.hxpcknyqswetsqmqmeep:<password>@aws-0-eu-central-1.pooler.supabase.com:5432/postgres"
Verify staging migrations:
# List migrations on staging
supabase migration list --project-ref hxpcknyqswetsqmqmeep
Use when: CLI methods fail, or for quick hotfixes
Always load credentials from .env.local first:
# Load credentials from .env.local
source apps/web/.env.local 2>/dev/null
Session Mode (Port 5432):
Transaction Mode (Port 6543):
Rule of thumb: Start with session mode (5432). If you get "MaxClientsInSessionMode: max clients reached", switch to transaction mode (6543).
Session Mode (Port 5432) - Default:
# Load credentials from .env.local
source apps/web/.env.local 2>/dev/null
PGPASSWORD="$SUPABASE_DB_PASSWORD_PROD" psql \
"postgresql://postgres.csjruhqyqzzqxnfeyiaf@aws-1-eu-central-1.pooler.supabase.com:5432/postgres" \
-f apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
# Record migration in tracking table
PGPASSWORD="$SUPABASE_DB_PASSWORD_PROD" psql \
"postgresql://postgres.csjruhqyqzzqxnfeyiaf@aws-1-eu-central-1.pooler.supabase.com:5432/postgres" \
-c "INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES ('YYYYMMDDHHMMSS', 'description') ON CONFLICT DO NOTHING;"
Transaction Mode (Port 6543) - When Pool Saturated:
# Use port 6543 to bypass connection pool limits
PGPASSWORD="$SUPABASE_DB_PASSWORD_PROD" psql \
"postgresql://postgres.csjruhqyqzzqxnfeyiaf@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-f apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
# Record migration in tracking table
PGPASSWORD="$SUPABASE_DB_PASSWORD_PROD" psql \
"postgresql://postgres.csjruhqyqzzqxnfeyiaf@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-c "INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES ('YYYYMMDDHHMMSS', 'description') ON CONFLICT DO NOTHING;"
Session Mode (Port 5432) - Default:
# Load credentials from .env.local
source apps/web/.env.local 2>/dev/null
PGPASSWORD="$SUPABASE_DB_PASSWORD_STAGING" psql \
"postgresql://postgres.hxpcknyqswetsqmqmeep@aws-1-eu-central-1.pooler.supabase.com:5432/postgres" \
-f apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
# Record migration in tracking table
PGPASSWORD="$SUPABASE_DB_PASSWORD_STAGING" psql \
"postgresql://postgres.hxpcknyqswetsqmqmeep@aws-1-eu-central-1.pooler.supabase.com:5432/postgres" \
-c "INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES ('YYYYMMDDHHMMSS', 'description') ON CONFLICT DO NOTHING;"
Transaction Mode (Port 6543) - When Pool Saturated:
# Use port 6543 to bypass connection pool limits
PGPASSWORD="$SUPABASE_DB_PASSWORD_STAGING" psql \
"postgresql://postgres.hxpcknyqswetsqmqmeep@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-f apps/web/supabase/migrations/YYYYMMDDHHMMSS_description.sql
# Record migration in tracking table
PGPASSWORD="$SUPABASE_DB_PASSWORD_STAGING" psql \
"postgresql://postgres.hxpcknyqswetsqmqmeep@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-c "INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES ('YYYYMMDDHHMMSS', 'description') ON CONFLICT DO NOTHING;"
Benefits:
Connection Pooler Notes:
aws-1-eu-central-1.pooler.supabase.com (reliable for both projects)Use when: All CLI methods fail, or for simple migrations
Production Dashboard:
https://supabase.com/dashboard/project/csjruhqyqzzqxnfeyiaf/sql/new
Staging Dashboard:
https://supabase.com/dashboard/project/hxpcknyqswetsqmqmeep/sql/new
Process:
INSERT INTO supabase_migrations.schema_migrations (version, name, statements)
VALUES ('YYYYMMDDHHMMSS', 'migration_name', ARRAY['BEGIN', 'COMMIT'])
ON CONFLICT (version) DO NOTHING;
Limitations:
After deployment (automatic or manual):
# Check if migration is applied on production
pnpm db:sync
# This will:
# 1. Fetch remote migration list
# 2. Compare with local migrations
# 3. Show any mismatches
Production database query (via GitHub Actions logs):
SELECT version, name
FROM supabase_migrations.schema_migrations
ORDER BY version DESC
LIMIT 10;
Location: .claude/skills/database-migration-manager/scripts/
cd apps/web
../../.claude/skills/database-migration-manager/scripts/check-migration-status.sh
Output:
Features:
# Check production
../../.claude/skills/database-migration-manager/scripts/find-missing-migrations.sh production
# Check staging
../../.claude/skills/database-migration-manager/scripts/find-missing-migrations.sh staging
Output:
Use cases:
See: .claude/skills/database-migration-manager/scripts/README.md for detailed documentation
Symptoms:
Solutions:
aws-0-eu-central-1.pooler.supabase.combrew upgrade supabase/tap/supabaseSymptom: FATAL: MaxClientsInSessionMode: max clients reached - in Session mode max clients are limited to pool_size
Cause: Too many concurrent connections to the session mode pooler (port 5432). The connection pool is saturated.
Solutions (in order of preference):
Switch to Transaction Mode (Port 6543) ✅ RECOMMENDED
# Load credentials from .env.local
source apps/web/.env.local 2>/dev/null
# Production - use port 6543 instead of 5432
PGPASSWORD="$SUPABASE_DB_PASSWORD_PROD" psql \
"postgresql://postgres.csjruhqyqzzqxnfeyiaf@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-c "SELECT COUNT(*) FROM supabase_migrations.schema_migrations;"
# Staging - use port 6543 instead of 5432
PGPASSWORD="$SUPABASE_DB_PASSWORD_STAGING" psql \
"postgresql://postgres.hxpcknyqswetsqmqmeep@aws-1-eu-central-1.pooler.supabase.com:6543/postgres" \
-c "SELECT COUNT(*) FROM supabase_migrations.schema_migrations;"
Wait for Pool to Clear (15-30 minutes)
Increase Pool Size (requires dashboard access)
Close Idle Connections (database admin only)
-- View active connections
SELECT pid, usename, application_name, state, query_start
FROM pg_stat_activity
WHERE datname = 'postgres'
ORDER BY query_start DESC;
-- Terminate idle connections (use with caution)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'postgres'
AND state = 'idle'
AND query_start < NOW() - INTERVAL '30 minutes';
Prevention:
connection_limit=1 in serverless connection stringsReferences:
Symptom: Migration fails because it's already applied on production
Solution:
# Mark migration as applied without executing
supabase migration repair --status applied YYYYMMDDHHMMSS
Symptom: TypeScript types don't match production schema
Solution:
# Regenerate types from production (requires link)
pnpm supabase:typegen:linked
# Or trigger GitHub Actions workflow manually
# Actions → Deploy Database Migrations → Run workflow → Force typegen: true
After migration is deployed:
pnpm supabase:typegen:linkedpnpm buildpnpm typecheckIF NOT EXISTS)apps/web/supabase/migrations/)SECURITY DEFINER functions without validationis_super_admin() for admin operationsis_super_admin() bypassStorageBuckets constantgetPublicUrl() for private buckets (use signed URLs)apps/web/supabase/migrations/apps/web/supabase/schemas/apps/web/supabase/CLAUDE.mddocs/31-PROJECT_CONVENTIONS.mdtools
# Test Patterns Testing patterns for reliable, maintainable, and fast tests. > **Template Usage:** Customize for your test framework (Vitest, Jest, Playwright, etc.) and assertion library. ## Test Structure ```typescript // user.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { userService } from '@/services/user.service'; import { createTestUser, cleanupTestData } from '@/tests/helpers'; describe('UserService', () => { let testUserId: string; befor
tools
# State Management Patterns Client-side state management patterns for modern applications. > **Template Usage:** Customize for your state library (React Query, Zustand, Jotai, Redux, etc.). ## State Categories | Type | Description | Solution | |------|-------------|----------| | **Server State** | Data from API/database | React Query, SWR | | **Client State** | UI state, user preferences | Zustand, Jotai, useState | | **Form State** | Form inputs, validation | React Hook Form, Formik | | **U
development
# Service Patterns Service layer patterns for clean architecture with proper error handling, logging, and type safety. > **Template Usage:** Customize for your ORM (Prisma, Drizzle, TypeORM, etc.) and logging solution. ## Result Type Pattern Never throw exceptions from services. Always return a Result type. ```typescript // lib/result.ts export type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; export function ok<T>(data: T): Result<T, never> { r
testing
# Row-Level Security Patterns Database security patterns for multi-tenant and user-scoped data. > **Template Usage:** Customize for your database (PostgreSQL, Supabase, etc.) and auth system. ## RLS Fundamentals ### Enable RLS on Tables ```sql -- Enable RLS (required before policies take effect) ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; -- Force RLS for table owners too (recommended) ALTER TABLE