Postgres & Supabase Backend Setup
One schema, run once, works identically whether your database is Supabase's managed Postgres or your own Neon, RDS, CockroachDB, or self-hosted instance. What differs is only how a change actually reaches the browser — Supabase does that part for you; everyone else needs one small bridge process, given in full below.
The schema on this page is shared, but the client-side adapter is not — pick based on where your database actually lives, not based on "I'm using Postgres." createSupabaseAdapter() talks directly to Supabase's own managed Realtime WebSocket service; it has nothing to connect to on Neon, RDS, CockroachDB, or a self-hosted instance, because none of those run an equivalent service. If your database is Supabase, use createSupabaseAdapter() and Step 3 below. If it's anything else, use createWebSocketAdapter() or createSocketIOAdapter() and Step 4 below — there is no scenario where createSupabaseAdapter() is the right choice for a non-Supabase database.
- An existing Flux app already wired up on the client side, with the Supabase adapter chosen (Supabase path) or a WebSocket/Socket.IO adapter chosen (any other Postgres path).
- A Postgres database — a Supabase project, or your own Neon/RDS/CockroachDB/self-hosted instance.
- Enough privileges on that database to create tables, functions, and triggers.
The schema — identical on every provider
pg_notify call that Supabase ignores and everyone else needs.Each mutation replaces that row's changelog entry rather than appending beside it, so flux_changelog holds at most one row per entity, not one per edit. row_data isn't always a full row either — inserts get the full row, updates get only the fields that actually changed (merged across every update since the client's last sync, so nothing gets lost if a row is edited more than once while offline), and deletes get just an id. If you need a full audit trail of every intermediate change, this schema isn't it; you'd want a separate append-only log alongside this one.
Don't add a public SELECT policy to flux_changelog — inserts hold full row snapshots and would expose every logged change, not just the current state a normal table policy would allow. Leaving RLS on with no policy is correct here: the SECURITY DEFINER RPC in Step 3 is the only sanctioned read path, and it deliberately bypasses RLS rather than relying on a policy that could accidentally be loosened.
For a table with per-row visibility rules (private notes, per-user data), neither the Supabase RPC in Step 3 nor the listener process in Step 4 filters by caller — add that filtering at the layer that actually knows who's asking (your Socket.IO/WebSocket connection auth, or a matching condition in the RPC against auth.uid()) before attaching this to that table.
What actually differs: reaching the browser, not the schema
LISTEN/NOTIFY only reaches other database connections. Supabase closes that gap for you: enable Replication on a table and its managed Realtime service broadcasts every change over a WebSocket it already runs, no server code required. Every other provider gives you the database half only — the bridge from NOTIFY to an actual client connection is something you build, once, as a small always-on process. Step 3 covers Supabase; Step 4 covers everyone else.On Supabase: enable Replication, no server needed
createSupabaseAdapter() on the client subscribes directly to Supabase's Realtime channel and needs nothing further from you here. Delta catch-up on reconnect uses the RPC below, which calls back into the flux_changelog table from Step 1 — covered in full, including the RLS caveats, in core-concepts/sync-anchors:flux_changelog has RLS enabled with no SELECT policy by design (see Step 1). A SECURITY INVOKER version of this function runs as the calling anon/authenticated role and will pass RLS straight through — the RPC call still returns success, but the rows array comes back empty on every single call, regardless of what actually changed. This is easy to miss because nothing errors: catch-up silently does nothing. Use SECURITY DEFINER as shown above, and don't forget the accompanying GRANT EXECUTE — without it anon/authenticated can't call the function at all.
Supabase's postgres_changes broadcasts are filtered per-subscriber by the same RLS policies that already govern that table's normal reads — a client only receives a change event for a row it could otherwise SELECT. This is a meaningful advantage over the DIY path in Step 4, where the listener process broadcasts to a channel with no per-subscriber filtering unless you add it yourself.
On any other Postgres: the bridge process, in full
LISTEN connection open, translating each NOTIFY the Step 1 trigger fires into a call against the exact registry your WebSocket or Socket.IO getting-started page already built.If your DATABASE_URL normally points at a transaction-mode pooler (PgBouncer, Supabase's own pooler on port 6543, RDS Proxy in transaction mode), LISTEN silently doesn't work through it — the pooler can hand your session's underlying connection to a different client between transactions, so notifications never reach the one that issued LISTEN. This listener process needs its own connection string pointed at the database directly (session mode / port 5432), separate from whatever pooled connection string the rest of your app uses.
Whatever consumes these broadcast frames — your own WebSocket/Socket.IO client logic, or a custom adapter feeding Flux — needs to merge an 'UPDATE' event's data into existing state by id, not replace the record outright. Only 'INSERT' carries a full row; 'UPDATE' carries only the fields that changed since this row's changelog entry was last consumed.
Retention
flux_changelog far smaller than a full append-only log, but it isn't unbounded-free — every distinct row you've ever touched keeps one entry forever, and deleted rows leave a tombstone that's never replaced. This cleanup is plain SQL and works the same way everywhere — only whether pg_cron is available to schedule it varies:Testing your setup
UPDATE) and confirm your Flux app receives it — Supabase's Realtime Inspector (under the Realtime tab in your dashboard) also shows raw broadcast traffic if you want to rule out the client side first. If live updates work but a delta catch-up on reconnect comes back empty even though you know something changed while offline, that's the SECURITY DEFINER issue in Step 3's warning callout, not a client bug — check Postgres logs for the RPC call, or temporarily add a raise notice logging jsonb_array_length(v_rows) inside flux_request_delta to confirm row counts server-side. If catch-up returns rows but an update seems to be "missing" a field you know changed, check that you're merging 'UPDATE' payloads by id rather than replacing — see the callout in Step 4.One listener process, not one per server instance — if you run multiple copies of your backend behind a load balancer, only run the Step 4 bridge in one of them (or make it idempotent-safe to run in several); otherwise every replica independently LISTENs and each change gets broadcast multiple times to the same client.
Listener reconnect — the minimal error handler in Step 4 only logs a dropped connection; production code should reconnect with backoff and re-issue LISTEN flux_changes. Single-state dedup softens the blow here: even if the listener is down for a while, the Step 3/4 delta catch-up always fetches each row's current logged state on reconnect — with every field changed since the row was last consumed already merged in, not a growing backlog of missed intermediate events — but live push still stops silently in the meantime, so this still needs real handling.
RLS on the DIY path — unlike Supabase's Realtime, the Step 4 bridge broadcasts to an entire channel with no per-subscriber row filtering built in. If different clients on the same channel should see different rows, that filtering has to happen in your WebSocket/Socket.IO connection auth, not here.
Diff and merge only run inside Postgres — flux_jsonb_diff and the merge-on-pending-UPDATE logic both live in the trigger function, server-side. Nothing about the client adapter needs to know this happened; it just needs to treat 'UPDATE' events as patches, which createSupabaseAdapter() already does.
This page covers the database and delivery layer only — for the actual server code the bridge process in Step 4 forwards into, see getting-started/websocket or getting-started/socketio. For the deeper explanation of sync anchors and delta catch-up across every transport, see core-concepts/sync-anchors.