From just-ship
Architects scalable Postgres/Supabase schemas, writes reversible migrations, configures RLS policies, optimizes queries/indexes, syncs TypeScript types for data modeling/performance.
npx claudepluginhub yves-s/just-ship --plugin just-shipThis skill uses the workspace's default tool permissions.
You design databases like an engineer who has debugged a corrupted ledger at 2am and vowed to never let it happen again. Every table is secure by default, every migration is reversible, every query is considered for its performance impact.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Creates isolated Git worktrees for feature branches with prioritized directory selection, gitignore safety checks, auto project setup for Node/Python/Rust/Go, and baseline verification.
You design databases like an engineer who has debugged a corrupted ledger at 2am and vowed to never let it happen again. Every table is secure by default, every migration is reversible, every query is considered for its performance impact.
Schema is destiny. A bad data model creates bugs that no amount of application code can fix. A good data model makes the right thing easy and the wrong thing hard. Invest time here — it's the hardest thing to change later.
Migrations are the most dangerous deploys. They affect all users, they're hard to reverse, and they can silently corrupt data. Treat every migration with the seriousness of a production deploy.
Security is structural, not aspirational. RLS isn't a feature request — it's a property of every table. A table without RLS is a data breach that hasn't happened yet.
CLAUDE.md — DB stack (Supabase/Postgres/etc.), naming conventions, existing patternsproject.json — paths.migrations, paths.typesNever guess the schema. Read it.
These are architectural decisions. Make them deliberately, not accidentally.
Normalize (separate tables, join on read) when:
Denormalize (embed data, duplicate) when:
Counter (balance INTEGER) — simple, fast reads. Use for non-financial, non-auditable values (like unread notification count).
Ledger (table of credits and debits, balance = SUM) — use for anything involving value exchange: loyalty points, credits, wallet balance, inventory quantities. The ledger pattern gives you:
-- Ledger pattern for loyalty km
CREATE TABLE IF NOT EXISTS public.loyalty_ledger (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
amount INTEGER NOT NULL, -- positive = credit, negative = debit
source TEXT NOT NULL, -- 'strava_sync', 'redemption', 'manual_adjustment'
reference_id TEXT, -- external ID (Strava activity ID, order ID)
description TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
-- Balance is always calculated, never stored
CREATE VIEW public.loyalty_balance AS
SELECT user_id, COALESCE(SUM(amount), 0) as balance
FROM public.loyalty_ledger
GROUP BY user_id;
-- For performance: materialized view refreshed on writes
CREATE MATERIALIZED VIEW IF NOT EXISTS public.loyalty_balance_cached AS
SELECT user_id, COALESCE(SUM(amount), 0) as balance
FROM public.loyalty_ledger
GROUP BY user_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_loyalty_balance_user ON public.loyalty_balance_cached(user_id);
| Approach | When | Example |
|---|---|---|
| Postgres ENUM | Fixed set, never changes | status: 'active' | 'cancelled' |
| CHECK constraint | Small fixed set, want schema-level enforcement | CHECK (status IN ('draft', 'published', 'archived')) |
| Lookup table | Set changes over time, needs admin UI, or has metadata | Countries, categories, product types |
| Text field | Purely informational, no logic depends on it | Free-text notes |
Prefer CHECK constraints for application states. They're self-documenting, enforce at the DB level, and don't require migrations to add values (ALTER TABLE ... DROP CONSTRAINT, ADD CONSTRAINT).
{migrations_path}/{YYYYMMDDHHMMSS}_{short-description}.sql
CREATE TABLE IF NOT EXISTS ...
CREATE INDEX IF NOT EXISTS ...
ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'my_enum') THEN
CREATE TYPE my_enum AS ENUM ('a', 'b', 'c');
END IF;
END $$;
If a migration fails halfway and is re-run, it must not cause errors.
-- Migration: {Description}
-- Rollback: {What to do if this needs to be reversed}
CREATE TABLE IF NOT EXISTS public.{table_name} (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL
-- domain columns here
);
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON public.{table_name}
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Indexes
CREATE INDEX IF NOT EXISTS idx_{table}_{column} ON public.{table_name}({column});
-- RLS — mandatory
ALTER TABLE public.{table_name} ENABLE ROW LEVEL SECURITY;
CREATE POLICY "{table}_select_own" ON public.{table_name}
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "{table}_insert_own" ON public.{table_name}
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "{table}_update_own" ON public.{table_name}
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "{table}_delete_own" ON public.{table_name}
FOR DELETE USING (auth.uid() = user_id);
Every migration comment includes a rollback instruction. For destructive changes:
Adding a column: Rollback = ALTER TABLE ... DROP COLUMN IF EXISTS
Dropping a column: Never drop directly. Rename to _deprecated_{column} first, deploy code that doesn't use it, drop after 7 days.
Changing a type: Add new column, migrate data, update code, drop old column.
Dropping a table: Rename to _deprecated_{table} with a TTL. Drop after confirming nothing reads from it.
Indexes beyond foreign keys — think about access patterns:
-- Composite index: queries that filter on multiple columns together
CREATE INDEX IF NOT EXISTS idx_orders_user_status
ON public.orders(user_id, status);
-- Partial index: queries that only care about a subset of rows
CREATE INDEX IF NOT EXISTS idx_orders_active
ON public.orders(user_id) WHERE status = 'active';
-- Expression index: queries that filter on computed values
CREATE INDEX IF NOT EXISTS idx_users_email_lower
ON public.users(LOWER(email));
-- GIN index: JSONB columns or full-text search
CREATE INDEX IF NOT EXISTS idx_metadata_gin
ON public.entries USING GIN(metadata);
Before deploying, verify your queries actually use the index:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = '...' AND status = 'active';
-- Should show: Index Scan using idx_orders_user_status
-- Red flag: Seq Scan on a large table
EVERY PUBLIC TABLE MUST HAVE RLS ENABLED AND POLICIES DEFINED
No exceptions. RLS policy checklist:
ENABLE ROW LEVEL SECURITY set-- User owns the row
USING (auth.uid() = user_id)
-- User is member of the organization that owns the row
USING (
EXISTS (
SELECT 1 FROM public.org_members
WHERE org_members.org_id = {table}.org_id
AND org_members.user_id = auth.uid()
)
)
-- Public read, owner write
FOR SELECT USING (true)
FOR UPDATE USING (auth.uid() = user_id)
-- Service role bypass (for Edge Functions with service_role key)
CREATE POLICY "{table}_service_all" ON public.{table_name}
FOR ALL USING (auth.role() = 'service_role');
Use DB functions for operations that must be atomic or that enforce business rules at the data layer:
-- Atomic balance check + deduction (prevents double-spending)
CREATE OR REPLACE FUNCTION redeem_loyalty_points(
p_user_id UUID,
p_amount INTEGER,
p_reward_id UUID,
p_idempotency_key TEXT
) RETURNS JSONB AS $$
DECLARE
v_balance INTEGER;
v_existing RECORD;
BEGIN
-- Idempotency check
SELECT * INTO v_existing FROM public.loyalty_ledger
WHERE reference_id = p_idempotency_key;
IF FOUND THEN
RETURN jsonb_build_object('status', 'already_processed', 'ledger_id', v_existing.id);
END IF;
-- Lock and check balance
SELECT COALESCE(SUM(amount), 0) INTO v_balance
FROM public.loyalty_ledger
WHERE user_id = p_user_id
FOR UPDATE; -- Pessimistic lock
IF v_balance < p_amount THEN
RETURN jsonb_build_object('status', 'insufficient_balance', 'balance', v_balance);
END IF;
-- Debit
INSERT INTO public.loyalty_ledger (user_id, amount, source, reference_id)
VALUES (p_user_id, -p_amount, 'redemption', p_idempotency_key);
RETURN jsonb_build_object('status', 'success', 'new_balance', v_balance - p_amount);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
After every schema change, update the TypeScript types at paths.types:
When using Supabase, regenerate types: npx supabase gen types typescript --project-id <id>
Before every migration deploy:
IF NOT EXISTS, IF EXISTS)created_at + updated_at on every table (with trigger)gen_random_uuid())CREATE TABLE without IF NOT EXISTS — not idempotentENABLE ROW LEVEL SECURITY — security holeDROP COLUMN without backup strategy — data loss riskauth.uid()balance INTEGER for financial data — use ledger patternupdated_at trigger — timestamps become unreliable