DEV Community

Cover image for La requête PostgREST qui trie en silence par `ctid` : une semaine Supabase, distillée
Michel Faure
Michel Faure

Posted on

La requête PostgREST qui trie en silence par `ctid` : une semaine Supabase, distillée

Le quatrième appel de la semaine

Catherine appelle de Maisons-Laffitte un mardi de début mai, en milieu d'après-midi. "Ça bug, mais c'est vite corrigé." C'est sa formule, je la connais, et d'habitude elle dit vrai. Elle décrit la chose en trois phrases : l'export newsletter du segment inscrits revient avec quatre-vingt-douze noms, le planning affiche quatre-vingt-douze cours actifs, mais la page compteur en affiche quatre-vingt-neuf. Trois inscrits absents. Elle a vérifié dans la base directement, ils sont bien là. "Pourquoi trois étapes pour ça ?" Elle ne pose pas la question pour moi, elle la pose pour elle. Sauf que cette fois, je m'aperçois en raccrochant que c'est la quatrième fois de la semaine que je raccroche en pensant la même chose. Quatre incidents Supabase, quatre fixes, quatre tickets refermés. Et pas une seule exception levée par la base.

Je rouvre les trois précédents en parallèle et je pose les quatre côte à côte sur l'écran. Ce n'est pas quatre bugs. C'est un seul mode d'échec, décliné quatre fois.

Les trois premiers

L'épisode 1 portait sur les GRANT par défaut que Supabase pose sur les fonctions et les policies. Une fonction SQL créée sans REVOKE explicite hérite d'un accès anon que personne n'a écrit dans la migration, et que personne n'a vu en review parce que le diff ne le montre pas. La fonction marche. Elle est juste exécutable depuis l'extérieur. [CANONICAL URL EPISODE 1: à renseigner après publication de #48 — "3 incidents Supabase, une seule racine : SECURITY DEFINER hérite de EXECUTE TO PUBLIC"]

L'épisode 2, une cascade ON DELETE SET NULL couplée à une CHECK NOT NULL sur la colonne cible. Le DELETE parent tente le SET NULL, la CHECK rejette, et la transaction remonte une erreur qu'on lit comme un échec de suppression alors qu'elle masque une cohérence qu'on croyait acquise depuis trois mois. La requête échoue franchement, ce qui est plus charitable que les trois autres cas, mais le diagnostic part dans la mauvaise direction parce que personne n'a déclaré que les deux contraintes vivaient en tension. [CANONICAL URL EPISODE 2: à renseigner après publication de #49 — "Quand await mutation() ment : le destructure { error } qui sauve ton week-end"]

L'épisode 3, la récursion RLS quand auth.users consulte user_roles qui consulte auth.users. La première requête vivante après le déploiement de la policy effondre la session. La logique est correcte sur le papier. C'est l'évaluation côté Postgres qui ne peut pas terminer. [CANONICAL URL EPISODE 3: à renseigner après publication de #50 — "RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook"]

Et le quatrième, qu'on connaît déjà

