DEV Community

Cover image for Pourquoi ta requête Supabase s'arrête à exactement 1000 lignes (et ne te le dit jamais)
Michel Faure
Michel Faure

Posted on

Pourquoi ta requête Supabase s'arrête à exactement 1000 lignes (et ne te le dit jamais)

La nuit où un dropdown m'a menti

Vingt-trois heures, fin avril. Une utilisatrice de mon outil interne me signale un filtre incomplet sur un dropdown. Mon premier diagnostic accuse un sous-filtre côté interface. Je le démonte, j'isole la source du dataset, je relance sans filtre. Le compteur retourne mille pile. Or la table en porte mille deux cent trois. Je remonte le pipeline, niveau par niveau, jusqu'à la requête source. Quatre niveaux plus haut, le coupable apparaît : un .select() chaîné sur .from(), sans .order(), sans .limit(). La requête réussit. Elle ment.

Le mécanisme

Toute requête PostgREST qui ne déclare pas son ORDER BY reçoit en interne un tri par ctid, l'identifiant de tuple physique Postgres, et un plafond Range HTTP à 1000 lignes appliqué par Supabase. La requête réussit. Aucune exception, aucun warning, aucune trace dans Sentry. Le client reçoit un sous-ensemble dont l'ordre dépend de l'historique des UPDATE et DELETE de la table, rebrassé après un VACUUM FULL ou un pg_repack. Le bug n'existe qu'au-dessus de mille lignes en prod.

// silencieusement plafonné à 1000 lignes
await supabase.from('events').select('*').eq('type', 'login')

// le Range HTTP applique son LIMIT sur un tri stable
await supabase.from('events').select('*').eq('type', 'login').order('id')
Enter fullscreen mode Exit fullscreen mode

La règle ESLint qui ferme la porte

Le pattern est trop discret pour tenir uniquement dans la revue de code. Je l'ai migré côté lint, en visitor AST sur CallExpression, qui exige qu'un .select() chaîné sur un .from() porte un .order() quelque part en aval, sauf lorsque la chaîne se termine par .single(), .maybeSingle(), ou un .limit() explicite à valeur inférieure ou égale à mille. C'est l'un des cinq garde-fous structurels d'une rule Supabase exploitable. Sans les autres, le bruit submerge la rule en moins d'une heure.

export default {
  meta: { type: 'problem', messages: { unordered:
    'select() without .order() falls back to ORDER BY ctid.' } },
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee?.property?.name !== 'select') return
        if (!chainContainsFromCall(node.callee.object)) return
        if (chainHasSafeTerminator(node)) return        // .single, .order, .csv...
        if (selectOptsHasHeadTrue(node)) return         // count head
        if (chainEndsAtAssignment(node)) return         // let q = supabase...
        if (chainIsInsideHelper(node, 'fetchAll')) return
        context.report({ node, messageId: 'unordered' })
      },
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

L'ampleur réelle

Une fois la rule promue en error, le premier audit a remonté cent soixante-dix-huit alertes, étalées sur cinquante-six fichiers. Quarante pourcent étaient des faux positifs : variable réassignée à distance, write returning, helper de pagination qui injecte son propre .order(), count head, terminateur monoligne. Les cinq garde-fous structurels ont ramené le bruit à cent huit vraies cibles avant de toucher la moindre ligne applicative.

La règle

Toute chaîne .from(X).select(...) non triviale porte un .order() explicite. Pas d'option, pas de tiède.


Rule complète, paire avant/après et helper fetchAll pseudonymisés :
github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap

Ce default PostgREST silencieux est exactement le cas archétypal 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)