npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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:

  1. wrote(): turns the silent case into an error you can catch, at the moment of saving.
  2. 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.
  3. 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-wrote

End 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 NotSaved

Or 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.json

From 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-rows cannot tell a blocked write from a row that does not exist. Both mean nothing was saved, and the message says so. policies.sql tells you when a policy is the cause.
  • policies.sql reads permissive policies for anon and authenticated in the public schema. 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-ok comment silences a false alarm.

Licence

MIT. By Fuad Laguda.