DEV Community

Aman Gupta
Aman Gupta

Posted on Originally published at Medium AI-assisted

typescript-eslint suggests void. With supabase-js, that drops the request.

For the first 39 sends of a new feature in a production app I run, open tracking just didn't work. The sends went out fine. The opens never got recorded: 39 sends recorded zero opens. No exception, nothing in the logs, every test green. It came down to one word.

The call looked roughly like this (names changed):

void supabase.rpc('track_event', { id });
Enter fullscreen mode Exit fullscreen mode

That void was a habit. typescript-eslint's no-floating-promises complains about promises you don't await, and its message tells you to await it, handle it, or have it "explicitly marked as ignored with the void operator". The first suggestion it offers is "Add void operator to ignore." For an eager promise, like a storage upload or an auth call, that's fine, because the request is already on its way.

A supabase-js query builder isn't like that. It's a lazy thenable, and nothing gets sent until something calls .then() on it. await does that for you. void doesn't. So void supabase.rpc(...) builds the request and then throws it away.

It gets worse. Since typescript-eslint 8.0.0, no-floating-promises doesn't check thenables by default, so a builder you forgot on its own line isn't flagged by anything. And if you turn checkThenables on to catch those, the rule flags every builder and suggests void first, which is the one fix guaranteed to make sure the request never happens.

So I built an ESLint plugin that knows the difference: eslint-plugin-supabase. It has five rules, each for a Supabase mistake that's easy to ship without noticing. Four of them don't produce an error at all.

1. The query that never gets sent

supabase/no-unsent-query flags a lazy builder that's voided or just left sitting there as a statement:

void supabase.from('todos').insert({ title }); // voided: never sent
supabase.from('todos').update({ done: true }).eq('id', id); // never awaited: never sent
Enter fullscreen mode Exit fullscreen mode

You get two suggestions, await (inside an async function) and .then(). There's no autofix, because both change when the request runs, and that's your call to make. .then() chains, await, return and passing the builder somewhere else are all fine. Storage downloads are lazy too, so they're covered.

2. The subscription that never fires

supabase
  .channel('room')
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, onMessage)
  .subscribe((status) => console.log(status));
Enter fullscreen mode Exit fullscreen mode

If public.messages isn't in the supabase_realtime publication, this channel happily reaches SUBSCRIBED and then never gets a single event. The only error is a system message your subscribe callback never sees. That same app had a subscription like this sitting on an unpublished table while the table took 1,926 writes. It fired zero times.

supabase/realtime-table-published replays your migrations and reports the subscription, along with the fix: alter publication supabase_realtime add table public.messages;.

3. The read that comes back empty

const { data, error } = await supabase.from('orders').select('*');
// data: [], error: null
Enter fullscreen mode Exit fullscreen mode

