ArchitectureReal-Time & WebSockets

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
  1. Database triggers pg_notify on project_changes or org_changes when a row changes
  2. The Go API holds a dedicated LISTEN connection via pgx
  3. The hub reads the payload’s routing fields and picks the rooms it belongs in
  4. Each room’s members receive the payload verbatim — the hub never reshapes it
  5. 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.

RoomKeyWho is in it
Orgorg_idevery connection whose token names that org (see Delivery)
Projectproject:{hcOrgId}:{projectId}connections the server resolved as entitled to that project
Named sub-roome.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:

  1. The user is a project member in another org
  2. The user’s org is a supplier linked onto the project
  3. The user’s org is a client linked onto the project
  4. 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

PayloadWho receives it
room setthat named room only
no project_id (or '')the whole org room
project_id setorg-wide members of the org room, org-room members holding that project, and the project:{org_id}:{project_id} room
project_id not a stringorg-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 room goes only to that room — it must never fall through to the org room, or private conversation state leaks org-wide