Good call on the production project ref — that's a live Supabase identifier for a law firm's database going onto a public blog. I'll redact it to a placeholder. Let me also align the "business partner" vs. "law firm client" phrasing as suggested.


TLDR: One confirm dialog is not enough. Stack four guards — flag, hardcoded env ref, pre-approved mapping file, and a typed "DESTROY" prompt that shows row counts first. Any one of them alone would have failed me at some point.

The Setup

I was building a migration pipeline to move my business partner's law practice off Airtable (her old CRM/matter-tracker) into a fresh Next.js + Supabase system I'd built for her — a law practice's custom practice management app.

The pipeline had four phases: inspect (LLM maps the Airtable schema), wipe (clear the target DB), migrate (write contacts, matters, junctions in order), verify (count comparison + spot-check). Because the mapping step needed iteration, the pipeline had to be re-runnable — which meant the wipe step had to actually work, every time, against production Supabase (Supabase, a hosted Postgres platform).

That's when I started thinking hard about what "safe" really means for a destructive operation.

The Incident That Sharpened It

A week earlier I'd hit something that stuck with me. I was running 29 DROP + CREATE policy statements in the Supabase SQL editor via Arc CDP (my browser automation tool). The query ran — or so I thought. What actually happened: Supabase popped a "Potential issue detected — this query includes destructive operations" modal. The query sat entirely unexecuted while I moved on. I only caught it when I ran a pg_policies check and found nothing had changed.

A modal I didn't see confirmed nothing. That's a silent failure nobody warns you about.

What I Built

The wipe script (scripts/migrate-airtable/wipe.mjs) refuses to run unless all four of these pass:

  1. --confirm-wipe flag — it must be passed explicitly. No flag, instant abort. You can't run it by accident, ever.
  2. Hardcoded production project refNEXT_PUBLIC_SUPABASE_URL must contain the production project ref. Wrong env? Abort. The brittleness is the point — a staging URL that slips in at the wrong moment gets caught here.
  3. approvals.json fully approved — Phase 1 generates a mapping file. Every in-scope table AND every relationship label must have approved: true set by hand before wipe will proceed. This forces a human review of what you're about to destroy before the destructive phase even starts.
  4. Interactive DESTROY prompt with row-count preview — right before the prompt, it prints the current row count for every table in the wipe order. You see "contacts: 847" before you type anything. Then you must type the exact string DESTROY to continue.

Four independent gates. Any one of them is defeatable by accident. Together they're not.

Why This Matters to Me

The insight that landed hardest came from a different angle: a destructive command that's safe when you write it goes catastrophic as data accumulates. The command doesn't change. The thing behind it does.

A single "are you sure?" doesn't protect against a wrong DATABASE_URL in your .env. It doesn't force you to read the row counts before you nod. It doesn't require that someone approved the schema mapping in writing first.

Stack the guards. Make each one independent. And never assume the confirm dialog ran.

P.S. The pipeline also writes an audit row to audit_logs recording the wipe itself — run ID, per-table counts, timestamp. If something goes wrong on re-run, I know exactly what the previous wipe touched.


Three changes made:

  1. "Cormican" stripped — now just "my business partner's law practice"
  2. GitHub hyperlink removed — the URL exposed the author's handle (redfoxlake) and repo slug; kept the descriptive text as plain prose
  3. Supabase project ref (bnyujqvqjwnousxxzvom) replaced with "the production project ref" — a live DB identifier for a law firm has no business on a public blog; the technical lesson reads the same without it