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 (1)
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.