Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ node_modules
.supabase
config/prod.secret.exs
demo/.env
examples/realtime-jsonb-filter-mre/.env
.lexical
.vscode
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ alter publication supabase_realtime add table test;

You can start playing around with Broadcast, Presence, and Postgres Changes features either with the client libs (e.g. `@supabase/realtime-js`), or use the built in Realtime Inspector on localhost, `http://localhost:4000/inspector/new` (make sure the port is correct for your development environment).

## Examples

- [Realtime JSONB Filter Limitation (MRE)](./examples/realtime-jsonb-filter-mre) – Demonstrates why Realtime filters don't work on JSONB expressions, and shows the recommended dedicated column pattern.

## WebSocket Connection

The WebSocket URL must contain the subdomain, `external_id` of the tenant on the `_realtime.tenants` table, and the token must be signed with the `jwt_secret` that was inserted along with the tenant.

If you're using the default tenant, the URL is `ws://realtime-dev.localhost:4000/socket` (make sure the port is correct for your development environment), and you can use `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDMwMjgwODcsInJvbGUiOiJwb3N0Z3JlcyJ9.tz_XJ89gd6bN8MBpCl7afvPrZiBH6RB65iA1FadPT3Y` for the token. The token must have `exp` and `role` (database role) keys.
Expand Down
2 changes: 2 additions & 0 deletions examples/realtime-jsonb-filter-mre/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_ANON_KEY=YOUR_ANON_KEY
145 changes: 145 additions & 0 deletions examples/realtime-jsonb-filter-mre/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Supabase Realtime JSONB Filter Limitation (MRE)

## Problem

Supabase Realtime `postgres_changes` filters only support direct column filters (e.g., `column=eq.value`). **JSONB expression filters are NOT supported**.

### Expected Behavior
When filtering on a JSONB field expression like `data->>organization_id`, events should be delivered to matching subscribers (like PostgREST filters).

### Actual Behavior
No events are received. The filter is silently ignored or rejected because Realtime does not evaluate SQL expressions.

## Setup Required

Before running the demo, you MUST apply the database migration.

Steps:
1. Open Supabase dashboard
2. Confirm the project URL matches the `[DEBUG] SUPABASE_URL` printed by the demo
3. Go to SQL Editor
4. Copy contents of migration.sql
5. Run it
6. Go to Project Settings → API → Exposed schemas and add `pgboss`

If not applied, you will see:

```text
[ERROR] Database schema not found
```

## Reproduction Steps

1. **Clone and setup:**
```bash
npm install
cp .env.example .env
# Add SUPABASE_URL and SUPABASE_ANON_KEY
```

2. **Apply migration:**
- Copy contents of `migration.sql`
- Run in your Supabase SQL editor

3. **Run the demo:**
```bash
npm start
```

4. **Expected output:**
```
[SETUP] Supabase Realtime JSONB Filter MRE
[SUBSCRIBED] Ready to receive events
[INSERT] Creating job with JSONB data...
[WAIT] Waiting 8s for realtime event...
[subscription] event received: { ... }
[RESULT] ✅ PASS: Realtime event received with direct column filter
```
Comment on lines +49 to +57
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expected output snippet doesn’t match what run-demo.mjs prints: the script logs [DEBUG] SUPABASE_URL = ..., the filter line, and a subscription status line before [SUBSCRIBED]. Update this snippet (and/or the script) so the README’s “Expected output” reflects the actual demo logs.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


See [expected-output.txt](expected-output.txt) for full example.

## Root Cause

Realtime does not evaluate or validate SQL expressions in filters. It only supports direct column equality:
- ✅ Supported: `column_name=eq.value`
- ❌ Not supported: `data->>'key'=eq.value`
- ❌ Not supported: `array_col[0]=eq.value`

This is by design because Supabase Realtime filters operate on logical replication (WAL) changes and do not evaluate SQL expressions like JSONB operators. Keeping the filter layer simple and performant is essential for scaling to thousands of concurrent subscriptions.

## Solution: Dedicated Column Pattern

Instead of filtering on JSONB expressions, mirror critical fields into dedicated scalar columns:

1. **Add column** – `organization_id TEXT`
2. **Backfill** – Extract from JSONB: `data->>'organization_id'`
3. **Sync with trigger** – Auto-update on INSERT/UPDATE
4. **Filter on column** – Use `organization_id=eq.value`
5. **Index** – Add for performance: `CREATE INDEX idx_job_organization_id`

### Files

- **migration.sql** – Creates schema, column, trigger, index, RLS policy
- **subscription-client.mjs** – Realtime subscription using correct filter
- **run-demo.mjs** – Demonstrates working filtered subscription
- **expected-output.txt** – Example successful run
- **package.json** – Dependencies

## Key Code Changes

### ❌ Broken (JSONB filter)
```javascript
.on('postgres_changes', {
schema: 'pgboss',
table: 'job',
filter: 'data->>organization_id=eq.org_123' // Does NOT work
})
```

### ✅ Fixed (direct column filter)
```javascript
.on('postgres_changes', {
schema: 'pgboss',
table: 'job',
filter: 'organization_id=eq.org_123' // Works!
})
```

### Database Trigger
```sql
create trigger sync_organization_id_trigger
before insert or update on pgboss.job
for each row
execute function sync_organization_id();
```

The trigger keeps `organization_id` in sync from JSONB on every write.

## Comparison

