DEV Community

Cover image for The PostgREST query that silently ORDER BY ctid: a Supabase week, distilled
Michel Faure
Michel Faure

Posted on • Originally published at dev.to

The PostgREST query that silently ORDER BY ctid: a Supabase week, distilled

The fourth call of the week

Catherine calls from the Maisons-Laffitte site on a Tuesday afternoon in early May. "It's broken, but it's a quick fix." That's her line — I know it, and she's usually right. She describes it in three sentences: the newsletter export for the enrolled-students segment comes back with ninety-two names, the planning view shows ninety-two active courses, but the counter page shows eighty-nine. Three enrolled students missing. She'd checked the database directly — they're all there. "Why three steps for that?" She's not asking for my benefit. She's asking for herself. Except this time, hanging up, I realize it's the fourth time this week I've hung up thinking the same thing. Four Supabase incidents, four fixes, four closed tickets. And not a single exception raised by the database.

I reopen the three previous ones and lay all four side by side on screen. This isn't four bugs. It's one failure mode, declinated four times.

The first three

Episode 1 was about the default GRANTs Supabase places on functions and policies. A SQL function created without an explicit REVOKE inherits anon access that nobody wrote in the migration, and that nobody caught in review because the diff doesn't show it. The function works. It's just callable from outside. [CANONICAL URL EPISODE 1: to fill in after publication of #48 — "3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC"]

Episode 2, an ON DELETE SET NULL cascade coupled with a CHECK NOT NULL on the target column. The parent DELETE attempts the SET NULL, the CHECK rejects it, and the transaction surfaces an error we read as a deletion failure — while it actually masks a consistency assumption we'd held for three months. The query fails loudly, which is more charitable than the other three cases, but the diagnosis heads in the wrong direction because nobody had declared that the two constraints lived in tension. [CANONICAL URL EPISODE 2: to fill in after publication of #49 — "When await mutation() lies: the { error } destructuring that saves your weekend"]

Episode 3, RLS recursion when auth.users queries user_roles which queries auth.users. The first live request after the policy is deployed takes down the session. The logic is correct on paper. It's the Postgres evaluation that can't terminate. [CANONICAL URL EPISODE 3: to fill in after publication of #50 — "RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook"]

And the fourth, which we already know

The fourth, I already told that story in isolation — the night of the lying dropdown, the counter stuck at exactly one thousand on a table carrying one thousand two hundred and three, the .select() chained on .from() with no .order(), the HTTP Range capped at a thousand and ctid stepping in as fallback sort order. [CANONICAL URL #40: https://dev.to/michelfaure/why-your-supabase-query-stops-at-exactly-1000-rows-and-never-tells-you-4g57] I won't re-expose the mechanics here, nor the ESLint rule that eventually shuts it down. What interests me, laying all four cases side by side, is that this fourth case isn't just one more anomaly. It's the purest portrait of a family that also includes the other three.

The common profile

Four times, the same gesture from the database. It receives a query, it executes it, it raises nothing. No exception surfaced to the client, no warning, no trace in Sentry, no application log. Return code is 200. The payload arrives. And it lies in a different way each time — incomplete payload for ctid, accessible payload for the GRANT, badly cascaded payload for the CHECK NOT NULL, absent payload for the RLS recursion when the query ends in a silent timeout on the infra side.

The unit test passes, because it runs against fifty locally seeded rows. The code review passes, because the incriminating chain fits on a single readable line. Prod breaks, because prod has twelve hundred rows, anonymous users calling endpoints we thought were private, and policies that loop around a dependency the developer never drew.

I used to read these four cases as four paragraphs of the Supabase manual we hadn't read carefully enough. I now think it's more structural than that. A vendor default isn't a paragraph to read — it's an implicit design decision that the platform's client adopts without seeing it, because it appears nowhere in their own code. The thousand-row Range, the default anon access, the recursion permitted on an auth-policy, the behavior of ON DELETE SET NULL against a CHECK — these are micro-contracts nobody wrote in the repo that nonetheless hold up production.

Three rules to close the door

Four incidents in one week isn't a statistic, it's a convergence that demands doctrine. I'll leave aside the idea of writing more tests, since none of these four cases would have been caught by a unit test, and integration would only have saved the second one given exactly the right fixture. Three material rules instead.

First rule: every undeclared vendor default is an implicit Snapshot — materialize it at commit time or forbid it. Snapshot in the sense of our internal nomenclature: a frozen value that no mechanism refreshes. When the platform sets a default behavior that my code doesn't write, it plants a frozen copy of the platform contract in my repo without my knowledge. The day Supabase changes its max_rows, or refines its anon policy, I'll discover the drift in production. So either the default is cited textually in a CLAUDE.md rule or an ADR and its expected behavior is asserted, or the practice that depends on it is blocked by a material guard — ESLint rule, SQL CHECK, pre-commit hook. No lukewarm middle ground.

Second rule: the ESLint rule beats code review as soon as the pattern counts in the dozens. A .from().select() chain without .order() hides in hundreds of files. Human code review can't hold against that volume — it will catch two occurrences out of ten, not more. An AST rule, by contrast, sweeps the repo in one pass, and it's what says no to the next PR, mechanically, without a human needing to squint at the diff. The upfront cost is in the structural guards needed to avoid drowning in false positives. I measured it on the ctid case: five anti-noise mechanisms reduced the rule from one hundred seventy-eight raw alerts to one hundred eight real targets, without which it would have been disabled from lassitude within a week.

Third rule: instrumentation must come from prod, not from tests. None of these four defaults depends on the logic of the code — they all depend on volume, auth context, call frequency, or table history. The unit test never sees them. So the net is a daily prod probe, measuring what prod actually does, and screaming when a counter suspiciously resembles a ceiling. Why exactly one thousand? Why exactly zero refused accesses on a sensitive endpoint? Why has this policy never consumed CPU? These are questions no test asks, but a daily drift probe can ask the database itself.

A fourth rule, if you want one

And if you want one last sharp wedge, I'd place it here: never read a vendor default from an AI agent — read it from the official docs and materialize it in the repo. When you ask an agent what Supabase does when you don't declare .order(), it will answer plausibly, and there's a non-zero chance it gets the cap value wrong, or the version where the behavior changed, or the fallback order. The material source is the docs, and the real-world test is a local migration you inspect against ten thousand seeded rows. The agent can write the probe — it can't self-attest. That's exactly the posture that the doctrine I've published elsewhere imposes: cite the official text, materialize vendor defaults.

Coda

Four incidents in one week, one failure mode. The database doesn't lie out of malice — it executes contracts nobody explicitly signed in my repo and that my unit tests can't reproduce because they don't operate on those dimensions. The defense isn't more vigilance; I can see clearly now that human vigilance bends against four invisible defaults stacked on top of each other. It's more material guards, set once, that prevent the whole class of incidents from returning. An ESLint rule, a CHECK constraint, a drift probe, an ADR that names the vendor default — each of those buys back a quiet Tuesday afternoon. When Catherine calls again, it will be for a fifth reason, and that one I'd rather not know in advance.


Four Supabase episodes, one mini-series. The full ESLint rule and the pseudonymized fetchAll helper live at:
github.com/michelfaure/rembrandt-samples/tree/main/postgrest-row-cap

This vendor-default typology is the core of R12 in the Counterpart Toolkit ("cite the official text, materialise vendor defaults"). 14 rules, one-command install: github.com/michelfaure/doctrine-counterpart

Top comments (0)