Database Schema Design
The Design Process
- List entities (nouns from spec): users, agents, challenges, entries, scores, votes, wallets
- List relationships (verbs): user registers agent, agent enters challenge, judge scores entry
- Determine cardinality: user 1:N agents, challenge 1:N entries, entry 1:N judge_scores
- Normalize to 3NF, then selectively denormalize for performance
- Add temporal columns:
created_at, updated_at on every table
- Add soft delete where needed:
deleted_at on users, teams (not events)
- Define indexes based on expected query patterns
- Write RLS for every table
- Create functions for complex operations (ELO, wallet, transitions)
Naming Conventions (Enforce Consistently)
| Element |
Convention |
Example |
| Tables |
plural snake_case |
challenges, agent_ratings |
| Columns |
singular snake_case |
user_id, elo_rating |
| Primary keys |
id (uuid) |
id uuid PRIMARY KEY DEFAULT gen_random_uuid() |
| Foreign keys |
{table_singular}_id |
user_id, challenge_id |
| Booleans |
is_ or has_ prefix |
is_active, has_submitted |
| Timestamps |
_at suffix |
created_at, completed_at |
| Status columns |
status with CHECK |
CHECK (status IN ('pending','active','done')) |
| JSON columns |
_json suffix |
scores_json, metadata_json |
JSONB vs Separate Table
| Use JSONB |
Use Separate Table |
| Flexible metadata varying per record |
Data you filter/sort/aggregate on |
| Config blobs, score breakdowns |
Data with its own relationships |
| Audit snapshots |
Data that grows unboundedly |
Test: if you'd write WHERE json->>'key' = ? in a hot query, make it a column |
|
Index Strategy
-- Every foreign key (Postgres does NOT auto-index FKs)
CREATE INDEX idx_entries_challenge ON entries (challenge_id);
CREATE INDEX idx_entries_agent ON entries (agent_id);
-- Multi-column for common query patterns
CREATE INDEX idx_entries_challenge_status ON entries (challenge_id, status);
-- Partial index (only index relevant subset)
CREATE INDEX idx_entries_pending ON entries (challenge_id, created_at)
WHERE status = 'pending';
-- GIN for JSONB and tsvector
CREATE INDEX idx_challenges_search ON challenges USING GIN (search_vector);
-- CONCURRENTLY for production (no table lock)
CREATE INDEX CONCURRENTLY idx_votes_entry ON votes (entry_id);
Standard RLS Patterns
-- 1. User-owned data
CREATE POLICY "own_data" ON entries FOR ALL TO authenticated
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
-- 2. Team data
CREATE POLICY "team_data" ON challenges FOR SELECT TO authenticated
USING (team_id IN (
SELECT team_id FROM team_members WHERE user_id = (select auth.uid())
));
-- 3. Public read, owner write
CREATE POLICY "public_read" ON agents FOR SELECT USING (true);
CREATE POLICY "owner_write" ON agents FOR UPDATE TO authenticated
USING ((select auth.uid()) = user_id);
-- 4. Service role only (webhooks, cron)
-- No authenticated policy = only service_role can access
-- 5. Insert with ownership check
CREATE POLICY "create_own" ON entries FOR INSERT TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
-- ALWAYS use (select auth.uid()) not bare auth.uid() — 99%+ performance improvement
Standard Temporal Columns
-- Add to EVERY table
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
-- Auto-update trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END; $$;
-- Apply to each table
CREATE TRIGGER set_updated_at BEFORE UPDATE ON challenges
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
Schema Evolution Rules
- Prefer nullable columns over NOT NULL when field might not always apply
- Use CHECK constraints over Postgres ENUM (easier to extend)
- Plan for soft delete from start:
deleted_at timestamptz (null = active)
- UUID primary keys everywhere (never auto-increment for public-facing IDs)
text over varchar(n) — length validation belongs in Zod, not the DB
Output Template
Given a product spec, produce:
- Complete
CREATE TABLE statements with all constraints
- All indexes with rationale
- All RLS policies
- Database functions for complex operations
updated_at triggers
- Seed data for development
- The migration file(s)
Sources
- PostgreSQL documentation (constraints, indexes, RLS)
- Supabase schema design best practices
- cal.com database schema (production reference)
- advanced-postgres skill (CTEs, window functions, partitioning)
Changelog
- 2026-03-21: Initial skill — database schema design
1---2name: database-schema-design3description: Schema design from product specs — entity extraction, normalization, naming conventions, index strategy, RLS patterns, migration generation, and seed data.4---56# Database Schema Design78## The Design Process9101. **List entities** (nouns from spec): users, agents, challenges, entries, scores, votes, wallets112. **List relationships** (verbs): user registers agent, agent enters challenge, judge scores entry123. **Determine cardinality**: user 1:N agents, challenge 1:N entries, entry 1:N judge_scores134. **Normalize to 3NF**, then selectively denormalize for performance145. **Add temporal columns**: `created_at`, `updated_at` on every table156. **Add soft delete** where needed: `deleted_at` on users, teams (not events)167. **Define indexes** based on expected query patterns178. **Write RLS** for every table189. **Create functions** for complex operations (ELO, wallet, transitions)1920## Naming Conventions (Enforce Consistently)2122| Element | Convention | Example |23|---------|-----------|---------|24| Tables | plural snake_case | `challenges`, `agent_ratings` |25| Columns | singular snake_case | `user_id`, `elo_rating` |26| Primary keys | `id` (uuid) | `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` |27| Foreign keys | `{table_singular}_id` | `user_id`, `challenge_id` |28| Booleans | `is_` or `has_` prefix | `is_active`, `has_submitted` |29| Timestamps | `_at` suffix | `created_at`, `completed_at` |30| Status columns | `status` with CHECK | `CHECK (status IN ('pending','active','done'))` |31| JSON columns | `_json` suffix | `scores_json`, `metadata_json` |3233## JSONB vs Separate Table3435| Use JSONB | Use Separate Table |36|-----------|-------------------|37| Flexible metadata varying per record | Data you filter/sort/aggregate on |38| Config blobs, score breakdowns | Data with its own relationships |39| Audit snapshots | Data that grows unboundedly |40| **Test:** if you'd write `WHERE json->>'key' = ?` in a hot query, make it a column | |4142## Index Strategy4344```sql45-- Every foreign key (Postgres does NOT auto-index FKs)46CREATE INDEX idx_entries_challenge ON entries (challenge_id);47CREATE INDEX idx_entries_agent ON entries (agent_id);4849-- Multi-column for common query patterns50CREATE INDEX idx_entries_challenge_status ON entries (challenge_id, status);5152-- Partial index (only index relevant subset)53CREATE INDEX idx_entries_pending ON entries (challenge_id, created_at)54 WHERE status = 'pending';5556-- GIN for JSONB and tsvector57CREATE INDEX idx_challenges_search ON challenges USING GIN (search_vector);5859-- CONCURRENTLY for production (no table lock)60CREATE INDEX CONCURRENTLY idx_votes_entry ON votes (entry_id);61```6263## Standard RLS Patterns6465```sql66-- 1. User-owned data67CREATE POLICY "own_data" ON entries FOR ALL TO authenticated68 USING ((select auth.uid()) = user_id)69 WITH CHECK ((select auth.uid()) = user_id);7071-- 2. Team data72CREATE POLICY "team_data" ON challenges FOR SELECT TO authenticated73 USING (team_id IN (74 SELECT team_id FROM team_members WHERE user_id = (select auth.uid())75 ));7677-- 3. Public read, owner write78CREATE POLICY "public_read" ON agents FOR SELECT USING (true);79CREATE POLICY "owner_write" ON agents FOR UPDATE TO authenticated80 USING ((select auth.uid()) = user_id);8182-- 4. Service role only (webhooks, cron)83-- No authenticated policy = only service_role can access8485-- 5. Insert with ownership check86CREATE POLICY "create_own" ON entries FOR INSERT TO authenticated87 WITH CHECK ((select auth.uid()) = user_id);8889-- ALWAYS use (select auth.uid()) not bare auth.uid() — 99%+ performance improvement90```9192## Standard Temporal Columns9394```sql95-- Add to EVERY table96created_at timestamptz NOT NULL DEFAULT now(),97updated_at timestamptz NOT NULL DEFAULT now()9899-- Auto-update trigger100CREATE OR REPLACE FUNCTION update_updated_at()101RETURNS trigger LANGUAGE plpgsql AS $$102BEGIN103 NEW.updated_at = now();104 RETURN NEW;105END; $$;106107-- Apply to each table108CREATE TRIGGER set_updated_at BEFORE UPDATE ON challenges109 FOR EACH ROW EXECUTE FUNCTION update_updated_at();110```111112## Schema Evolution Rules113114- Prefer nullable columns over NOT NULL when field might not always apply115- Use CHECK constraints over Postgres ENUM (easier to extend)116- Plan for soft delete from start: `deleted_at timestamptz` (null = active)117- UUID primary keys everywhere (never auto-increment for public-facing IDs)118- `text` over `varchar(n)` — length validation belongs in Zod, not the DB119120## Output Template121122Given a product spec, produce:1231. Complete `CREATE TABLE` statements with all constraints1242. All indexes with rationale1253. All RLS policies1264. Database functions for complex operations1275. `updated_at` triggers1286. Seed data for development1297. The migration file(s)130131## Sources132- PostgreSQL documentation (constraints, indexes, RLS)133- Supabase schema design best practices134- cal.com database schema (production reference)135- advanced-postgres skill (CTEs, window functions, partitioning)136137## Changelog138- 2026-03-21: Initial skill — database schema design