The FK that wasn't the FK
Monday morning, May twelfth. A screenshot lands in my window: update or delete on table "plannings" violates foreign key constraint "cours_planning_id_fkey". My first instinct blames the application-side cascade — children before parent. I re-read the server action. The cascade is there, I wrote it two weeks ago. I replay the sequence in BEGIN ... ROLLBACK straight against Postgres. The real error surfaces at the first DELETE, not the second. ERRCODE 23514. A CHECK constraint. Three lines above the FK error the UI showed me.
The visible error said FK violation. The actual error, three lines higher, said CHECK constraint. Between the two, a non-destructured await had swallowed the first and let the application stumble onto the second. The UI wasn't lying. The code was.
The missing destructure
The @supabase/supabase-js return value is a { data, error } object, not an exception. A query that fails on the Postgres side fills error and returns control cleanly. If the caller writes await supabase.from('events').delete().eq('id', id) without looking at what came back, the error goes nowhere. The surrounding try / catch never fires, Sentry isn't notified, the integration test passes green. The rest of the application keeps running as if everything is fine, until something downstream cracks. That something downstream is what the user sees.
Three patterns, three contracts
// ❌ The DB error evaporates
await supabase.from('events').delete().eq('id', id)
// ✓ Destructure, decide
const { error } = await supabase.from('events').delete().eq('id', id)
if (error) throw new Error(error.message)
// ✓ Supabase exception convention
await supabase.from('events').delete().eq('id', id).throwOnError()
The first compiles, passes tests, lies in production. The second is verbose but explicit — you read the error and decide what to do with it. The third is short and swaps the Supabase convention for a standard exception convention, readable by any try / catch or error middleware. The choice between two and three is stylistic. The choice between one and the others isn't.
The structural rule
In the same week, I ran into the same class of defect three times: a silent catch that swallowed a 404, a Slack wrapper that sent nothing on HTTP 5xx, and this Supabase delete. Three variants of the same contract — the return value isn't an exception, and nobody looked at it.
A linter catches what discipline lets through.
// no-bare-await-on-supabase-mutation
const MUTATORS = new Set(['insert', 'update', 'upsert', 'delete'])
export default {
meta: { type: 'problem', messages: { bare:
'Bare await on Supabase mutation: destructure { error } or use .throwOnError().' } },
create(ctx) {
return {
AwaitExpression(node) {
if (node.parent.type !== 'ExpressionStatement') return
let cur = node.argument
while (cur?.callee?.type === 'MemberExpression') {
if (MUTATORS.has(cur.callee.property?.name)) {
return ctx.report({ node, messageId: 'bare' })
}
cur = cur.callee.object
}
},
}
},
}
Closing
The { data, error } return is harmless as long as you agree to read it. When you don't, the UI takes over and tells a different story for you.
The three patterns, the ESLint rule and the SECURITY DEFINER RPC alternative, pseudonymized:
github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutations-silent-await
Top comments (3)
The .throwOnError() recommendation is the right default. We learned this the hard way on a Supabase + FastAPI project (biblie-school): a mutation in our progress-update path was failing silently with a CHECK constraint violation on attempt_count, and what students saw was a generic "try again later" downstream. Took two days to trace because the postgres error was already swallowed by the time we hit logging.
The ESLint rule is nice, but a runtime wrapper that auto-throws on every .from().insert/update/delete() worked better for us, no per-call discipline needed.
Thanks you. Good call on the attempt_count incident. Two days lost to a CHECK constraint violation that the
{ data, error }shape quietly swallowed, that's exactly the failure mode that justifies going past per-call discipline.If your runtime wrapper works so cleanly, it's because it exploits an asymmetry that the Supabase API design ignores. A 0-row SELECT is rarely an error, but a failed INSERT/UPDATE/DELETE almost always is. Restricting the auto-throw to mutations only (and leaving reads alone) restores a sensible default per operation class without breaking legitimate "not found" paths.
I'd still keep the ESLint rule, even with the wrapper layered on top, for one
reason. The intent stays visible in the diff. A reviewer who sees
.throwOnError()on the line knows the author thought about the error contract. A wrapper makes that invisible, safer in aggregate but harder to spot the day someone legitimately needs the raw{ data, error }, for instance inspecting a 23505 before deciding to throw. Belt and suspenders feels right, lint for the audit trail in review, wrapper for the floor.Curious how you handle the
.upsert(...).select().single()shape in your wrapper. Does the auto-throw fire on the upsert, on the chained select, or on both ?On .upsert(...).select().single(): our wrapper hooks the final await of
the chain, so the whole thing returns one composite result and we throw
on whichever stage set .error. In practice that means a 23514 (constraint
on the upsert) and a PGRST116 (0 rows after .single()) come through the
same throw path.
That conflation bit us once: an RLS policy filtered the upserted row out
of the caller's view after the mutation actually committed. The wrapper
threw PGRST116 and upstream retried — but the retry was the wrong move
because the data was already there. Fix was to handle PGRST116
specifically as "no-visible-row, may have succeeded" rather than
"mutation failed".
On audit trail -- agreed. Lint rule alongside the wrapper is the right
shape. Wrapper is the floor, lint is the intent. Belt and suspenders.