Épisode 2/4 de la mini-série La semaine où Supabase m'a menti 4 fois. L'épisode 1 — 3 incidents Supabase, une seule racine : SECURITY DEFINER hérite de EXECUTE TO PUBLIC — traitait des defaults SaaS qui mentent par confiance accordée. L'épisode 3 [CANONICAL URL EPISODE 3: à compléter après push] regardera la récursion RLS. L'épisode 4 [CANONICAL URL EPISODE 4: à compléter après push] expliquera le cap silencieux à 1000 lignes.
Le mercredi de la semaine où Supabase mentait
Lundi, c'était l'épisode 1 — une fonction SECURITY DEFINER invocable par anon parce que Postgres avait ajouté EXECUTE TO PUBLIC que personne n'avait écrit. Mardi, je préparais l'épisode du jeudi sur la récursion RLS qui retourne zéro sans le dire. Mercredi matin, Pauline est passée dans le bureau avec un tableur et un sourire en coin.
Elle a posé son écran devant moi — pas de bruit, pas de préambule. Vingt lignes, colonne A nom, colonne B statut, colonne C date supposée d'archivage. « Je les retrouve tous ce matin, encore actifs dans le CRM. » Elle a dit ça avec la politesse de quelqu'un qui sait déjà que c'est un problème du code, pas de son tableur. Puis elle a attendu.
Vingt contacts archivés la veille en fin de journée. Toast verte, « Archivage réussi », message équipe envoyé pour confirmer. Le tableur de Pauline les retrouvait tous le lendemain matin, encore actifs, encore visibles dans le CRM. Pas d'erreur dans les logs. Pas de Sentry. Pas une ligne rouge nulle part. Vingt lignes muettes qui disaient simplement non, l'opération n'a pas eu lieu — et personne pour les entendre.
C'est l'épisode deux d'une semaine où Supabase m'a menti quatre fois. L'épisode un disait que les defaults SaaS mentent par confiance accordée. L'épisode deux dit autre chose et la même chose : un return value d'API ment quand le caller refuse d'ouvrir l'enveloppe.
Le contrat dont personne ne parle
@supabase/supabase-js a fait un choix défendable il y a longtemps : ne pas lever d'exception sur les erreurs DB. Une mutation rejetée — par une CHECK constraint, par une policy RLS, par un trigger qui dit non — ne fait pas crasher l'appelant. Elle rend la main proprement avec un objet { data, error } dans lequel data est null et error porte le message. Convention return-value-error, héritée de Go, défendable en isolation.
Conséquence redoutable. Un appelant qui ne destructure pas error n'a pas l'erreur. Pas masquée par un try / catch mal écrit, pas avalée par un .then() distrait. Tout simplement : personne ne l'a réclamée, donc personne ne l'a vue. Le contrat tient parce que les deux parties l'honorent ; il s'effondre dès qu'une partie regarde ailleurs.
La doctrine que je pose dans ~/doctrine-counterpart/CLAUDE.md appelle ça R10, Silent failure forbidden. Le mensonge le plus dangereux d'un système n'est pas celui qui crie une fausseté, c'est celui qui se tait sur un échec. Le silence est un état lisible — encore faut-il avoir un dispositif qui lit.
Le code qui a menti
J'ouvre le module d'archivage. Vingt lignes côté action serveur, dont celle-ci, au cœur :
// app/crm/actions.ts — archivage contacts (avant correctif)
async function archiverContact(id: string) {
const supabase = createSupabaseServer()
const now = new Date().toISOString()
await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
// pas de destructure, pas de check, pas de throw
return { ok: true }
}
Le return { ok: true } était écrit deux lignes plus bas, comme une déclaration de confiance que rien ne soutenait. Et la policy RLS, écrite quelques semaines plus tôt, n'incluait pas UPDATE sur contacts pour le rôle dont disposait Pauline. Le serveur Postgres a fait son travail : il a refusé la mutation, il a renvoyé l'erreur, et il est passé à la requête suivante. Personne n'a lu l'enveloppe. La toast verte s'est affichée.
Ce n'est pas un bug. C'est un contrat non-honoré par le caller. Et un contrat non-honoré est rigoureusement indistinguable d'un contrat respecté quand personne ne regarde — c'est précisément ce qui en fait un mensonge structurel et non un défaut ponctuel.
Pourquoi ESLint ne suffit pas
J'ai écrit une rule ESLint la semaine précédente, no-bare-await-on-supabase-mutation, qui attrape proprement le cas await supabase.from(...).delete().eq(...) orphelin en bout de statement. La rule a éliminé 56 occurrences sur le repo. Mais elle laisse passer une variante plus retorse :
// passe le linter, ment quand même
const { data } = await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
if (!data) toast.error('Erreur') // data null si row inexistante OU si error remplie
La destructure est là — data est lu — mais error ne l'est pas. Le test !data mélange deux cas pourtant distincts : la ligne n'existait pas et la policy a refusé. L'un est métier, l'autre est sécurité, et les deux remontent ici comme un seul « erreur » générique. Pour Pauline, erreur est un mot creux qui ne dit ni ce qu'il faut corriger ni à qui demander la permission.
Un linter règle ce que la grammaire laisse filer. Mais quand la grammaire est légale et que c'est l'usage qui ment, le linter passe à côté. Il faut un dispositif plus haut dans la pile.
Le wrapper qui force la destructure
Le contre-feu que j'ai posé tient en une couche. Un helper mutate() typé qui rend error non-optional dans sa signature de retour, ce qui force TypeScript à refuser la compilation si le caller ne lit pas le champ.
// 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 — le caller ne compile pas s'il omet `error`
const { data, error } = await mutate(
supabase.from('contacts').update({ statut: 'ancien' }).eq('id', id)
)
if (error) throw new Error(error.message)
TypeScript fait son travail. Et quand error est non-null, Sentry reçoit immédiatement un breadcrumb avec le code PG et les détails — avant même que le caller décide quoi faire. Je vois passer en review du lendemain les endroits où le contrat est encore violé. Le mensonge n'est plus possible : il est soit interdit par le compilateur, soit observé par l'instrumentation. Je préfère encore les deux.
Le coût ? Une couche d'indirection sur toutes les mutations, ce qui veut dire qu'un dev qui rejoint le projet et qui écrit await supabase.from(...).update(...) ne compile pas. Friction productive. Quoi qu'on puisse penser de la verbosité, elle vaut mieux qu'une toast verte mensongère.
Ce que Supabase n'invente pas
L'embarras n'est pas propre à Supabase et c'est important pour qui code dans d'autres écosystèmes. Le piège return-value-error avale silencieusement dans fetch().ok qu'on oublie de tester avant de lire response.json(), dans child_process.spawn qui rend un exit code non-zéro sans throw, dans certains SDK Stripe où la différence entre network error et response error tient à un champ qu'on doit aller chercher. Supabase est la vitrine d'un contrat plus large.
La règle qu'il faut tirer dépasse le cas Supabase et tient en une phrase. Un retour qui peut porter une erreur sans la lever est un mensonge tant qu'aucun mécanisme matériel n'oblige le caller à la lire. La discipline n'y suffit pas ; les rules linter n'y suffisent pas seules ; le typage qui rend le champ non-optional, couplé à une instrumentation qui détecte les contournements, finit par tenir.
Coda
Un return-value-error sans lecteur n'est pas une erreur, c'est une rumeur. Mercredi soir, j'ai poussé le wrapper, ré-archivé les vingt contacts de Pauline en une opération qui a cette fois levé proprement deux exceptions sur des rôles mal calés. La toast s'est affichée rouge. Pauline a soupiré — économe, le soupir — et a corrigé les permissions en deux clics. La rumeur était devenue une information.
Jeudi — épisode trois [CANONICAL URL EPISODE 3: à compléter après push] — une autre forme du même mensonge. Une policy RLS qui retourne zéro ligne sans dire pourquoi, et ce que j'ai fini par mettre à la place de mes policies après une journée entière à les déboguer.
Épisode 2 de la mini-série « La semaine où Supabase m'a menti 4 fois ». Le wrapper mutate() typé, pseudonymisé : github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper
Top comments (0)