-
-
Notifications
You must be signed in to change notification settings - Fork 437
docs(realtime): add MRE demonstrating JSONB filter limitation and col… #1802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a5d7034
5fed095
d5f5b0b
5ec3aa1
0740da6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 | ||
| ``` | ||
|
|
||
| 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) | ||
|
|
||
| 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 | ||
|
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] | ||
| 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
|
||||||||||||||||||||||||||||||||||||||
| -- 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); |
There was a problem hiding this comment.
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.mjsprints: 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.There was a problem hiding this comment.
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