Stephen

Hackett-Delaney

Full Stack Software Engineer

ArticlesDebugging

Building Safe Reset Scripts

debuggingdatabasescriptssupabasedevtools

February 16, 2026

We needed a script to reset a brand's onboarding state in our development environment. Delete their catalog, lookbooks, inspirations, invited stylists, and media — then regenerate invite tokens so we could walk through the flow again from scratch.

The first version of the script failed immediately. Foreign key constraints blocked every deletion. We hadn't thought through the relational graph before writing the first DELETE.

How Did We Get Here?

During development of Maura, a fashion platform, we were iterating fast on the brand onboarding flow. Every time we changed something, we needed to reset the brand to a clean state and walk through it again.

At first, this was manual — delete rows in the Supabase dashboard one table at a time. That got old fast when the data model grew to 10+ related tables. So we wrote a script.

The naive approach was: query everything related to the brand, delete it all.

// Version 1: The naive approach (this failed)
await supabase.from("clothing_template").delete().eq("brand_id", brandId);
await supabase.from("clothing_template_variant").delete().eq("brand_id", brandId);
await supabase.from("media").delete().in("id", mediaIds);

This immediately hit foreign key constraint errors. clothing_template_variant has a FK to clothing_template. You can't delete the parent before the children. And media might be referenced by other records we hadn't accounted for.

The Iteration

Attempt 1: Reverse the order

Delete children before parents. This got further but still failed — we missed intermediate tables like suggested_clothing_template_variant that reference variants.

Attempt 2: Map the full dependency graph

We had to trace every foreign key relationship:

suggested_clothing_template_variant → clothing_template_variant → clothing_template
inspiration_item → inspiration → brand
lookbook → brand
stylist_to_brand → brand (and → stylist)
brand_address → brand
media ← (referenced by variants, inspirations, inspiration_items, logo)

The deletion order had to be: deepest children first, parents last.

Attempt 3: Handle shared rows

This was the subtle one. A stylist can be linked to multiple brands via stylist_to_brand. If we delete a stylist who also works with another brand, we break that relationship.

// Check if stylist is linked to other brands before deleting
for (const sid of allStylistIds) {
  const { count } = await supabase
    .from("stylist_to_brand")
    .select("*", { count: "exact", head: true })
    .eq("stylist_id", sid);

  if ((count ?? 0) <= 1) {
    safeToDeleteIds.push(sid);
  } else {
    skippedIds.push({ id: sid, brandCount: count ?? 0 });
  }
}

Attempt 4: Handle media gracefully

Media rows can be referenced by multiple records. Instead of failing on FK violations, we delete one at a time and skip any that are still referenced:

for (const mediaId of mediaIds) {
  const { error } = await supabase
    .from("media")
    .delete()
    .eq("id", mediaId);

  if (error?.message.includes("foreign key constraint")) {
    mediaSkipped++;
  } else if (error) {
    console.error(`Failed to delete media ${mediaId}:`, error.message);
    process.exit(1);
  } else {
    mediaDeleted++;
  }
}

The Dry-Run Pattern

The biggest improvement was making the script read-only by default. Without the --reset flag, it queries everything and shows you exactly what would happen:

=== RESET SUMMARY ===
Brand:                                "Acme Fashion" (uuid-here)
category:                             Womenswear → NULL
logo_url:                             https://... → NULL
onboarding_completed_at:              2026-02-10 → NULL
clothing_template:                    47 row(s) → DELETE
clothing_template_variant:            183 row(s) → DELETE
media:                                230 row(s) → DELETE
stylist_to_brand:                     3 row(s) → DELETE
stylist + profile + auth user:        2 row(s) → DELETE (1 skipped — linked to other brands)
brand_employee:                       1 row(s) → PRESERVED (invite token reset)

--- DRY RUN (read-only) ---
Re-run with --reset to execute the changes.

You see every row that will be affected before anything is modified. This caught several issues before they became problems — like the time we almost deleted a stylist who was shared across brands.

The Process Shortcomings

We didn't map the schema before writing the script. If we'd drawn the FK dependency graph first — even on paper — we would have got the deletion order right on the first try. Instead, we discovered constraints one error at a time.

We didn't think about multi-tenancy. The script operates on one brand, but the data model is multi-tenant. Stylists, media, and other entities can be shared. We only realized this when the second brand's data disappeared.

We didn't start with dry-run. The dry-run flag was added after the first accidental deletion. It should have been the default from the start. Any script that deletes data should show you what it will do before doing it.

How to Get It Right from the Start

If I were writing this script from scratch knowing what I know now:

1. Start with the schema diagram. Run \d+ table_name for every table involved. Map every FK relationship. The deletion order writes itself once you have the graph.

2. Default to dry-run. Make the script read-only unless an explicit flag is passed. Print a complete summary of what would change.

3. Assume shared data. For every DELETE, ask: "Could this row be referenced by something outside this brand?" If yes, check before deleting.

4. Delete one-at-a-time for risky tables. Media, shared profiles, anything with multiple inbound FKs — delete individually and handle constraint errors gracefully instead of batch-deleting and hoping.

5. Verify after mutation. Query the same tables after the reset and print counts. The script should prove it worked:

// Post-reset verification
const [vTemplates, vVariants, vMedia] = await Promise.all([
  supabase.from("clothing_template").select("id").eq("brand_id", brandId),
  supabase.from("clothing_template_variant").select("id")
    .in("clothing_template_id", templateIds),
  supabase.from("media").select("id").in("id", [...mediaIds]),
]);

console.log(`clothing_template count:     ${vTemplates.data?.length ?? 0}`);
console.log(`clothing_template_var count: ${vVariants.data?.length ?? 0}`);
console.log(`media count:                 ${vMedia.data?.length ?? 0}`);

What I Learned

Reset scripts are infrastructure, not throwaway code. They get run dozens of times during development. Invest in them like you would a migration.

Dry-run is a feature, not a luxury. It costs maybe 20 extra lines of code (an early return before the mutation block) and saves you from ever having to explain why test data disappeared.

Foreign keys are documentation. They tell you the deletion order. They tell you what's shared. They tell you what's safe. Read them before you write the script, not after it fails.


This is Part 2 of the Debugging series. Next up: The Subtle Art of Clearing Caches — when the fix is worse than the bug.

© 2026. All rights reserved