--- name: postgrest-silent-failures description: "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." license: MIT metadata: author: Anthony Limpert organization: Clickflame version: "1.0.0" date: September 2026 abstract: A field guide to silent failure in PostgREST and Supabase applications, written from production outages. Covers zero row updates that return 200, columns used but not selected, stored timestamps that break URLs, ambiguous embeds, session defaults that resolve null on the service key, poison pill sweeps, and endpoints that quietly 404 after a rebuild. Includes the verification moves that catch each one. --- # PostgREST Silent Failures 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 ```js 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:** ```js 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 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: ```sql 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: 1. **Whenever a render reads `row.something`, check `something` is 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. 2. **Make fallbacks sane defaults.** Falling back to `created_at` for 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. ```js 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. ```sql 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. ```js 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: ```sql 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:** ```bash 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. ```js 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](https://clickflame.com). Companion skill: `supabase-rls-audit`, for the same class of bug on the database side.*