Realtime (Postgres LISTEN/NOTIFY)
Multiplayer/live updates without a message broker — one source of truth (Postgres) with minimal moving parts.
Multiplayer/live updates without a message broker — the architecture the product blog post argues for. One source of truth (Postgres), minimal moving parts.
Channel grammar (canonical — one spelling everywhere)
All channels are colon-delimited and tenant-prefixed. This is the single grammar; docs 10 and 20 defer to it (the old NOTIFY session_<id> underscore form is retired):
t:<tid>:entity:<entityId> # an entity changed (live-ops default)
t:<tid>:aoi:<tileId> # an area-of-interest tile changed (map viewport)
t:<tid>:case:<caseId> # a case's bound items changed
t:<tid>:session:<sid> # WARGAME ONLY — a session's world-state changedLive-ops (the M0 maritime use case) has no sessions — a new AIS observation NOTIFYs the affected entity and aoi channels, never a session channel. session: is wargame-specific.
-- inside the committing transaction (ingest or mutation)
SELECT pg_notify('t:'||:tid||':entity:'||:entityId,
json_build_object('kind','event','entity',:entityId)::text);
SELECT pg_notify('t:'||:tid||':aoi:'||:tileId,
json_build_object('kind','observation','aoi',:tileId)::text);The key rule: a notification is a hint, not data
The payload is a cache-invalidation nudge ("re-query this slice"), never the data itself. On receipt, the client runs an ordinary authorized read, which re-applies RLS — so a misrouted notification leaks nothing, and realtime updates pass the same authz as a cold load. Payloads stay under Postgres's 8KB NOTIFY cap by design.
Coarse channels for live-ops (not one LISTEN per fine-grained channel)
One LISTEN per session is fine for wargame rooms of dozens. A live map with thousands of entities/AOIs is different — a LISTEN connection per fine-grained channel would compete for the very Cloud SQL connection budget 60 worries about. So:
- The fan-out process holds a small, fixed set of coarse channels — e.g. one per tenant (
t:<tid>:fanout) or one per AOI tile — and the relayed payload carries the fine-grainedentity/aoiid. - The client filters to the entities/AOIs it currently cares about (its map viewport / open case). Fine-grained routing happens client-side, not via thousands of server
LISTENs. - Wargame sessions can still use a dedicated
session:channel (bounded count).
Reliability
- Fire-and-forget: clients also poll a cheap
session_version/tenant_versioncounter as a backstop and reconcile on reconnect (a dropped notify during a fan-out restart self-heals). - WebSocket re-auth: a long-lived socket will outlive the short JWT (
40). On token expiry the client performs a silent re-auth handshake (refresh token → new JWT → re-subscribe) rather than being dropped; the server closes sockets whose token has expired and no valid refresh arrives.
Where it runs
A dedicated realtime Cloud Run service with min-instances ≥ 1 so the LISTEN connection persists. This means realtime never scales to zero — note the cost consequence: combined with per-tenant Cloud SQL + Atlas, a tenant carries a 24/7 floor cost, so the "scales to zero / cheap idle trials" framing in 50 does not apply to any tenant that needs live updates (see the tiering decision in 00). Transport: WebSocket for fan-out; Claude streaming uses SSE separately (32, D6).
Gaps to close
- Decide the coarse-channel granularity (per-tenant vs. per-AOI-tile) and the client-filter contract.
- Define the
tenant_version/session_versionbackstop table + the WS re-auth handshake message. - Confirm reconnection/resume semantics (what the client replays on reconnect).