Postgres & Supabase Backend Setup
One schema, run once, works identically on Supabase's managed Postgres or your own Neon, RDS, CockroachDB, or self hosted instance. Every change is logged as a full row with an owner, so global rows reach everyone and per user rows reach only their owner. What differs is how a change reaches the browser. Supabase does that part for you and 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 on "I am 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 5 below. If it is anything else, use createWebSocketAdapter(), createSocketIOAdapter() or createSseAdapter() and Step 6 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 or SSE adapter chosen (any other Postgres path).
- A Postgres database: a Supabase project, or your own Neon, RDS, CockroachDB or self hosted instance.
- Enough privileges on that database to create tables, functions, and triggers.
- A decision for every table you will track: is it GLOBAL (the same rows for everyone) or PER USER (rows belong to one owner)?
The changelog table
user_id column, which records who is allowed to read each entry.Global rows versus per user rows
flux_resolve_owner() at the moment the row changes. A global row has a null user_id. It is the same for every client, so pricing tables, blog posts and currency rates belong here, and a logged out visitor may catch up on them. A per user row stores its owner's uuid. Only that user's catch up and live push ever receive it, so projects, profiles and anything private belong here.auth.uid() and returns rows where user_id is null or equals the caller. A logged out caller therefore sees global rows only. On a bridge backend the listener does the same job at broadcast time, using the owner carried in the NOTIFY.Returning null from flux_resolve_owner() publishes that row to every client that can call the delta function. The example raises an error for any table without a rule so a missed table fails on its first write instead of leaking. Keep that behavior. For child tables, resolve the owner through the parent, as project_domains does above.
The trigger: full rows, one entry per row
Each mutation replaces that row's entry, so flux_changelog holds at most one entry per entity. If you need an audit trail of every intermediate change, this schema is not it. Keep a separate append only log alongside it.
Do not add a public SELECT policy to flux_changelog. Entries hold full row snapshots and would expose every logged change. Leaving RLS on with no policy is correct. The SECURITY DEFINER functions are the only sanctioned read path, and they filter by caller themselves.
Per user filtering is only as correct as your owner rules. A wrong case in flux_resolve_owner() shows one user's rows to another, so test each table with two different accounts before shipping.
What actually differs: reaching the browser, not the schema
LISTEN and NOTIFY only reach 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, filtered per subscriber by RLS. Every other provider gives you the database half only. The bridge from NOTIFY to a client connection is something you build once, as a small always on process. Step 5 covers Supabase and Step 6 covers everyone else.On Supabase: enable Replication, then install the delta functions
createSupabaseAdapter() subscribes to the Realtime channel and needs nothing more from you for live events. Catch up on reconnect uses three functions: the single table delta, a batch version that serves every table in one HTTP request, and the reconcile function that confirms deletions the delta window cannot see.flux_changelog has RLS enabled with no SELECT policy by design. An INVOKER version runs as the calling role and passes RLS straight through. The call still succeeds but the rows array is empty every time, which the client reads as "nothing changed." Use SECURITY DEFINER as shown, keep the explicit GRANT EXECUTE, and let the function filter by auth.uid() itself.
The reply is sent with realtime.send on a public topic, flux:tbl:your_table, shared by every client subscribed to that table. The function only puts the caller's rows in the payload, and the adapter ignores any reply it did not ask for, but a hostile client could subscribe to the same topic and read replies meant for other users. For tables where even the existence of a catch up is sensitive, have the function return the rows as jsonb and read them in your own adapter instead of broadcasting.
Supabase's postgres_changes broadcasts are filtered per subscriber by the RLS policies that already govern that table, so a client only receives changes to rows it could SELECT. That is a real advantage over the bridge in Step 6, where you filter by owner yourself.
On any other Postgres: the bridge process, in full
LISTEN connection and translating each NOTIFY into a call against the registry your WebSocket, Socket.IO or SSE page already built. It reads the owner from the entry and passes it along, so a per user row reaches only its owner's connections and a global row reaches everyone.If your DATABASE_URL points at a transaction mode pooler (PgBouncer, Supabase's pooler on port 6543, RDS Proxy in transaction mode), LISTEN silently does not work through it. The pooler can hand your session's connection to another client between transactions, so notifications never reach the one that issued LISTEN. The listener needs its own connection string pointed at the database directly (session mode, port 5432).
The Supabase delta functions above are not used on this path. Your WebSocket, Socket.IO or SSE server answers catch up itself, by running the same query as flux_request_delta against flux_changelog with the connected user's id: rows where user_id is null or user_id = that user, ordered by changed_at, answering too old past the retention window. Reconcile is the flux_reconcile query run under that user's identity.
Retention
flux_reconcile confirms deletions the window cannot show.Upgrading from the older diff based schema
Testing your setup
auth.uid(), and that reconcile reports missing ids.One listener process, not one per server instance. If you run several copies of your backend behind a load balancer, run the Step 6 bridge in only one of them, or each replica will LISTEN and broadcast every change again.
Listener reconnect. The minimal error handler only logs a dropped connection. Production code should reconnect with backoff and issue LISTEN flux_changes again. Single state dedup softens the blow, because catch up on reconnect fetches each row's current entry, but live push stops silently in the meantime.
Owner rules are your access control. Whether a client sees a row is decided by flux_resolve_owner(), on both the Supabase path and the bridge path. Review it whenever you attach the trigger to a new table.
Server clock only. Timestamps and the sync anchor come from the database clock. The five second overlap in server_now exists so a transaction that commits late is delivered again on the next catch up. Applying a row twice is harmless.
Rate of catch up. The batch function caps at 50 tables per call and each table at 2000 entries. Past that the answer is too old and the client refetches, which is the correct outcome.
This page covers the database and delivery layer only. For the server code the Step 6 bridge forwards into, see getting_started/websocket, getting_started/socketio or getting_started/sse. For the deeper explanation of sync anchors and delta catch up across every transport, see core_concepts/sync_anchors.