DEV Community

Cover image for Why your Supabase mutations lie about their errors
Michel Faure
Michel Faure

Posted on • Originally published at dev.to

Why your Supabase mutations lie about their errors

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()
Enter fullscreen mode Exit fullscreen mode

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
        }
      },
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
arvavit profile image
Vadym Arnaut • Edited

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.