Presence
The presence module provides the contract for real-time collaboration
awareness in Primebrick: who is viewing or editing an entity, what field they
are editing, and what the latest server-side change is. The SDK owns the
types, the PresencePort abstraction, the Redis implementation, and the NATS™
subject builders. The BE combines these with the SSE primitives
to expose collaboration SSE endpoints.
Presence is best-effort and optional. The system is fully valid without it — if Redis is unavailable, the BE wrapper turns presence calls into no-ops and the UI simply shows no other users. This mirrors the cache layer pattern.
Architecture
- The FE sends a
PresenceSignal(READING / EDITING / HEARTBEAT / LEAVE) toPOST /api/v1/entities/:entity/:uuid/presence. - The BE wrapper calls
PresencePort.upsertReading()/upsertEditing()/remove()/heartbeat()on Redis, then publishes aPresenceDeltato the NATS™presence.{entityType}.{entityUuid}subject. - When an auditable entity is saved, the BE audit hook calls
PresencePort.setChanged()on Redis andpublishEntityChanged()on NATS™ (subjectentity.{entityType}.{entityUuid}.changed). - The BE bridges both subjects to per-entity
SseEventBusinstances viabridgeNatsToSse(). Connected FE clients receive the deltas as SSE events.
Types
All types are in snake_case per the BE/FE/DAL data-model convention. They are
exported from the package root: import type { PresenceSignal, PresenceEntry, ... } from "@primebrick/sdk".
PresenceSignal — client → BE
The wire payload for POST /api/v1/entities/:entity/:uuid/presence. Sent by
the FE on open, on field focus, on field blur, on heartbeat, and on close.
Code
action | Meaning | State change |
|---|---|---|
READING | User opened the entity | Upsert READING entry; clear from editors if was EDITING |
EDITING | User is editing a field | Upsert EDITING entry with field + value |
HEARTBEAT | Keep-alive | Refresh Redis TTL; update last_seen_at; no status change |
LEAVE | User closed the tab / navigated away | Remove the user entirely (if last tab) |
PresenceEntry — stored in Redis
One entry per (entity_type, entity_uuid, user_uuid). Stored as JSON in the
Redis hash presence:{entityType}:{entityUuid}:users.
Code
PresenceSnapshot — BE → FE (initial SSE event + GET)
Returned by GET /api/v1/entities/:entity/:uuid/presence and sent as the
initial SSE snapshot event on connect.
Code
PresenceDelta — NATS™ → SSE
Published on presence.{entityType}.{entityUuid}. Consumed by the BE SSE
bridge and forwarded to connected FE clients.
Code
EntityChangedMarker — server-side save marker
Published on entity.{entityType}.{entityUuid}.changed by the BE audit hook
after a successful save. Stored in Redis with a 5-minute TTL so late-joining
clients can see the most recent change without scanning the audit log.
Code
The PresencePort interface
The SDK owns the port. Consumers (BE) inject their own implementation —
typically RedisPresencePort (also in the SDK), but any implementation is
accepted (useful for tests with a fake/in-memory port).
Code
All methods MUST be best-effort safe: the BE wrapper swallows rejections (mirror the cache-port-holder pattern). Presence is a feature, not a requirement.
RedisPresencePort — the Redis implementation
Uses node-redis (the redis npm package, v6.x) — the same client the
RedisCachePort uses. Serialization uses the SDK's canonical
extJsonStringify / extJsonParse (BigInt-safe).
Redis key design
| Key | Type | TTL | Purpose |
|---|---|---|---|
presence:{type}:{uuid}:users | Hash | 30s | field = userUuid, value = JSON PresenceEntry (READING or EDITING) |
presence:{type}:{uuid}:editors | Hash | 30s | field = userUuid, value = JSON { field, value, since } |
presence:{type}:{uuid}:tabs:{userUuid} | Set | 30s | members = sessionId strings. tab_count = scard |
presence:{type}:{uuid}:changed | String | 5min | value = JSON EntityChangedMarker |
A user is in users with status EDITING and in editors while editing.
LEAVE removes from both. On LEAVE of a tab, srem that sessionId; if the tabs
set is empty, remove the user from users and editors.
Construction
Code
The TTL is refreshed by every signal (READING/EDITING) and every HEARTBEAT. If a client stops sending signals, its entry expires automatically after the TTL window — no cleanup job needed.
NATS™ subject builders
The SDK provides subject builders and publish helpers. Subscription is handled
by the SDK's existing bridgeNatsToSse() — the BE bridges these subjects to
per-entity SseEventBus instances.
Code
NatsClient.publish() uses extJsonStringify (BigInt-safe) internally — pass
plain objects, receive plain objects.
BE integration pattern
The BE wraps the port in a best-effort holder that swallows rejections (so a Redis outage does not break the API). The pattern mirrors the cache-port-holder:
Code
The BE presence route handler calls presenceStore.* (best-effort) and then
publishPresence() (also best-effort). If Redis is down, both are no-ops and
the API returns 200 — the FE simply shows no other users.
TTL and cleanup
There is no cleanup job. Presence entries expire automatically:
- The
usersandeditorshashes have a 30s TTL, refreshed by every signal and heartbeat. If a client stops sending signals (browser crash, network loss), its entry expires after 30s. - The
tabsset has a 30s TTL per user. On LEAVE of a tab,sremremoves that sessionId; if the set is empty, the user is removed fromusersandeditors. - The
changedmarker has a 5-minute TTL. Late-joining clients see the most recent change without scanning the audit log.
Next steps
- SSE standard — the SSE writer, event bus, and NATS™ bridge that the BE uses to forward presence deltas to FE clients.
- Cache layer — the same best-effort Redis pattern used by the cache port.
- NATS Client —
publish()andrequest()with Ext-JSON. - Config tables —
redis_urlis read from the shared config at startup to initialize the Redis client. - API Reference —
PresencePort,RedisPresencePort,publishPresence,publishEntityChanged, and all the types.