skills/development/database-architect/SKILL.md
Use this skill when the user says 'design database', 'create tables', 'database schema', 'add table', 'database architect', 'ERD', 'data model', or is designing Supabase/Postgres table structures, relationships, RLS policies, or migrations. Do NOT use for schema migration of existing tables (use migration-planner) or code refactoring.
npx skillsauth add cwinvestments/memstack memstack-development-database-architectInstall 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.
Produces production-ready Supabase/Postgres schemas with proper naming, relationships, RLS policies, indexes, and migration SQL.
When this skill activates, output:
🗄️ Database Architect — Analyzing requirements and designing schema...
Then execute the protocol below.
| Context | Status | |---------|--------| | User says "design database" or "create tables" or "add table" | ACTIVE | | User says "database schema" or "ERD" or "data model" | ACTIVE | | Building a new feature that needs persistent storage | ACTIVE | | Writing migration SQL for Supabase | ACTIVE | | Querying existing data or debugging SQL | DORMANT | | Discussing database concepts generally | DORMANT |
| Trap | Reality Check |
|------|---------------|
| "I'll add RLS later" | RLS is the auth layer in Supabase. No RLS = public data. Design it with the schema. |
| "JSONB for everything" | JSONB skips type safety, indexing, and foreign keys. Use it for truly flexible data, not as a schema-avoidance crutch. |
| "I'll index when it's slow" | Known query patterns should get indexes from day one. Adding indexes to production tables locks writes. |
| "Free text for status" | status TEXT invites typos and inconsistency. Use an enum: CREATE TYPE order_status AS ENUM (...). |
| "One big table is simpler" | Wide tables with nullable columns signal missing normalization. Split into focused tables with proper FKs. |
| "snake_case is just preference" | Supabase auto-generates TypeScript types from your schema. Inconsistent naming produces inconsistent types. Pick snake_case and enforce it. |
Before designing, understand what the data needs to represent:
If the user hasn't specified, ask:
What entities and relationships does this feature need? Who should be able to read/write each table?
Apply naming conventions strictly:
| Convention | Rule | Example |
|------------|------|---------|
| Table names | snake_case, plural | order_items, user_profiles |
| Column names | snake_case | first_name, created_at |
| Foreign keys | {singular_table}_id | user_id, organization_id |
| Primary keys | id (uuid) | id UUID DEFAULT gen_random_uuid() |
| Junction tables | {table1}_{table2} alphabetical | organizations_users |
| Enums | {table}_{column} or descriptive | order_status, subscription_tier |
| Indexes | idx_{table}_{columns} | idx_orders_org_id_created_at |
Standard columns on every table:
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
Auto-update updated_at:
-- Create this function once per database
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to each table
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON table_name
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
Design foreign keys with explicit constraint naming and cascading behavior:
-- One-to-many: organization has many projects
ALTER TABLE projects
ADD CONSTRAINT fk_projects_organization
FOREIGN KEY (organization_id) REFERENCES organizations(id)
ON DELETE CASCADE;
-- Many-to-many: users belong to many organizations
CREATE TABLE organizations_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (organization_id, user_id)
);
Cascade rules:
| Relationship | ON DELETE | Rationale | |-------------|-----------|-----------| | Org → Projects | CASCADE | Deleting org removes its projects | | User → Profile | CASCADE | Deleting user removes profile | | Order → Items | CASCADE | Deleting order removes line items | | User → Orders | RESTRICT | Don't delete user if they have orders | | Category → Products | SET NULL | Keep products, clear category |
Every table needs RLS enabled with explicit policies. No exceptions.
-- Enable RLS (do this for EVERY table)
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
Common RLS patterns:
-- Pattern 1: User owns their own rows
CREATE POLICY "Users read own data"
ON user_profiles FOR SELECT
USING (auth.uid() = user_id);
-- Pattern 2: Org-scoped access (most common in multi-tenant apps)
CREATE POLICY "Org members read projects"
ON projects FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM organizations_users
WHERE user_id = auth.uid()
)
);
-- Pattern 3: Role-based write access
CREATE POLICY "Org admins write projects"
ON projects FOR INSERT
WITH CHECK (
organization_id IN (
SELECT organization_id FROM organizations_users
WHERE user_id = auth.uid()
AND role IN ('owner', 'admin')
)
);
-- Pattern 4: Public read, authenticated write
CREATE POLICY "Public read"
ON articles FOR SELECT
USING (published = true);
CREATE POLICY "Authors write own articles"
ON articles FOR UPDATE
USING (author_id = auth.uid());
-- Pattern 5: Service role bypass (for server-side operations)
-- Use supabase.rpc() or service_role key — RLS is bypassed with service role
RLS checklist per table:
| Operation | Policy | Who Can? | |-----------|--------|----------| | SELECT | Read own / org-scoped / public | Define per table | | INSERT | Org members / authenticated / admin-only | Define per table | | UPDATE | Owner / admin / role-based | Define per table | | DELETE | Owner / admin / restrict | Define per table |
Design indexes based on known query patterns:
-- Foreign keys used in JOINs (always index these)
CREATE INDEX idx_projects_organization_id ON projects(organization_id);
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Composite index for common filter + sort
CREATE INDEX idx_orders_org_created ON orders(organization_id, created_at DESC);
-- Partial index for active records (skip soft-deleted)
CREATE INDEX idx_projects_active ON projects(organization_id)
WHERE deleted_at IS NULL;
-- GIN index for JSONB columns (if used)
CREATE INDEX idx_settings_metadata ON settings USING GIN(metadata);
-- Full-text search index
CREATE INDEX idx_articles_search ON articles USING GIN(
to_tsvector('english', title || ' ' || content)
);
Index decision matrix:
| Query Pattern | Index Type | Example |
|--------------|-----------|---------|
| WHERE fk_id = ? | B-tree (single column) | idx_orders_user_id |
| WHERE fk_id = ? ORDER BY created_at | B-tree (composite) | idx_orders_user_created |
| WHERE status = 'active' | Partial index | WHERE status = 'active' |
| WHERE metadata @> '{}' | GIN | JSONB containment queries |
| WHERE to_tsvector(...) @@ ? | GIN | Full-text search |
| WHERE col LIKE 'prefix%' | B-tree (text_pattern_ops) | Prefix search |
Don't index:
JSONB is powerful but should be used intentionally:
✅ Good use cases:
metadata on polymorphic tables)❌ Bad use cases:
-- Good: flexible metadata alongside typed columns
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
category_id UUID REFERENCES categories(id),
metadata JSONB DEFAULT '{}'::jsonb, -- flexible extras
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Create enum types for finite status sets
CREATE TYPE order_status AS ENUM (
'draft', 'pending', 'confirmed', 'processing',
'shipped', 'delivered', 'cancelled', 'refunded'
);
CREATE TYPE subscription_tier AS ENUM (
'free', 'starter', 'professional', 'enterprise'
);
-- Use in table definition
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status order_status NOT NULL DEFAULT 'draft',
-- ...
);
Enum vs CHECK constraint vs lookup table:
| Approach | When to Use | Trade-off |
|----------|------------|-----------|
| CREATE TYPE ... AS ENUM | Fixed set, rarely changes | Adding values requires ALTER TYPE |
| CHECK (col IN (...)) | Very short lists (2-3 values) | Inline, no separate type |
| Lookup table | Values change often or have metadata | More flexible, extra JOIN |
Adding values to an existing enum:
ALTER TYPE order_status ADD VALUE 'on_hold' AFTER 'processing';
-- Note: you cannot remove or reorder enum values without recreating the type
Produce a complete, ordered migration file:
-- Migration: 001_create_[feature]_tables.sql
-- Description: [what this migration creates]
-- Date: YYYY-MM-DD
BEGIN;
-- 1. Create enum types
CREATE TYPE ...;
-- 2. Create tables (parent tables first, then children)
CREATE TABLE ...;
-- 3. Add foreign key constraints
ALTER TABLE ...;
-- 4. Create indexes
CREATE INDEX ...;
-- 5. Enable RLS on all tables
ALTER TABLE ... ENABLE ROW LEVEL SECURITY;
-- 6. Create RLS policies
CREATE POLICY ...;
-- 7. Create triggers (updated_at)
CREATE TRIGGER ...;
-- 8. Insert seed data (if needed)
INSERT INTO ...;
COMMIT;
Migration rules:
BEGIN/COMMIT for atomicity-- Rollback: 001_create_[feature]_tables.sql
BEGIN;
DROP TABLE IF EXISTS child_table CASCADE;
DROP TABLE IF EXISTS parent_table CASCADE;
DROP TYPE IF EXISTS custom_enum;
COMMIT;
If new connection strings or configuration are needed, output .env.example entries:
# Database
DATABASE_URL=postgresql://user:pass@host:5432/dbname
DIRECT_URL=postgresql://user:pass@host:5432/dbname # For migrations (bypasses pooler)
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # Server-side only, never expose to client
Remind user: Add to .env.local with real values. Never commit secrets.
Present the complete design:
🗄️ Database Architect — Schema Complete
Feature: [name]
Tables: [count] new, [count] modified
Enums: [list]
Relationships: [count] foreign keys, [count] junction tables
ERD Summary:
organizations ──< projects ──< tasks
│ │
└──< organizations_users └──< task_comments
│
auth.users
RLS Policies: [count] policies across [count] tables
Indexes: [count] new indexes
Migration: [filename] ([line count] lines)
Files generated:
- supabase/migrations/001_create_[feature].sql
- supabase/migrations/001_create_[feature]_rollback.sql
Next steps:
1. Review migration SQL
2. Run: supabase db push (or supabase migration up)
3. Verify RLS policies in Supabase dashboard → Auth → Policies
4. Test queries with different user roles
tools
Use when the user says 'save diary', 'log session', 'wrapping up', or at end of a productive session.
tools
Use when the user says 'submit to marketplace', 'publish my skill', 'share this skill', 'list on marketplace', 'submit plugin', 'publish to community', or needs to submit a skill or plugin to a community marketplace via PR. Do NOT use for building skills or writing plugin code.
development
Use when the user says 'write browser tests', 'test this page', 'playwright test', 'e2e test', 'end to end test', 'browser test', 'test the UI', or needs Playwright-based browser testing for a web application. Do NOT use for unit tests, API tests, or non-browser testing.
development
Use when the user says 'teach me', 'explain as you go', 'mentor mode', 'walk me through', 'help me learn', 'explain why', 'learning mode', or wants real-time plain language narration of decisions and tradeoffs while building. Do NOT use for code review or debugging.