skills/d1-and-supabase-migrations/SKILL.md
Use when applying Cloudflare D1 migrations, fighting Supabase migration history vs SQL execution, choosing direct psql over CLI, designing idempotent migrations, debugging schema drift between local and remote, or recovering after a half-applied migration. Triggers: supabase migration repair instructions appearing, table missing after "applied" status, "Tenant or user not found" when running psql, --remote vs --local D1 confusion, NOT NULL on a populated column, foreign key constraint failures, drift between staging and prod schemas. NOT for Mongo/document migrations, ORM-managed migrations specifically (Prisma/Drizzle have their own conventions), or pure data backfills.
npx skillsauth add curiositech/windags-skills d1-and-supabase-migrationsInstall 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.
Migrations are state changes the database remembers. The most expensive bug in this domain is "the history says applied but the SQL never ran" — Supabase's migration repair makes this trivially possible. This skill catalogs the traps and the safe patterns.
migration repair; pause before running it.Read this twice:
supabase migration repair --status applied 042ONLY updatessupabase_migrations.schema_migrations. It does NOT execute the SQL.
If the database doesn't have the table or column the migration creates, repair --status applied will leave you with the history claiming "applied" and the schema saying nothing. Subsequent supabase db push will skip the migration because the history says it ran.
Always run the SQL first. Then repair.
Build the connection URL yourself when the CLI fails:
PGPASSWORD="$SUPABASE_DB_PASSWORD" psql \
"postgresql://postgres@db.${PROJECT_REF}.supabase.co:5432/postgres" \
-f supabase/migrations/042_add_audit_log.sql
PROJECT_REF is the substring before .supabase.co in NEXT_PUBLIC_SUPABASE_URL.5432 is the direct connection. Port 6543 is the pgbouncer pooler.Tenant or user not found for migration scripts because pgbouncer's transaction-pooling mode doesn't support all features migrations need (advisory locks, prepared statements).After the SQL has actually run, mark the history:
supabase migration repair --status applied 042
Verify with a real query, not history:
psql ... -c "SELECT count(*) FROM information_schema.columns WHERE table_name = 'audit_log';"
D1 has a single migration command and no separate "repair":
wrangler d1 migrations create windags-telemetry add_cascade_scores
# Edit the generated SQL file under migrations/
wrangler d1 migrations list windags-telemetry --remote
wrangler d1 migrations apply windags-telemetry --remote
The d1_migrations table is updated only on successful apply. There's no "mark applied without running" footgun in D1.
--local and --remote are different databases. Local uses .wrangler/state/v3/d1/. A common bug: applying locally, declaring victory, then prod has the old schema.
wrangler d1 execute windags-telemetry --remote --command="SELECT count(*) FROM tool_call_events;"
wrangler d1 execute windags-telemetry --remote --file=hotfixes/backfill_top_score.sql
For exploratory queries, use --json for parseable output.
Both Postgres and SQLite (D1) accept IF NOT EXISTS on most things:
-- Both DBs
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
-- Postgres-only
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS actor_id TEXT;
-- SQLite (D1) — no IF NOT EXISTS on ADD COLUMN. Check first:
SELECT count(*) AS has_col
FROM pragma_table_info('audit_log')
WHERE name = 'actor_id';
-- Then conditionally apply via app code, or use a defensive approach:
-- ALTER TABLE audit_log RENAME COLUMN ... can also fail if you re-run.
A simple alternative for SQLite: write each ALTER as a separate migration file, never edit a migration after it's been applied anywhere.
SQLite (D1) needs PRAGMA foreign_keys = ON per connection. D1's runtime enables it for you on every query, but wrangler d1 execute may not — verify:
wrangler d1 execute mydb --remote --command="PRAGMA foreign_keys;" # expect 1
Postgres always enforces foreign keys. The bug is usually the other way: a deferred constraint isn't being deferred, blocking a delete.
Three steps, three migrations:
-- 1. Add the column nullable.
ALTER TABLE orders ADD COLUMN region TEXT;
-- 2. Backfill (separate migration, possibly batched).
UPDATE orders SET region = 'us-west' WHERE region IS NULL;
-- 3. Make it NOT NULL.
ALTER TABLE orders ALTER COLUMN region SET NOT NULL; -- Postgres
-- SQLite: requires table rebuild. Acceptable for small tables; use `ALTER TABLE ... RENAME` + new table for big ones.
Single-migration NOT NULL with default is fine for small tables (ALTER TABLE … ADD COLUMN region TEXT NOT NULL DEFAULT 'us-west'). On large tables this rewrites the heap on Postgres pre-11; check size first.
Postgres:
pg_dump --schema-only --no-owner --no-privileges \
"postgresql://[email protected]:5432/postgres" \
> /tmp/staging.sql
pg_dump --schema-only --no-owner --no-privileges \
"postgresql://[email protected]:5432/postgres" \
> /tmp/prod.sql
diff -u /tmp/staging.sql /tmp/prod.sql
D1: there's no built-in dump. Query sqlite_master:
wrangler d1 execute mydb --remote --command="SELECT sql FROM sqlite_master ORDER BY type, name;" --json > /tmp/remote.json
wrangler d1 execute mydb --local --command="SELECT sql FROM sqlite_master ORDER BY type, name;" --json > /tmp/local.json
diff /tmp/local.json /tmp/remote.json
After every migration, run a query that asserts presence:
const { results } = await env.DB.prepare(
"SELECT count(*) AS n FROM pragma_table_info('audit_log') WHERE name = 'actor_id'"
).first();
if (results.n === 0) throw new Error('migration did not apply');
Don't trust history rows. Trust the schema.
supabase migration repair --status appliedSymptom: Wrangler/Supabase CLI suggests repair; you run it; the table is still missing.
Diagnosis: Repair updates history, not schema. SQL was never executed.
Fix: Run the SQL via psql or the Supabase SQL editor first. Verify the schema. Then repair if needed.
Symptom: psql -f migration.sql returns Tenant or user not found or hangs.
Diagnosis: You're using port 6543 (pgbouncer transaction pool), which doesn't support migration features.
Fix: Direct connection on port 5432: db.<ref>.supabase.co:5432.
Symptom: Code deploys, throws "no such column" on first request.
Diagnosis: Migrations applied with default --local flag; remote DB unchanged.
Fix: Always --remote for production migrations. Add a CI check that the remote schema matches the local one before deploy.
Symptom: Migration fails: "column contains null values". Diagnosis: Adding NOT NULL without first backfilling existing rows. Fix: Three-step: nullable → backfill → NOT NULL. Or single ADD COLUMN with NOT NULL DEFAULT.
Symptom: App refuses to start because "expected migration 042 applied".
Diagnosis: Coupling app version to a specific migration number; works for a sprint, breaks on the first squash/cherry-pick.
Fix: Feature checks against the schema (information_schema.columns / pragma_table_info), not migration history.
Symptom: Production locks up for minutes during a deploy.
Diagnosis: Single transaction rewriting a 100M-row heap (full-table ALTER, big index build without CONCURRENTLY).
Fix: Postgres: CREATE INDEX CONCURRENTLY, batched UPDATE in chunks of 10k with sleep, online DDL via pg_repack. SQLite: shadow table + atomic rename.
--remote to production.pg_dump --schema-only or sqlite_master) matches expected baseline.repair --status applied never run unless the SQL has been executed and verified.cloudflare-workers-debugging.hono-patterns.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.