supabase-wrote
v1.1.0
Published
Know when a Supabase update or delete did not save, and why. Row level security blocks them silently: 200, no error, nothing changed.
Downloads
1,179
Maintainers
Readme
supabase-wrote
Know when a Supabase update or delete did not save, and why.
When row level security blocks an update or delete, Postgres does not refuse it. The policy works as a filter: the query matches no rows, changes nothing, and comes back 200 with no error. Anything that trusts "no error" tells the user Saved for a write that never happened. Blocked inserts fail loudly, so this hides in the quiet paths: notes, renames, toggles, "mark as done".
supabase-wrote gives you three things:
wrote(): turns the silent case into an error you can catch, at the moment of saving.- The finder: a command that lists every update and delete in your code that cannot tell whether it saved. With a committed baseline, it fails CI only on new ones.
policies.sql: a read-only check that lists every table where updates or deletes will silently do nothing, and which policy is missing.
1. The helper
npm install supabase-wroteEnd the update or delete with .select(), so the changed rows come back, and pass the result to wrote():
import { wrote } from 'supabase-wrote'
const result = await supabase.from('notes').update({ title }).eq('id', id).select('id')
wrote(result, 'rename note') // returns the saved rows, or throws NotSavedOr in one step:
import { saved } from 'supabase-wrote'
await saved(supabase.from('notes').delete().eq('id', id).select('id'), 'delete note')NotSaved has a reason:
| reason | what happened |
|---|---|
| no-rows | No row matched, so nothing changed. Row level security blocked it, the row does not exist, or another filter in the query did not match, like a version check. |
| error | The database refused it. The original error is on err.cause. |
| unchecked | The query did not end in .select(), so there is no way to tell. |
no-rows means the WHERE matched nothing, and the helper cannot tell why. A stale edit is a third cause: if the query also checks a version column (.eq('version', 5)) and another client has already moved the row to 6, zero rows come back although the row exists and the caller may read it. That is the right answer, so do not retry without the version filter. Say what fits the screen: "Not saved" is always true, "Someone else changed this, reload" is right when the query carried a version.
Using .select().single() for a one-row save? On zero rows PostgREST answers with error PGRST116 ("The result contains 0 rows"). wrote() reports that as no-rows too, so the same check works either way.
One banner for the whole app
Most save handlers catch nothing, so a thrown error alone can vanish. Listen once and show a message:
import { onNotSaved } from 'supabase-wrote'
onNotSaved((err) => showBanner(`Not saved: ${err.message}`))Listeners run before the error is thrown. A listener that throws never hides the real failure.
Edge Functions (Deno)
import { wrote } from 'npm:supabase-wrote'2. The finder
npx supabase-wrote find .lib/notes.ts:9 .update(), no .select() after it: cannot tell if it saved
pages/bids.html:3 .update(), no .select() after it: cannot tell if it saved
worker.ts:235 fetch PATCH, no "return=representation" or "count=exact": cannot tell if it saved
jobs/clean.py:2 .update(), result never checked: data is [] when nothing saved
4 writes to check.What it reads:
| files | what it looks for |
|---|---|
| .js .ts .jsx .tsx .mjs .cjs .vue .svelte .astro .html | a .from(...) chain with .update( or .delete( and no .select( after it |
| .dart (Flutter) | the same rule, because supabase_flutter also returns nothing without .select() |
| plain fetch() to /rest/v1/ | PATCH or DELETE with no Prefer: return=representation or count=exact, so no rows or count come back |
| .py | a .table(...) or .from_(...) chain with .update( or .delete( whose result is never kept, or that asks for returning="minimal" |
It skips node_modules, build folders and virtual environments, and exits with code 1 when it finds something, so it can guard CI. It is a quick text scan, not a full parser.
Upserts are not listed. On the ON CONFLICT DO UPDATE path Postgres raises an error when the existing row fails the UPDATE policy's USING check, so an upsert either saves or throws (Postgres docs, CREATE POLICY, UPDATE section).
Some writes are meant to change nothing sometimes: marking messages as read when none are unread, or setting a value only once. Mark those with a wrote-ok comment (// or #) on the line above, on the same line, or inside the chain, and the finder skips them:
await supabase
.from('messages')
.update({ read_at: now }) // wrote-ok: nothing unread is fine
.is('read_at', null)In CI, on a project that already has some
On an existing app the finder will find writes on day one. Rather than fix them all before CI can go green, save what is there now as a baseline and commit it:
npx supabase-wrote find . --save-baseline
git add .supabase-wrote-baseline.jsonFrom then on npx supabase-wrote find . fails only on writes the baseline does not list. Writes are matched by file and line of code, not line number, so code moving up or down does not set it off, and the same line twice counts twice. A renamed file is reported as moved and does not fail, so a pure rename never goes red before the baseline is saved again. Only writes whose old file is gone count as moved: a line pasted into another file while the old file is still there is new, because a fixed write and an unrelated new one can share the same line of code. A line that moved and changed is new too. A formatter swapping " for ', or adding a trailing ; or ,, does not count as a change. A formatter that splits the line differently does, so run it once before saving the baseline.
When you fix writes, --prune-baseline drops the fixed ones and nothing else. Unlike --save-baseline it never adds a write, so it cannot wave a new one through, and new writes still fail.
In GitHub Actions each new write is also marked on its line in the pull request, with no setup. When you fix one, the run tells you, and --prune-baseline drops it. --no-baseline lists everything, and --baseline <file> keeps the file somewhere else.
3. Why did it not save? policies.sql
The most common cause is a table where a policy allows the update, but no policy allows reading the row. Postgres has to read a row to find it, so every update and delete there matches zero rows. Paste policies.sql into the Supabase SQL editor and run it. It only reads. It lists:
| level | finding | |---|---| | 1 | Every UPDATE (or DELETE) by this role silently changes nothing: a policy allows it, but no SELECT policy lets it find the row. | | 2 | The role is granted UPDATE (or DELETE) but no policy allows it, so every one silently changes nothing. |
Each row comes with the line to fix it. Nothing is run for you. service_role skips row level security, so it is never affected and not listed.
A SELECT policy that exists but does not match the row has the same effect. With using (auth.uid() = user_id) for SELECT, an update on another user's row finds nothing, even when the UPDATE policy allows it. policies.sql cannot see this, because it depends on the row, but wrote() reports it as no-rows when it happens.
Other languages
The same check, without the package:
# supabase-py returns the changed rows by default
res = supabase.table("notes").update({"title": title}).eq("id", note_id).execute()
if not res.data:
raise RuntimeError("Nothing was saved: row level security blocked it, the row does not exist, or a filter did not match")// supabase_flutter: end with .select() so the changed rows come back
final rows = await supabase.from('notes').update({'title': title}).eq('id', id).select('id');
if (rows.isEmpty) throw Exception('Nothing was saved: row level security blocked it, the row does not exist, or a filter did not match');Testing your own saves
The service_role key skips row security, so a test that saves with it always passes and can never catch a blocked write. Use it for setup and cleanup only, and run the save under test as a real signed-in user. Then test the other way round too: another user tries to change the row, and the test expects the refusal.
import { wrote, NotSaved } from 'supabase-wrote'
// admin = service_role client, for setup only. alice and bob = clients signed in as each user.
const { data: note } = await admin.from('notes').insert({ owner: aliceId, title: 'a' }).select('id').single()
// Alice can change her own note.
wrote(await alice.from('notes').update({ title: 'b' }).eq('id', note.id).select('id'))
// Bob cannot, and nothing changed.
await assert.rejects(async () => wrote(await bob.from('notes').update({ title: 'x' }).eq('id', note.id).select('id')), NotSaved)
const { data: after } = await admin.from('notes').select('title').eq('id', note.id).single()
assert.equal(after.title, 'b')What about maxAffected()?
PostgREST 13 can refuse an update or delete that touches more rows than you allow (.maxAffected(n) in supabase-js, Prefer: max-affected with handling=strict). That guards the top, not the bottom: zero rows still passes. Use both if you want both ends covered.
Tested against the real thing
npm test runs the unit and finder tests. test/live/ runs real Postgres row level security behind PostgREST 12.2.3 (the server every Supabase project uses), called through @supabase/postgrest-js (what supabase-js uses). test/policies/ runs policies.sql on a table for each finding and checks the exact output. What the database does, and what wrote() makes of it:
| case | database | wrote() |
|---|---|---|
| change your own row | 200, one row back | saved |
| change someone else's row, blocked by row security | 200, no error, empty list | no-rows |
| change a row that does not exist | 200, no error, empty list | no-rows |
| delete someone else's row, blocked | 200, no error, empty list | no-rows |
| update with no .select() | 204, no data | unchecked |
| table with no grant | 403, permission denied | error |
| allowed to update, not allowed to read | 200, empty list, and nothing changed | no-rows |
| blocked row with .select().single() | 406, PGRST116, 0 rows | no-rows |
| row found, but WITH CHECK refuses the new value | 403, new row violates row-level security policy | error |
So USING and WITH CHECK behave differently. When USING hides the row, the update is silent. When the row is found but WITH CHECK refuses the new value, Postgres raises an error. Only the silent kind needs this package, but wrote() reports both.
The "allowed to update, not allowed to read" row answers a fair worry: could it be a false alarm? No. Postgres needs read rights to find the row before it can update it, so nothing changes and the empty list is the truth. policies.sql flags that same table before anyone hits it.
Limits
no-rowscannot tell a blocked write from a row that does not exist. Both mean nothing was saved, and the message says so.policies.sqltells you when a policy is the cause.policies.sqlreads permissive policies foranonandauthenticatedin thepublicschema. It does not judge what your policies' conditions allow.- The finder is a text scan. A write built in an unusual way can slip past it, and a
wrote-okcomment silences a false alarm.
Licence
MIT. By Fuad Laguda.
