Real-Time & WebSockets
Overview
Lacroo Pro uses an event-driven real-time architecture — no client-side polling.
A change in Postgres reaches every browser and phone entitled to see it, and no others. “Entitled” is the whole design: a field worker can belong to a supplier org while working on a head contractor’s project, and can work across several projects in several orgs at once. There is no single org a connection belongs to, so connections are scoped by project entitlement, not by org.
Data Flow
PostgreSQL LISTEN/NOTIFY → Go hub → room fan-out → TanStack Query invalidation- Database triggers
pg_notifyonproject_changesororg_changeswhen a row changes - The Go API holds a dedicated
LISTENconnection viapgx - The hub reads the payload’s routing fields and picks the rooms it belongs in
- Each room’s members receive the payload verbatim — the hub never reshapes it
- The client invalidates the matching TanStack Query keys, which refetch
Rooms
A connection is registered into rooms at handshake, from the identity in its verified token. Nothing a client sends can put it in a room.
| Room | Key | Who is in it |
|---|---|---|
| Org | org_id | every connection whose token names that org (see Delivery) |
| Project | project:{hcOrgId}:{projectId} | connections the server resolved as entitled to that project |
| Named sub-room | e.g. agent:{conversationId} | explicit subscribe, checked by an authorizer |
A project room key always carries the head contractor’s org, never a bare project id. The same project means different things to different orgs, and a bare id would let a supplier org’s payload address a head contractor’s room.
Entitlement
authz.ResolveRealtimeRegistration answers three questions per connection.
Org-wide or not? Only org managers and master admins — the org-level
web_org avenue, the same test as authz.HasOrgWideAccess with no project —
hear every project their org owns. Everyone else hears only the projects they
are a member of (a project_members row). is_full_access plays no part:
project admins hold it in many orgs.
Which projects? Membership arms, deduped:
- The user is a project member in another org
- The user’s org is a supplier linked onto the project
- The user’s org is a client linked onto the project
- Projects owned by the user’s own org:
- org-wide — no room; the org room already carries them
- anyone else — one room per project they are a member of, and nothing for the rest
Org room or not? Client and supplier portal identities get project rooms and no org room; everyone else keeps theirs. The rule is affirmative: project-only requires at least one client-facing surface and no org-facing surface. An empty or unrecognised surface set yields the org room. Dropping a real user’s org room on a resolution miss would break their whole app silently; keeping it is the failure this design can afford.
Delivery
| Payload | Who receives it |
|---|---|
room set | that named room only |
no project_id (or '') | the whole org room |
project_id set | org-wide members of the org room, org-room members holding that project, and the project:{org_id}:{project_id} room |
project_id not a string | org-wide members of the org room only |
Each connection gets a payload once, however many ways it qualifies. A non-org-wide connection never sees a project it is not on — not even its id.
Keeping entitlement current
Changing who can see what does not wait for a reconnect. When a payload names a table that could change entitlement — project membership, supplier/client links, roles, surfaces — the hub re-resolves the connections that could be affected and updates their rooms in place.
Losing access is immediate. Gaining it is eventual. The re-authorization set is drawn from connections that already hold at least one project room, so a connection holding none is not selected until it reconnects. In practice that is only a worker’s first cross-org project. This is a deliberate position, not a gap: the common case must not become a promise the design cannot keep.
Same-org entitlement is the exception, because an org-wide connection holds no
project room at all. A change to role assignments, roles or role surfaces
re-resolves every connection in that org’s room, so a demoted org manager
stops hearing other projects at once and a promoted one starts. A
project_members change re-resolves the org’s non-org-wide connections, so a
newly added member hears their first project without reconnecting. A projects
row change re-resolves only connections holding that project — it can revoke
(soft delete) but never grants.
Writing a trigger
The hub decodes four routing fields — org_id, room, project_id, table —
and forwards everything else untouched. Adding a field to a payload needs no hub
change.
PERFORM pg_notify('project_changes', json_build_object(
'org_id', NEW.org_id::text,
'project_id', NEW.project_id::text,
'table', TG_TABLE_NAME,
'action', TG_OP
)::text);Emit those four as JSON strings or null — cast with ::text, use a uuid
column, or ''. They decode into typed Go strings, so a number or object fails
the strict decode. The hub falls back to a loose read rather than dropping the
payload, but the field’s own fan-out is lost. notify_envelope_shape_test.go
fails the build on an unsafe shape.
If the row belongs to a project, emit its project_id. An empty
project_id makes the payload org-level, and it reaches every connection in the
org — including people who are not on that project.
One event may legitimately notify twice — the head contractor’s org on
project_changes, the supplier’s on org_changes. The supplier-side copy
carries the supplier org: its project room key is one no client holds, and in
the supplier’s org room it reaches only org-wide members and connections that
already hold that project.
Key Rules
- Never implement polling on the client side
- All real-time features go through the LISTEN/NOTIFY → WebSocket pipeline
- WebSocket connections use their own short-lived token. It is not accepted by the REST API, and a REST token is not accepted here.
- A connection’s rooms come from server-side entitlement, never from the client
- A payload tagged with
roomgoes only to that room — it must never fall through to the org room, or private conversation state leaks org-wide