If public.orders has row level security on and no select policy for the role your client uses, this read gives you an empty result and no error. An update or delete in the same situation quietly changes nothing, also without an error. (Inserts and upserts fail loudly with 42501, so the rule doesn't bother with them.)

supabase/rls-readable reads your policies from the migrations and reports the read, plus filtered updates and deletes.

4. The channel error nobody hears

supabase.channel('room').on('broadcast', { event: 'ping' }, onPing).subscribe();
Enter fullscreen mode Exit fullscreen mode

Call .subscribe() without a status callback and CHANNEL_ERROR and TIMED_OUT go nowhere. supabase/subscribe-status-handler asks you to pass one: .subscribe((status, err) => { ... }). If you already wrap subscribe in a helper that checks the status, add it to allowWrappers.

5. The call the 2026-10-30 change will break

This one comes with a date, so it gets its own section further down.

Why a linter needs your schema

Supabase's own database linter already catches something close to the empty read (the third one above): lint 0008_rls_enabled_no_policy flags every table with row level security on and no policy. But plenty of those tables are like that on purpose. The app only reads them through a function, and "no policy" is the design.

You can't tell a deliberate table from a broken one by looking at the database alone. You can't tell from the client code alone either: supabase.from('orders').select() looks fine until you know what the migrations say about orders. You need both. So the schema rules replay supabase/migrations into a model of the database (tables, grants, row level security, policies, publication membership) and check every client call against it. A table that's only read through a function never trips rls-readable, because there's no direct client call to report.

The plugin also works out which role each client acts as. A client created with a publishable or anon key is anon or authenticated. A client created with the service role key bypasses row level security, so rls-readable leaves it alone. When the plugin can't trace a client at all, it skips the call and counts the skip. When it can trace the client but can't tell what kind of key it got (createClient(url, config.key)), it treats it like a publishable client by default (unknownKey: "default"), because in client code that's almost always what it is. Set unknownKey: "skip" if you'd rather skip those calls.

2026-10-30: find the call sites before the change does

Supabase has announced that from 2026-10-30, new tables, views and sequences in public stop getting automatic grants for anon, authenticated and service_role on every existing project. A migration that creates a table and forgets the grant will apply cleanly, row level security will look right, and the first request will fail.

After the change, a client call on a new table that the role has no grant for fails with 42501. supabase-grants-lint catches it at the migration. This plugin catches it at the line of client code that will break. A Realtime subscription on such a table fails silently instead: it connects and never receives any data.

supabase/table-granted knows which privileges each call needs (an upsert needs insert, update and select; a filtered delete needs delete and select) and reports a call when no role the client can act as has them:

authenticated has no insert privilege on `public.orders` in your migrations, so this call fails with
42501 (permission denied). Fix in a migration: `grant insert on public.orders to authenticated;`
Enter fullscreen mode Exit fullscreen mode

Right now, before the change, the replayed defaults still grant almost everything, so this rule is quiet on most projects. And the replay doesn't look at the calendar. It only sees the change once your project records it, either with the opt-in migration (the revoke SQL Supabase published) or with grants-lint's since setting. Without one of those, the rule stays quiet after 2026-10-30 too. Once it's recorded, every table your next migration creates gets checked at the call site, in your editor, before the request fails in production.

What it found in 99 public repos

I ran the recommended config over 99 public repositories that have Supabase migrations, TypeScript sources and supabase-js. I pinned the commits and linted them as they are. Out of those 99:

  • 35 of 99 (35.4%) had at least one finding.
  • 15 of 99 (15.2%) subscribed with postgres_changes to a table their migrations never add to the publication.
  • 11 of 99 (11.1%) had a read, update or delete that row level security turns into a silent no-op, by their own migrations.
  • 8 of 99 (8.1%) had a query that's never sent, either voided or left floating.
  • 29 of 99 (29.3%) had a .subscribe() with no status callback.
  • 2 of 99 (2.0%) already had a call with no privilege behind it, before the change.

Some honest caveats. The corpus only sees migrations, so a table published or a policy added by hand in the dashboard is invisible to it, and a finding can be right about the migrations and still not happen in that repo's production database. It's not a random sample of Supabase apps either. Calls the plugin can't resolve are skipped, so treat these numbers as lower bounds. I also did a blind hand review of 50 schema findings, drawn at random from a fresh run I hadn't looked at before. It turned up 2 false positives (4.0%), and one of those came from my own corpus harness and is fixed. I'm not naming any of the repositories, and I won't.

Try it

You'll need a TypeScript project with typed linting:

npm install --save-dev eslint-plugin-supabase eslint @typescript-eslint/parser "typescript@>=5.8 <6.1"
Enter fullscreen mode Exit fullscreen mode
// eslint.config.mjs
import parser from '@typescript-eslint/parser';
import supabase from 'eslint-plugin-supabase';

export default [
  {
    files: ['**/*.ts', '**/*.tsx'],
    ignores: ['**/supabase/functions/**'],
    languageOptions: {
      parser,
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
  },
  supabase.configs.recommended,
];
Enter fullscreen mode Exit fullscreen mode

Then run npx eslint .. The ignores line keeps Deno edge functions out of typed linting. The two code rules work on any project. The three schema rules look for supabase/migrations next to your code (and read grants-lint.config.json if you have one). If you created your tables in the dashboard, run supabase db pull once so your migrations actually create them. The plugin doesn't open a network connection, doesn't read your .env file and doesn't send telemetry.

One more step for the 2026-10-30 change. Once your project is past it (or you opted in early from the dashboard), tell the replay: add the opt-in migration (the revoke SQL Supabase published), or set since in grants-lint.config.json to the version of your last migration before the change. Without one of those, the replay keeps the old automatic grants and table-granted stays quiet on new tables.

How I built it

The schema rules don't parse SQL themselves. They use the replay engine from supabase-grants-lint, the migration linter I wrote for the same change, through a new engine export. That way the CLI and the editor agree on what your migrations mean.

Before writing this, I pointed the plugin at the history of the app where both incidents happened. At the commit before the void fix, no-unsent-query reports the call. At the fix, it goes quiet. The subscription was harder. That app adds tables to the publication inside a do block, which a replay can't read, so the plugin skips those subscriptions instead of guessing (reportUnresolved names the block and asks for plain alter publication statements). With the block rewritten as plain statements, realtime-table-published reports the unpublished subscription and nothing else.

Every guard in the rules is mutation-tested, and every rule has a test for each case it leaves alone on purpose.

It doesn't check the 1,000-row PostgREST limit. For that, use rowcap by anabecalab, which does it well.

Try it on your project

Run it once before 2026-10-30. If it finds something, or flags something that isn't a bug, please open an issue. A false positive is a bug, and I want to fix it this week.

I used AI to help draft and edit this post; the plugin, the tests and the numbers are mine.

Not affiliated with or endorsed by Supabase.

Top comments (0)