DEV Community

Cover image for Pourquoi tes mutations Supabase mentent sur leurs erreurs
Michel Faure
Michel Faure

Posted on

Pourquoi tes mutations Supabase mentent sur leurs erreurs

La FK qui n'était pas la FK

Lundi matin, douze mai. Un screenshot tombe dans ma fenêtre : update or delete on table "plannings" violates foreign key constraint "cours_planning_id_fkey". Mon premier réflexe accuse la cascade applicative — il faut supprimer les enfants avant le parent. Je relis l'action serveur. La cascade est là, je l'ai écrite il y a deux semaines. Je relance la séquence en BEGIN ... ROLLBACK côté Postgres. L'erreur réelle remonte au premier DELETE, pas au second. ERRCODE 23514. Une CHECK constraint. Trois lignes au-dessus de la FK que l'UI m'a montrée.

L'erreur visible disait FK violation. La vraie erreur, trois lignes plus haut, disait CHECK constraint. Entre les deux, un await non destructuré avait avalé la première et laissé l'application trébucher sur la seconde. L'UI ne mentait pas. Le code, si.

Le destructure manquant

Le retour de @supabase/supabase-js est un objet { data, error }, pas une exception. Une requête qui échoue côté Postgres remplit error et rend la main proprement. Si le caller fait await supabase.from('events').delete().eq('id', id) sans regarder ce qu'il a reçu, l'erreur ne remonte nulle part. Le try / catch autour ne se déclenche pas, Sentry n'est pas notifié, le test d'intégration passe vert. La suite du code applicatif s'exécute comme si tout allait bien, jusqu'à ce qu'une dépendance en aval craque. Cette dépendance, c'est ce que l'utilisateur voit.

Trois patterns, trois contrats

// ❌ L'erreur DB s'évapore
await supabase.from('events').delete().eq('id', id)

// ✓ Destructure, décide
const { error } = await supabase.from('events').delete().eq('id', id)
if (error) throw new Error(error.message)

// ✓ Convention exception Supabase
await supabase.from('events').delete().eq('id', id).throwOnError()
Enter fullscreen mode Exit fullscreen mode

Le premier compile, passe les tests, ment en production. Le deuxième est verbeux mais explicite, puisqu'on lit l'erreur et qu'on décide quoi en faire. Le troisième est court et bascule la convention Supabase vers une convention exception standard, lisible par n'importe quel try / catch ou middleware d'erreurs. Le choix entre deux et trois est stylistique. Le choix entre un et les autres ne l'est pas.

La règle structurelle

Sur la même semaine, je suis tombé trois fois sur la même classe de défaut : un catch muet qui avalait une 404, un wrapper Slack qui n'envoyait rien sur HTTP 5xx, et ce delete Supabase. Trois variantes du même contrat — le retour n'est pas une exception, et personne ne l'a regardé.

Un linter règle ce que la discipline laisse filer.

// 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

Clôture

Le retour { data, error } n'a pas de gravité tant qu'on accepte de le lire. Quand on ne le lit pas, c'est l'UI qui se charge de raconter une autre histoire à ta place.


Les trois patterns, la rule ESLint et l'alternative RPC SECURITY DEFINER, pseudonymisés :
github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutations-silent-await

Top comments (0)