For two days our support inbox showed zero tickets. Zero open, zero closed, zero total, a friendly "No open tickets" message in the middle of the page. There were sixty. Two were open. One had come in that afternoon.
Nothing errored. Nothing logged. The page loaded fast and looked exactly like a quiet week. Here is the whole bug, in the shape it had before we fixed it:
const { data } = await db
.from("feedback")
.select("id, ref, subject, status, created_at, email_verified")
.eq("type", "ticket")
.order("created_at", { ascending: false });
return Response.json({ ok: true, items: data ?? [] });
Two days earlier we had added email_verified to that select, so an anonymous ticket could no longer pass as a customer's by someone typing a name and an address into the contact form. The column was added to one of our two databases and not the other. On the one without it, PostgREST answered 42703: column feedback.email_verified does not exist.
Why the page said "no tickets"
supabase-js does not throw on a query error. It resolves normally and hands you { data: null, error }. That line reads data, never looks at error, and data ?? [] turns "the database refused" into "there are none". Then ok: true goes out the door.
The console did the same thing one layer up:
api(`/api/tickets`).then((d) => d && setItems(d.items));
No check on ok. So a refusal set items to undefined, the length came out as zero, and the empty state rendered. Two layers, each assuming the answer had arrived, and the sum of them was a confident sentence nobody had checked.
What makes it nasty
This is not a bug you can read. The query is valid TypeScript. The column exists — in the other database. Every test that runs against a mocked client passes, because the mock returns rows.
The only way to see it is to run the real select against the real database and look at the error you were not looking at. We found it because a person noticed the inbox was empty and did not believe it.
Then we swept for the shape
A read whose error is dropped, defaulted to an empty collection, and handed back as the answer.
Eighty-two Supabase reads in the codebase drop their error. Most are fine — a null there genuinely means no rows. Narrowing to the exact signature, where the empty value is then returned as fact, left fourteen. Reading those fourteen found two more that were live:
- The list of "what was actually sent" on the email tab had been reporting nothing sent for weeks — it asked both databases for a table only one of them has.
- The customer's own ticket list. The same line, on the page a customer opens after writing in. A failed read told them they had never written.
That second one is the whole reason to sweep. The admin-side copy got found because an admin was looking. The customer-side copy had nobody to notice it.
What we changed
Not "check error" — everyone already knows that, and it was still missing three times.
1. A failed read answers 500 with the real message, not an empty list. The console shows "these tickets could not be loaded — this is not an empty inbox." Whoever is reading has to be able to tell "nobody wrote in" from "we could not ask", because those need opposite reactions and the quiet one looks like good news.
2. Where a list can legitimately be empty, the response says which it is. sent: [] with sent_unavailable: true is a different fact from sent: [] on its own, and the UI renders them differently.
3. A test that finds the shape and requires a sentence. It walks the routes for the exact pattern — error dropped, empty default, returned — and fails on any instance not in a reviewed list.
Each entry in that list is not a tick; it is one sentence saying what an empty result would assert when the read fails, and whether that is tolerable. Two of the eight survivors are wrong in the same way and merely cheap, and their entries say so. The point is that a new one cannot appear without somebody writing that sentence down.
That sentence is the thing nobody wrote for the ticket queue.
4. Every select, run against every database its client can reach. Not mocked. A script resolves each .from().select() to the client it is called on and executes it with limit 1.
It fails on 42703 only — a missing table is usually a per-tenant branch doing its job, and an alarm that fires on correct code is one nobody reads; a missing column on a table that exists is always drift. It skips loudly without credentials, saying in as many words that it did not run and is not a pass.
The one thing to take
An empty list is a claim. A dropped error is not entitled to make it.
If your code can say "there are none," make sure it can also say "I could not look" — and make sure a reader can tell which one they are being told.
Top comments (0)