Skip to main content

Overview

All database changes go through migrations. Never modify the database directly in production.

Step 1: Plan Your Table

Before writing SQL, document:
  1. Table name - Use domain prefix (publisher_, agency_, etc.)
  2. Columns - Name, type, nullable, defaults
  3. Relationships - Foreign keys to other tables
  4. RLS policies - Who can read/write
  5. Indexes - Columns that need fast lookups

Step 2: Create Migration File

Create a new migration file:
# Create migration with timestamp
touch supabase/migrations/$(date +%Y%m%d%H%M%S)_add_table_name.sql
Or use Supabase CLI:
supabase migration new add_table_name

Step 3: Write Migration SQL

Basic Table Structure

-- Migration: Add [table_name] table
-- Created: 2025-12-18

-- Create table
CREATE TABLE IF NOT EXISTS table_name (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  -- Add your columns here
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Add comments
COMMENT ON TABLE table_name IS 'Description of what this table stores';
COMMENT ON COLUMN table_name.column_name IS 'Description of this column';

-- Create updated_at trigger
CREATE TRIGGER set_updated_at
  BEFORE UPDATE ON table_name
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

-- Enable RLS
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;

-- Create RLS policies
CREATE POLICY "Users can view own data"
  ON table_name FOR SELECT
  USING (user_id = auth.uid());

CREATE POLICY "Users can insert own data"
  ON table_name FOR INSERT
  WITH CHECK (user_id = auth.uid());

-- Create indexes
CREATE INDEX idx_table_name_user_id ON table_name(user_id);

With Foreign Keys

CREATE TABLE publisher_posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  vertical_id UUID NOT NULL REFERENCES publisher_verticals(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  content TEXT,
  status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index on foreign key for JOIN performance
CREATE INDEX idx_publisher_posts_vertical_id ON publisher_posts(vertical_id);

Step 4: Apply Migration

To Production

# Apply to Supabase production
supabase db push --project-ref ymdccxqzmhxgbjbppywf

Via Supabase Dashboard

  1. Go to SQL Editor in Supabase dashboard
  2. Paste migration SQL
  3. Run query
  4. Verify in Table Editor

Step 5: Update SCHEMA.md

Add documentation for the new table:
### table_name
Description of what this table stores.

| Column | Type | Description |
|--------|------|-------------|
| id | uuid | Primary key |
| ... | ... | ... |

Step 6: Regenerate Types

pnpm generate:types
This updates TypeScript types in packages/db/types.ts.

Step 7: Update Docs

Add the table to /database/tables documentation if significant.

Best Practices

Naming Conventions

ElementConventionExample
Tablessnake_case with domain prefixpublisher_posts
Columnssnake_casecreated_at
Foreign keys{table}_idvertical_id
Indexesidx_{table}_{column}idx_posts_vertical_id

Standard Columns

Every table should have:
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()

RLS First

Always enable RLS immediately:
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
Tables without RLS are publicly accessible!

Check Constraints

Use CHECK constraints for enums:
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived'))

Migration Template

-- Migration: [Description]
-- Created: [Date]
-- Author: [Name]

-- ============================================
-- TABLE CREATION
-- ============================================

CREATE TABLE IF NOT EXISTS domain_table_name (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  
  -- Foreign keys
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  
  -- Data columns
  name TEXT NOT NULL,
  description TEXT,
  status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'archived')),
  
  -- Timestamps
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- ============================================
-- COMMENTS
-- ============================================

COMMENT ON TABLE domain_table_name IS 'What this table stores';

-- ============================================
-- TRIGGERS
-- ============================================

CREATE TRIGGER set_updated_at
  BEFORE UPDATE ON domain_table_name
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

-- ============================================
-- ROW LEVEL SECURITY
-- ============================================

ALTER TABLE domain_table_name ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own data"
  ON domain_table_name FOR SELECT
  USING (user_id = auth.uid());

CREATE POLICY "Users can manage own data"
  ON domain_table_name FOR ALL
  USING (user_id = auth.uid());

-- ============================================
-- INDEXES
-- ============================================

CREATE INDEX idx_domain_table_name_user_id 
  ON domain_table_name(user_id);

Rollback

If something goes wrong, create a rollback migration:
-- Rollback: [Description]
DROP TABLE IF EXISTS domain_table_name CASCADE;
Rollbacks in production can cause data loss. Always backup first.

Database Relationships

See how tables connect