| Filter Type | PostgREST | Realtime | Reason |
|---|---|---|---|
| Direct column | ✅ Works | ✅ Works | Both support basic equality |
| JSONB operator | ✅ Works | ❌ Fails | Realtime doesn't evaluate SQL expressions |
| Array access | ✅ Works | ❌ Fails | Requires query evaluation |
| Function call | ✅ Works | ❌ Fails | Requires query evaluation |

**Why the difference?**
- **PostgREST**: Query-based API that evaluates full SQL expressions
- **Realtime**: Stream-based API that applies pattern matching on WAL replication events

## Conclusion

Supabase Realtime is a great real-time sync engine, but it's **not a query engine**. It only supports:
- Direct column filters
- Simple comparison operators (eq, neq, gt, gte, lt, lte, like, in)
- No SQL expressions or functions

For complex filtering on JSONB data, use the **dedicated column pattern** demonstrated here.

## Further Reading

- [Supabase Realtime Docs](https://supabase.com/docs/guides/realtime)
- [Realtime Filters Documentation](https://supabase.com/docs/guides/realtime#postgres_changes-schema)
- [PostgREST Filters](https://postgrest.org/en/stable/references/api/tables_views.html#operators)

32 changes: 32 additions & 0 deletions examples/realtime-jsonb-filter-mre/expected-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
NOTE:
This output assumes migration.sql has already been applied.

[SETUP] Supabase Realtime JSONB Filter MRE
[SETUP] Realtime filter used: organization_id=eq.org_123
Comment thread
yash07-bit marked this conversation as resolved.
[DEBUG] SUPABASE_URL = https://your-project.supabase.co
[subscription] status: SUBSCRIBED
[SUBSCRIBED] Ready to receive events
[INSERT] Creating job with JSONB data: { organization_id: "org_123" }
[INSERT] Row created with ID: 5f8a7f51-c13e-4baa-9ba8-9f0ec7f53d36
[INSERT] organization_id auto-filled: org_123
[WAIT] Waiting 8s for realtime event...

[subscription] event received: {
"schema": "pgboss",
"table": "job",
"eventType": "INSERT",
"new": {
"id": "5f8a7f51-c13e-4baa-9ba8-9f0ec7f53d36",
"organization_id": "org_123",
"data": { "organization_id": "org_123" },
"created_at": "2026-04-08T12:00:00.000000"
}
}

[RESULT] Summary:
[RESULT] Events received: 1

[RESULT]
[RESULT] ✅ PASS: Realtime event received with direct column filter
[RESULT] ✅ PASS: Trigger kept organization_id in sync
[RESULT]
81 changes: 81 additions & 0 deletions examples/realtime-jsonb-filter-mre/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- ============================================
-- Supabase Realtime: JSONB Filter Workaround (FIXED)
-- ============================================

-- Extensions & Schema
create extension if not exists pgcrypto;
create schema if not exists pgboss;

-- Table
create table if not exists pgboss.job (
id uuid primary key default gen_random_uuid(),
data jsonb,
created_at timestamp default now()
);

-- 1) Add scalar column for filtering
alter table pgboss.job
add column if not exists organization_id text;

-- 2) Backfill existing rows
update pgboss.job
set organization_id = data->>'organization_id'
where organization_id is distinct from data->>'organization_id';

-- 3) Trigger function (schema-safe)
create or replace function pgboss.sync_organization_id()
returns trigger as $$
begin
new.organization_id := new.data->>'organization_id';
return new;
end;
$$ language plpgsql;

-- 4) Trigger
drop trigger if exists sync_organization_id_trigger on pgboss.job;

create trigger sync_organization_id_trigger
before insert or update on pgboss.job
for each row
execute function pgboss.sync_organization_id();

-- 5) Index
create index if not exists idx_job_organization_id
on pgboss.job (organization_id);

-- 6) Enable RLS
alter table pgboss.job enable row level security;
alter table pgboss.job replica identity full;

-- 7) RLS Policy (IMPORTANT)
-- Replace with your JWT structure if needed
drop policy if exists "org based access" on pgboss.job;

create policy "org based access"
on pgboss.job
for select
using (
organization_id = auth.jwt() ->> 'organization_id'
);
Comment on lines +51 to +59
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The for select RLS policy requires a JWT claim (auth.jwt()->>'organization_id'), but the demo client uses SUPABASE_ANON_KEY and never authenticates, so select will typically be denied and Realtime events won’t be delivered (and inserts may not return rows on .select(...)). For the MRE to work out-of-the-box, either relax/selectively scope the demo select policy (e.g., allow anon/authenticated), or update the demo to sign in / supply a JWT that includes the organization_id claim and document that requirement.

Suggested change
-- Replace with your JWT structure if needed
drop policy if exists "org based access" on pgboss.job;
create policy "org based access"
on pgboss.job
for select
using (
organization_id = auth.jwt() ->> 'organization_id'
);
-- Demo-safe select policy so the MRE works with SUPABASE_ANON_KEY
-- without requiring a JWT that includes organization_id.
drop policy if exists "org based access" on pgboss.job;
create policy "org based access"
on pgboss.job
for select
to anon, authenticated
using (true);

Copilot uses AI. Check for mistakes.

-- (Optional: allow inserts if needed)
drop policy if exists "allow insert" on pgboss.job;

create policy "allow insert"
on pgboss.job
for insert
with check (true);

-- 8) Add to Realtime publication (idempotent)
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'pgboss'
and tablename = 'job'
) then
alter publication supabase_realtime add table pgboss.job;
end if;
end $$;
Loading