Le quatrième, j'en ai déjà fait le récit isolé, la nuit du dropdown qui mentait, le compteur figé à mille pile sur une table qui en porte mille deux cent trois, le .select() chaîné sur .from() sans .order(), le Range HTTP qui plafonne à mille et le ctid qui sert d'ordre de fallback. [CANONICAL URL #40: https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57] Je ne réexpose pas ici la mécanique ni la rule ESLint qui finit par la fermer. Ce qui m'intéresse, en posant les quatre cas en regard, c'est que ce quatrième cas n'est pas une anomalie de plus. C'est le portrait le plus pur d'une famille à laquelle appartiennent aussi les trois autres.

Le profil commun

Quatre fois, le même geste de la base. Elle reçoit une requête, elle l'exécute, elle ne lève rien. Pas d'exception remontée jusqu'au client, pas de warning, pas de trace dans Sentry, pas de log applicatif. Le code de retour est 200. Le payload arrive. Et il ment d'une manière différente à chaque fois, payload incomplet pour le ctid, payload accessible pour le GRANT, payload mal cascadé pour la CHECK NOT NULL, payload absent pour la récursion RLS quand la requête termine en timeout silencieux côté infra.

Le test unitaire passe, parce qu'il porte sur cinquante lignes seedées localement. La revue de code passe, parce que la chaîne incriminée tient en une seule ligne lisible. La prod craque, parce que la prod a mille deux cents lignes, des utilisateurs anonymes qui appellent des endpoints qu'on croyait privés, et des policies qui font le tour d'une dépendance que le développeur n'a pas dessinée.

J'ai longtemps lu ces quatre cas comme quatre paragraphes du manuel Supabase qu'on n'avait pas lus assez. Je crois maintenant que c'est plus structurel. Un default vendor n'est pas un paragraphe à lire, c'est une décision de design implicite que le client de la plateforme adopte sans la voir, parce qu'elle n'apparaît nulle part dans son code à lui. Le Range à mille, l'accès anon par défaut, la récursion permise sur une auth-policy, le comportement d'un ON DELETE SET NULL face à une CHECK, autant de micro-contrats que personne n'a écrits dans le repo et qui tiennent pourtant la prod.

Trois règles pour fermer la porte

Quatre incidents en une semaine, ce n'est pas une statistique, c'est une convergence qui demande sa doctrine. Je laisse de côté l'idée d'écrire plus de tests, puisque aucun de ces quatre cas n'aurait été pris par un test unitaire, et que l'intégration n'aurait sauvé que le deuxième, à condition d'avoir le cas exact en fixture. Je préfère trois règles matérielles.

Première règle, tout default vendor non déclaré est un Snapshot implicite, à matérialiser au commit ou à interdire. Snapshot au sens de notre nomenclature interne, soit une valeur figée qu'aucun mécanisme ne rafraîchit. Quand la plateforme pose un comportement par défaut que mon code n'écrit pas, c'est une copie figée du contrat plateforme posée dans mon repo à mon insu. Le jour où Supabase modifie son max_rows, ou affine sa politique anon, je découvrirai le drift en prod. Donc soit le default est cité textuellement dans une règle CLAUDE.md ou un ADR et son comportement attendu est asserté, soit la pratique qui en dépend est interdite par un garde-fou matériel, rule ESLint, CHECK SQL, hook pre-commit. Pas de tiède.

Deuxième règle, la rule ESLint vaut mieux que la review de code dès que le pattern se chiffre en dizaines d'occurrences. Une chaîne .from().select() sans .order() se cache dans des centaines de fichiers. La review de code humaine ne peut pas tenir contre ce volume, elle attrapera deux occurrences sur dix, pas plus. Une rule AST, en revanche, balaye le repo en une passe, et c'est elle qui dit non à la PR suivante, mécaniquement, sans qu'un humain ait besoin de relire la diff à la loupe. Le coût initial est dans les garde-fous structurels qu'il faut prévoir pour ne pas crouler sous le faux positif. Je l'ai mesuré sur le cas ctid, et cinq mécanismes anti-bruit ont ramené la rule de cent soixante-dix-huit alertes brutes à cent huit cibles réelles, sans quoi elle aurait été désactivée par lassitude en moins d'une semaine.

Troisième règle, l'instrumentation doit sortir de prod, pas du test. Aucun de ces quatre defaults ne dépend de la logique du code, ils dépendent tous du volume, du contexte d'auth, de la fréquence d'appel ou de l'historique de la table. Le test unitaire ne les voit jamais. Donc le filet, c'est une sonde quotidienne sur la prod, qui mesure ce que la prod fait réellement et qui hurle quand un compteur ressemble suspectement à un plafond. Pourquoi exactement mille ? Pourquoi exactement zéro accès refusé sur un endpoint sensible ? Pourquoi cette policy n'a-t-elle jamais consommé de CPU ? Ce sont les questions qu'aucun test ne pose mais qu'une sonde drift quotidienne peut poser à la base elle-même.

Une quatrième règle, si l'on veut

Et si l'on veut un dernier coin tranchant, je le placerais là, ne jamais lire un default vendor depuis l'agent IA, le lire depuis la doc officielle et le matérialiser dans le repo. Quand on demande à un agent ce que fait Supabase quand on ne déclare pas .order(), il répondra plausiblement, et il y a une chance non nulle qu'il se trompe sur la valeur du cap, sur la version où le comportement a changé, ou sur l'ordre de fallback. Le matériel, c'est la doc, et le test grandeur nature, c'est une migration locale qu'on inspecte sur dix mille lignes seedées. L'agent peut écrire la sonde, il ne peut pas s'auto-attester. C'est exactement la posture qu'impose la doctrine que j'ai publiée ailleurs, citer le texte officiel et matérialiser les defaults vendor.

Coda

Quatre incidents en une semaine, un même mode d'échec. La base ne ment pas par malice, elle exécute des contrats que personne n'a explicitement signés dans mon repo et que mon test unitaire ne peut pas reproduire parce qu'il ne porte pas sur ces dimensions-là. La défense, ce n'est pas plus de vigilance, je vois assez nettement maintenant que la vigilance humaine se fausse contre quatre defaults invisibles cumulés. C'est plus de garde-fous matériels, posés une fois, qui interdisent à la classe entière d'incidents de revenir. Une rule ESLint, une CHECK constraint, une sonde drift, un ADR qui cite le default vendor, chacun de ces dispositifs vaut un mardi après-midi tranquille en plus dans la semaine. Quand Catherine rappellera, ce sera pour une cinquième raison, et celle-là, je préfère ne pas la connaître à l'avance.


Quatre épisodes Supabase, une mini-série. La rule ESLint complète et le helper fetchAll pseudonymisés vivent dans :
github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap

Cette typologie des defaults vendor silencieux est le cœur de R12 du Counterpart Toolkit (« cite the official text, materialise vendor defaults »). 14 règles, install en 1 commande : github.com/michelfaure/doctrine-counterpart

Top comments (0)