PostgREST Silent Failures
Requests that succeed and do nothing. The client side half of the same problem.
Install
npx skills add halltony85-source/clickflame-agent-skills@postgrest-silent-failures -g -y
When your agent loads it
Find and prevent the class of bug where a Supabase or PostgREST app returns success and does nothing. Load this skill when a save does not save but shows no error, when a scheduled job or sweep looks healthy but produces no work, when a user reports 'it will not save' or 'nothing happened' with a clean console, when a column renders as undefined or a fallback value, when debugging a 400 or a 300 from PostgREST, when writing any loop over customer or job records, when adding a cron function or a poller, and before trusting that a form, webhook or intake endpoint is still wired to anything after a rebuild. Also load it when reviewing supabase-js queries, because an update that matches no rows and a select that omits a column both come back as success. Verify by calling, never by reading - this skill is how.
What it catches
- An update matching zero rows returns 200 and an empty list. Without
.select()you cannot tell a save from a no op. - A column used but not selected is undefined, so
a || btakesb. Fifty live quotes all displayed as expired. - A stored timestamp interpolated into a URL turns
+into a space and 400s. It works perfectly in testing, because a fresh date ends in Z. Three days of inbound leads went unanswered. - HTTP 300 is PGRST201, an ambiguous embed, not a redirect. A task ran 146 times and succeeded zero times while the board showed it healthy.
- A
current_account_id()column default resolves to null on the service key, manufacturing rows invisible to every tenant. - A loop over customer records with no per item catch is a queue with a poison pill. One motorhome with no model year took down a sweep for 14 hours.
- After a cutover, callers post into a 404 and the catch branch says something reassuring. The worst was dead for eight days behind four calls to action on a home page.
Where it came from
The same system, on the application side. Every pattern shipped, ran for days or weeks looking healthy, and was found by calling it rather than by reading it.
This one is free, like the rest of them
All work is by donation only. Nothing here is behind a payment, a trial or an email capture. If something we published saved you an afternoon, that is what the tag is for.
Donate on Cash App $clickflameThe complete skill, as published
There is a family of bugs in PostgREST applications that never throws, never logs, and never shows a red box. The request succeeds. The page renders. The number has a confident format. And nothing happened.
Every pattern below comes from a production outage that ran for days or weeks while every dashboard said fine. They share one cause:
The error path produced a value that was indistinguishable from a legitimate quiet result. Zero. An empty list. No output. No change. Each one is a plausible reading of a healthy system, so nobody looked.
The defense is the same every time and it is worth stating once, up front: verify by calling, not by reading. Most of these were invisible in the source and obvious on the first real invocation.
An update that matches no rows returns 200
await sb.from('vehicles').update(patch).eq('id', id)
With no .select(), this cannot tell a save from a no-op. PostgREST answers an update that matched zero rows with a 200 and an empty list, so error is null and every screen carries on as though it worked.
A service advisor pressed save three times on a vehicle and it never saved, with nothing on screen to say why. That row had account_id = null, the policy is account_id = current_account_id(), null equals nothing, so the row was invisible to the whole tenant and the update matched nothing.
How to write it so it cannot lie:
const { data, error } = await sb.from('vehicles')
.update(patch).eq('id', id).select('id')
if (error) throw error
if (!data?.length) throw new Error(`nothing updated for id ${id}`)
Wrap that in a mustUpdate() helper and use it for every write that has to land. Name the id in the error, because the next person to see it will be looking at a toast, not a stack trace.
When somebody says "it will not save" and there is no error, suspect RLS invisibility before you suspect the form. The one line check is select count(*) from <table> where account_id is null.
Session defaults resolve to null on the service key
The reason that row was orphaned is worth its own heading, because it manufactures invisible rows at a steady rate and nothing reports it.
vehicles.account_id defaulted to current_account_id(), which reads the caller's session. A server side job running on the service key has no session, so the default resolved to null. Every other insert in that file passed the tenant id explicitly and the vehicle insert did not. The job ticked every three minutes. Twenty eight rows were orphaned before anyone noticed.
A current_account_id() style default is not a safety net on any server side insert. Always pass the tenant id explicitly. The default exists for the browser client and it is silently absent everywhere else.
Repair for what already leaked out:
update vehicles v
set account_id = c.account_id
from customers c
where c.id = v.customer_id
and v.account_id is null;
A column used but not selected is undefined, not an error
PostgREST returns exactly the columns you named. A missing one is undefined, so a || b quietly takes b, and the screen looks plausible.
A quotes list showed "closed" on all fifty quotes, including ones raised that morning. The data was fine, the expiry column had 29 days left on it. The page's select() simply never asked for that column, so the value was undefined, the countdown took its fallback of created_at, which is always in the past, and the day count went negative. The one screen whose entire job was to say which quotes were still alive was reporting that every one was dead.
Two habits close this:
- **Whenever a render reads
row.something, checksomethingis in that
query's select()** - especially where a || fallback would absorb the miss. A sibling page reading the same table is not evidence; its query is a different query.
- Make fallbacks sane defaults. Falling back to
created_atfor an
expiry makes every row expired. The honest fallback was created_at + 30 days, which is the rule the business actually applies. A fallback that is not a sane default converts a missing value into a confident wrong answer.
A stored timestamp in a URL becomes a 400
Postgres spells a timestamp 2026-08-29T21:30:59.446007+00:00. In a URL a + decodes as a space, so interpolating a stored timestamp straight into a PostgREST filter produces an invalid timestamp and a 400.
A scheduled function did exactly that with a watermark it had read back from a settings table. It failed every five minutes for three days, 589 times on the worst day, and no inbound lead was answered in that window.
Why nobody noticed, and this is the important half: the function caught the error, logged it, and returned 200. A scheduled function that fails quietly is indistinguishable from one with nothing to do.
And it works perfectly in testing. new Date().toISOString() ends in Z and has no +. The bug only appears once a real value has been written and read back, which is to say only in production.
const floor = new Date(Date.parse(stored)).toISOString()
Normalize every timestamp that came out of the database before it goes into a URL. encodeURIComponent also works but does not fix a malformed stored value.
HTTP 300 is an ambiguous embed, not a redirect
PGRST201. It means two foreign keys join the two tables, so PostgREST refuses to guess which one a bare embed meant.
jobs_enquiry_id_fkey jobs.enquiry_id -> enquiries.id
enquiries_converted_job_fk enquiries.converted_job_id -> jobs.id
jobs?select=...,enquiries(notes) answers 300 forever. Name the constraint:
jobs?select=...,enquiries!jobs_enquiry_id_fkey(notes)
What it cost: a referral task returned "could not read the board (300)" on all 146 runs between being switched on and being found. It had never once succeeded. It went straight from off to broken, so no work was ever handed out, and the board showed a task running on schedule.
Any pair of tables where one points back at the other is exposed. Check before adding an embed, and confirm a suspect one in a single call rather than reasoning about it. The bare form answers 300, the named form answers 200. That takes ten seconds and settles it.
A task with zero successes is a failing task
This is the check that would have caught the previous one on day one, and it generalizes past PostgREST.
A scheduled task that has run 146 times and succeeded 0 times is not quiet. It is broken. But every dashboard that counts runs rather than outcomes shows it as healthy, and a task that went straight from off to broken has no better past to be compared against.
select task, count(*) as runs,
count(*) filter (where ok) as wins,
max(started_at) as last_run,
min(note) as sample_note
from scheduled_runs
group by task
order by wins, runs desc;
Read the zero row first. Then read the tasks whose note is always the same short word, because a task writing the same note on every tick is a task that is not doing its job.
The related trap: a task whose enable row was never inserted returns "off" forever. If your code starts with const a = await automation(key); if (!a?.enabled) return "off", then a missing row and a disabled row are the same value. One task ran every fifteen minutes for weeks writing off, with no switch anywhere in the product to turn it on, because its key had never been added to the settings screen either. Two earlier debugging sessions blamed a missing API key that was never the problem.
A new scheduled task needs three things, not one: the task file, its row in the settings table, and its key in whatever list the settings screen renders from.
A failing scheduled function is retried, so one bad record multiplies
This is the part people do not expect. A scheduled function that returns 500 is retried by the platform, so a single unprocessable record does not fail once. It fails on every retry, of every tick, until somebody intervenes.
That turns an ordinary uncaught exception into an outage with a heartbeat. One record whose model year could not be parsed produced 431 failures over 14 hours, three per tick, and four real customers behind it in the queue never got a price. The record itself was unremarkable: a motorhome described by engine and transmission rather than by year, against a NOT NULL year column.
for (const lead of leads) {
try { await handle(lead) }
catch (e) { await skip(lead.id, String(e?.message ?? e)); continue }
}
Every loop over leads, jobs or customers needs a per item catch that records the reason and continues. If you already have a skip(id, reason) for deliberate skips, failures should use it too, so both land in the same place somebody already looks.
The tell in the logs is a flat failure rate that matches a cron exactly. Three every three minutes, never varying, is not a flaky dependency. That cadence names the function without your reading any code:
select toStartOfMinute(timestamp) as m, count(*)
from postgres_logs
where event_message ilike '%null value in column%'
group by m order by m;
Grouping postgres_logs by event_message is the cheapest health check this stack has, and it finds outages nobody has reported yet.
After a rebuild, callers post into a 404
When a server is rebuilt or cut over, callers keep posting to routes that only the old deployment had. Every one of them fails quietly, because the caller's error branch says something reassuring.
The non-obvious half is CORS, not the missing route. An application/json body always triggers a preflight. If the server allows only some origins, that preflight falls through to the router and answers 404, which is indistinguishable from a route that does not exist. So a site can have two independent breaks producing one symptom, and fixing the route alone proves nothing because the POST still never leaves the browser.
What that combination costs when it goes unnoticed: a "get a quote by text" form returning 404 on every submission, hitting its catch branch, and telling the customer "That did not go through. You can also call." No lead, no record that anyone tried, 37 pages funnelling into it, dead for eight days.
Sweep for a fourth instance like this:
grep -rn "api\.yourdomain\.com/api/" ./site-repos
grep -rhno '"/api/[a-z0-9/-]*"' server/routes/*.ts server/index.ts | sort -u
Compare the two lists. Anything in the first that is not in the second is dead.
How to verify a public intake route without creating real work: give the endpoint a honeypot field, and fill it. A body of {"bot":"x"} returns {ok:true, skipped:"bot"} before anything is written, while still exercising routing, CORS and JSON parsing. To prove the whole path including the preflight, drive the real form in a browser with the honeypot set.
Do not test with a fake phone number. Spam checks usually do not catch 555, and if a sweep picks the row up it will really try to text it.
Two entry points into one handler
If a handler is reachable from both a webhook and a scheduled sweep, and nothing keeps them apart, it will run twice.
A customer who texted while a sweep was in flight got two model calls and two sends, 74 milliseconds to 5 seconds apart, some byte identical and some the same question reworded because the two runs read the thread at different moments. Sub-100ms gaps ruled out overlapping scheduler ticks. The race was inside one tick.
A module level Set is a real lock only while both callers live in one process. If the scheduler and the HTTP listener start from the same entry file, it holds. The moment that runs on more than one replica it silently stops working, and the lock has to move into the database as a claim taken with a conditional update.
const busy = new Set()
async function alone(key, fn) {
if (busy.has(key)) return 'held'
busy.add(key)
try { return await fn() } finally { busy.delete(key) }
}
Make the held count visible in the sweep's own note. A rising number of holds is information; silence looks identical to having nothing to do.
Before adding any "poke" route that calls an existing sweep, check whether that handler already has a second caller. This shape tends to appear several times in one codebase, because each one seemed like a small optimization on the day.
The verification moves, collected
When you are handed one of these, reach for these before reading source:
- Call it. Most of these were invisible in the code and obvious on the first
real invocation. A green unit test over the parts is not a test of the whole.
- Ask for outcomes, not runs. Zero successes in a task's entire history is a
failing task, not a quiet one.
- Group the Postgres logs by
event_message. A flat rate matching a cron
names the broken function for you.
- Add
.select()to any write that has to land, and check the returned
length.
- Read the query's
select()next to the render that consumes it. - Confirm a suspect embed or filter with one before-and-after call rather
than reasoning about the URL.
- Ask what the error path returns. If it returns zero, an empty list, or
nothing at all, you cannot tell it from success, and that is the bug whatever else is going on.
A .catch(() => 0) is a lie with a default. If a sweep, a poller or a monitor cannot do its job, it has to say so somewhere a person is actually looking.
Written from production incidents on a multi-tenant Supabase application, by Clickflame. Companion skill: supabase-rls-audit, for the same class of bug on the database side.
The companion skill
Supabase RLS Audit
Whether row level security is protecting anything, or only looking like it does.
Agent skillNetlify Deploy Traps
The deploy said Published. That is not the same as the right files, or the right rules, going out.
Agent skillAI Front Desk
An automated front desk does not fail by throwing. It fails by saying something confident and wrong to a real customer.