TLDR: Running unrelated migrations triggers a PostgREST schema cache reload. If any of your embeds have latent FK ambiguity, that reload will enforce it — and one failure mode renders silent zeros instead of errors.
The Setup
We were running a security audit on a KOL CRM for an ecommerce business — a Next.js app backed by Supabase (Postgres + PostgREST, the API layer Supabase auto-generates over your tables) for managing Key Opinion Leader webinar campaigns.
Twenty-two findings. One night. Migrations for RPCs, an append-only audit log, RLS policy hardening across the board.
None of them touched webinars, topics, or any junction table.
What Broke
Three queries failed simultaneously the moment PostgREST reloaded its schema cache.
src/queries/analytics.ts — the /analytics route 500'd immediately. Loud and obvious.
src/queries/kol-detail.ts — KOL activity list. Latent, broke on render.
src/queries/supply-chain.ts — SILENT. A Promise.all that destructured .data without ever checking .error. Query fails, data is null, dashboard renders "Upcoming Webinars: 0" and looks completely healthy.
We had five webinars on the board.
Why It Happened
Back on migration 013_multi_kol_topic.sql, we'd added webinar_kols and webinar_topics junction tables for multi-KOL, multi-topic support — but the legacy webinars.kol_id and webinars.topic_id columns stayed put.
Now there were two relationships between webinars and topics. PostgREST's resource embedding (select=*,topics(*)) hit ambiguity and threw PGRST201 — Could not embed because more than one relationship was found.
Here's the part that still bothers me: it had been working fine for weeks.
PostgREST used to silently pick a relationship when it could make a guess. The cache reload changed that — it re-derived all relationships from scratch and refused to guess anymore. A migration that touched nothing about those two tables made their embeds blow up.
What I Tried First (and Why It Kept Coming Back)
I'll be honest — I chased this in circles for a bit.
First move: yanked the junction joins entirely to unblock production. Deployed. Fixed the 500. But now we had no multi-KOL data.
Tried removing aliases from the nested joins. Still broke.
Forced a schema reload via the Supabase dashboard — re-added the joins. Worked! …until the next migration triggered another cache reload. Right back to broken.
The commits tell the story: remove → re-add → remove → re-add → FK hints. Three cycles of the same mistake before it clicked.
The Fix That Actually Held
PostgREST has a hint syntax: include the FK constraint name directly in the embed.
topics(*) becomes topic:topics!webinars_topic_id_fkey(*).
One surgical change per ambiguous embed. The canonical set for this project:
topic:topics!webinars_topic_id_fkey(*)kol:contacts!webinars_kol_id_fkey(...)traffic_source:traffic_sources!webinars_traffic_source_id_fkey(*)topic:topics!webinar_topics_topic_id_fkey(*)— inside a junction row
Now the cache can reload a thousand times. The hint tells PostgREST exactly which FK to follow. No more guessing, no more surprises.
The Sweep Pattern
After any migration now — even one that's obviously unrelated — I run this before deploying:
grep -rn ':topics\|:contacts\|:traffic_sources' src/ | grep -v '_fkey'
Anything that matches is an unhinted embed that could break silently on the next reload. Add the hint before the cache makes the decision for you.
The error message, when it surfaces, is actually verbose and accurate — it names both tables. Paste them into a search across src/queries/ and you'll find the right hint to copy.
The thing that genuinely unsettles me is the silent zero. Promise.all doesn't throw on a Supabase query error — it returns { data: null, error: ... }. If you only destructure data, the error evaporates and your UI renders empty state that looks like real data.
That's the kind of bug that erodes your trust in your own dashboards. You start wondering which other "zeros" are actually failures.
Explicit error checks on every destructured query. Every time. Non-negotiable now.
P.S. The silent failure isn't really a PostgREST problem — it's a JavaScript problem.
const [{ data }] = await Promise.all([...])is completely legal code that quietly swallows errors at scale. Check the.error. Always.