The night a dropdown lied to me
Eleven at night, late April. A user of my internal tool reports an incomplete filter on a dropdown. My first diagnosis blames a sub-filter on the UI side. I take it apart, I isolate the dataset source, I re-run without filters. The counter returns a thousand on the nose. The table holds one thousand two hundred and three rows. I walk up the pipeline, level by level, all the way to the source query. Four levels higher, the culprit appears: a .select() chained on .from(), no .order(), no .limit(). The query succeeds. It lies.
The mechanism
Any PostgREST query that doesn't declare its ORDER BY receives an internal ctid sort — the physical tuple identifier in Postgres — plus a 1000-row Range HTTP cap applied by Supabase. The query succeeds. No exception, no warning, no Sentry breadcrumb. The client gets a subset whose order depends on the table's UPDATE and DELETE history, reshuffled after a VACUUM FULL or pg_repack. The bug only exists above one thousand rows in production.
// silently capped at 1000 rows
await supabase.from('events').select('*').eq('type', 'login')
// the Range HTTP cap applies on a stable sort
await supabase.from('events').select('*').eq('type', 'login').order('id')
The ESLint rule that closes the door
The pattern is too quiet to live in code review alone. I moved it to lint, as an AST visitor on CallExpression, that requires a .select() chained on .from() to carry an .order() somewhere downstream, unless the chain terminates with .single(), .maybeSingle(), or an explicit .limit() below or equal to a thousand. It's one of five structural guards a workable Supabase rule needs. Without the others, the noise drowns the rule in under an hour.
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' })
},
}
},
}
The real scale
Once the rule was promoted to error, the first audit raised one hundred and seventy-eight alerts, spread over fifty-six files. Forty percent were false positives: variable reassigned at a distance, write returning, pagination helper injecting its own .order(), count head, single-row terminator. The five structural guards brought the noise down to one hundred and eight real targets before touching a single line of application code.
The rule
Every non-trivial .from(X).select(...) chain carries an explicit .order(). No option, no lukewarm.
Full rule, before/after pair and fetchAll helper, pseudonymized:
github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap
This silent PostgREST default is exactly the archetypal case of R12 of the Counterpart Toolkit ("cite the official text, materialise vendor defaults"). 14 rules, install in 1 command: github.com/michelfaure/doctrine-counterpart
Top comments (0)