Good. The advisor confirms exactly two hits and gives clear guidance on avoiding a redundancy. Here is the cleaned post:
TLDR: When you create a parent row and child junction rows in separate Supabase calls, you're responsible for rolling back. PostgREST gives you no transaction. You have to be your own cleanup crew.
The Setup
We're building an internal KOL CRM (Key Opinion Leader relationship manager) for a sports nutrition ecommerce business.
The whole app runs on Supabase (Postgres behind PostgREST, their REST query layer).
One of the core objects is a webinar: it links a KOL speaker, a content topic, and a scheduled date.
Simple on the surface. The schema evolution made it less simple. Migration 013_multi_kol_topic.sql had added webinar_kols and webinar_topics as proper junction tables — on top of the legacy webinars.kol_id and webinars.topic_id columns that were already there.
So creating a webinar became a three-step write:
INSERTintowebinars→ get back the newidINSERTintowebinar_kolswith thatidINSERTintowebinar_topicswith thatid
The Wall We Hit
Step 1 commits immediately.
PostgREST — the layer between your JS client and Postgres — doesn't let you wrap multiple API calls in a single transaction. Each call is its own atomic unit. Full stop.
So when step 2 fails…
You've got a webinars row sitting in the database with no KOL linked. No rollback. No error on the parent. Just an orphan.
In production, that orphan showed up on the scheduling board as a blank tile. Completely broken, zero explanation to the user.
What I Tried First
Retry the junction insert.
That's fine if the failure was a transient network hiccup. But if the insert failed because a constraint fired — bad KOL ID, duplicate, whatever — retrying does nothing. The orphan is already there, committed, staring at you.
I also looked at moving the whole create flow into a Postgres RPC function so it'd run as a single transaction. That's probably the right long-term move. But I wanted to ship and not rewrite the entire create path that morning.
The Fix That Worked
Compensating delete.
Exactly what it sounds like. If the junction insert fails, immediately fire a DELETE on the parent row you just created.
const { data: webinar, error: webinarError } = await supabase
.from('webinars')
.insert(payload)
.select()
.single()
if (webinarError) throw webinarError
const { error: kolError } = await supabase
.from('webinar_kols')
.insert({ webinar_id: webinar.id, kol_id: kolId })
if (kolError) {
await supabase.from('webinars').delete().eq('id', webinar.id)
throw kolError
}
Not glamorous. But explicit — anyone reading it knows exactly what's happening and why.
The Bonus Bug Hiding in the Same Diff
While I was in there, I caught something equally sneaky: camelCase drift.
The insert payload was being built from a form object with camelCase keys (kolId, topicId). Supabase expects kol_id, topic_id.
The insert succeeds. Supabase just ignores keys it doesn't recognize. The row is created. The columns are NULL. The UI says "saved." The data isn't there.
I flagged it in the commit message as a category of bug, not a one-off typo. It pairs with another footgun in the same codebase: .update() returning a null error doesn't mean the write actually happened — a CHECK constraint or RLS policy can silently reject it and you'd never know without checking the returned row.
Silent failure is the theme.
Why This One Stuck With Me
I've built on managed databases my whole career and leaned on transaction semantics without really thinking about them. Cross an API boundary — even one as capable as Supabase — and those guarantees just disappear. The abstraction looks like a database. It doesn't always behave like one.
If you're doing multi-step writes over REST: you own the cleanup. Either compensate on failure, or push the logic into a Postgres function that gives you a real transaction.
Pick one. Just don't leave orphans.