Episode 2/4 of the mini-series The week Supabase lied to me 4 times. Episode 1 covered SaaS defaults that lie by misplaced trust through SECURITY DEFINER inheriting EXECUTE TO PUBLIC. Episode 3 will look at RLS recursion. Episode 4 will explain the silent 1000-row cap.

Wednesday of the Week Supabase Was Lying

Monday was episode 1 — a SECURITY DEFINER function callable by anon because Postgres had silently added EXECUTE TO PUBLIC that nobody wrote. Tuesday I was drafting episode 3 on the RLS recursion that returns zero without saying why. Wednesday morning, Pauline walked in with a spreadsheet and a quiet smile.

She put her screen down in front of me — no noise, no preamble. Twenty rows, column A name, column B status, column C supposed archival date. “I found them all this morning, still active in the CRM.” She said it with the politeness of someone who already knows it’s a code problem, not a spreadsheet problem. Then she waited.

Twenty contacts archived the evening before. Green toast, “Archiving successful”, team message sent to confirm. Pauline’s spreadsheet found them all the next morning, still active, still visible in the CRM. No error in the logs. No Sentry alert. Not a single red line anywhere. Twenty silent rows saying simply no, the operation never happened — with nobody listening.

This is episode two of a week when Supabase lied to me four times. Episode one said SaaS defaults lie through misplaced trust. Episode two says something different and the same thing: an API return value lies when the caller refuses to open the envelope.

The Contract Nobody Talks About

@supabase/supabase-js made a defensible choice long ago: don’t throw exceptions on DB errors. A rejected mutation — from a CHECK constraint, an RLS policy, a trigger that says no — doesn’t crash the caller. It returns cleanly with a { data, error } object where data is null and error carries the message. Return-value-error convention, borrowed from Go, defensible in isolation.

The consequence is significant. A caller that doesn’t destructure error doesn’t have the error. Not hidden by a badly-written try / catch, not swallowed by a distracted .then(). Simply: nobody claimed it, so nobody saw it. The contract holds when both parties honor it; it collapses the moment one party looks away.

The doctrine I maintain in ~/doctrine-counterpart/CLAUDE.md calls this R10, Silent failure forbidden. The most dangerous lie in a system isn’t the one that shouts a falsehood, it’s the one that stays quiet about a failure. Silence is a readable state — you just need a device that reads it.

The Code That Lied

I open the archiving module. Twenty lines on the server action side, and this one at the center:

// app/crm/actions.ts — contact archiving (before fix)
async function archiveContact(id: string) {
  const supabase = createSupabaseServer()
  const now = new Date().toISOString()

  await supabase
    .from('contacts')
    .update({ statut: 'ancien', archived_at: now })
    .eq('id', id)
  // no destructure, no check, no throw

  return { ok: true }
}

The return { ok: true } was written two lines below, like a declaration of confidence nothing supported. And the RLS policy, written a few weeks earlier, didn’t include UPDATE on contacts for Pauline’s role. The Postgres server did its job: it rejected the mutation, returned the error, and moved on to the next query. Nobody read the envelope. The green toast appeared.

This isn’t a bug. It’s a contract the caller didn’t honor. And an unmet contract is rigorously indistinguishable from a met one when nobody’s looking — that’s precisely what makes it a structural lie, not a one-off defect.

Why ESLint Isn’t Enough

I wrote an ESLint rule the week before, no-bare-await-on-supabase-mutation, that cleanly catches the orphaned await supabase.from(...).delete().eq(...) at end of statement. The rule eliminated 56 occurrences across the repo. But it lets through a more insidious variant:

// passes the linter, still lies
const { data } = await supabase
  .from('contacts')
  .update({ statut: 'ancien', archived_at: now })
  .eq('id', id)
if (!data) toast.error('Error')   // data null if row missing OR if error is set

The destructure is there — data is read — but error isn’t. The !data test conflates two distinct cases: the row didn’t exist and the policy refused. One is business logic, the other is security, and both surface here as a single generic “error”. For Pauline, error is a hollow word that says neither what to fix nor who to ask for permission.

A linter fixes what grammar lets through. But when grammar is legal and it’s usage that lies, the linter misses it. You need a device higher in the stack.

The Wrapper That Enforces Destructuring

The fix is a single layer. A typed mutate() helper that makes error non-optional in its return signature, forcing TypeScript to refuse compilation if the caller ignores the field.

// lib/supabase-mutate.ts
import type { PostgrestBuilder } from '@supabase/postgrest-js'
import type { PostgrestError } from '@supabase/supabase-js'
import * as Sentry from '@sentry/nextjs'

export async function mutate<T>(
  query: PostgrestBuilder<T>
): Promise<{ data: T | null; error: PostgrestError | null }> {
  const result = await query
  if (result.error) {
    Sentry.addBreadcrumb({
      category: 'supabase.mutation',
      message: result.error.message,
      data: { code: result.error.code, details: result.error.details },
      level: 'error',
    })
  }
  return result
}

// Usage — won't compile if caller omits `error`
const { data, error } = await mutate(
  supabase.from('contacts').update({ statut: 'ancien' }).eq('id', id)
)
if (error) throw new Error(error.message)

The wrapper does three things: it makes error structurally visible in the return type so TypeScript enforces it, it logs a Sentry breadcrumb automatically on any mutation error, and it keeps the call site clean without requiring every caller to remember the pattern from scratch. Pauline’s archiving now surfaces the RLS rejection immediately, with a message that names the policy and the role — information that can actually be acted on.

The underlying lesson carries beyond Supabase: any API that uses return-value errors instead of exceptions places the entire burden of correctness on the caller. A wrapper that makes ignoring the error value a compile-time failure shifts that burden back to the type system, where it belongs.