Collaboration & Visual Merge
Overview
The collaboration module provides real-time co-editing awareness for any entity that is auditable. When two or more users open the same record, each user sees who else is viewing or editing, which field they are focused on, and receives a live notification when the other user saves. The frontend can then fetch the field-level diff and either silently merge non-overlapping changes or surface a conflict resolution UI when both users edited the same field.
The module provides four capabilities:
- Real-time presence — see who is currently reading or editing an entity, including which field they are focused on and the value they are typing.
- Entity-changed awareness — when any user saves an auditable entity, every
other connected user receives an
entity-changedevent over SSE within milliseconds. - Field-level merge — the audit diff endpoint returns the exact
{ old, new }delta per field, so the frontend can merge non-overlapping changes without a full reload. - Conflict resolution — optimistic locking (
versioncolumn) guarantees that if two users save conflicting changes, the second save returns a409with a stableERR01code. The frontend uses this to trigger the conflict UI.
No WebSockets are required. The entire transport layer is HTTP-based.
Architecture
The collaboration module uses three transports, each with a clear role:
| Transport | Role | Best-effort? |
|---|---|---|
| SSE (Server-Sent Events) | Push real-time updates to connected browsers | No — core feature |
| Redis | Presence state + entity-changed marker cache (5-min TTL) | Yes — system works without it |
| NATS | Fan-out across multiple BE instances | Yes — same-instance SSE still works |
Code
SSE works over standard HTTP/1.1 or HTTP/2. The browser opens a long-lived
GET connection to /presence/events and receives a stream of named events
(snapshot, presence-update, entity-changed, error). A keep-alive comment
is sent every 15 seconds to keep proxies from closing the connection.
Redis stores the presence state (who is reading, who is editing, which field) and the most recent entity-changed marker. The marker has a 5-minute TTL so that users who open the record shortly after a save see the latest change without needing to scan the audit log.
NATS provides cross-instance fanout. When multiple BE instances are behind a
load balancer, User A might be connected to instance 1 and User B to instance 2.
NATS ensures the entity-changed event published on instance 1 reaches the SSE
stream on instance 2. Each BE instance bridges the NATS subject for an entity
into its local SSE bus only while at least one SSE client is subscribed
(refcounted via collaborationBusRegistry).
How it's automatic
Any entity decorated with @AuditTrail() gets collaboration automatically.
There is no separate registration, no per-entity wiring, and no feature flag to
flip.
The audit hook (EntityAuditPort) fires after every UPDATE, SOFT_DELETE,
RESTORE, and HARD_DELETE on an auditable entity. It publishes the
EntityChangedMarker that drives the entity-changed SSE event. Because the
hook is part of the audit port — which only runs for @AuditTrail() entities —
collaboration is implicitly enabled for exactly those entities.
The frontend discovers this via the entity meta response. assembleMeta()
injects a collaboration fragment into every entity's meta payload:
Code
When collaboration.enabled is true, the frontend activates the presence
avatars, SSE subscription, and merge/conflict UI for that entity. When false,
the frontend treats the entity as a standard CRUD form with no collaboration
features.
Endpoints
All endpoints require authentication (Permission.AUTHENTICATED_USER) via
cookie-based session. The SSE client must send credentials: 'include'.
POST /api/v1/entities/:entity/:uuid/presence
Send a presence signal. Returns 204 No Content.
Code
| Field | Type | Required | Description |
|---|---|---|---|
action | "READING" | "EDITING" | "LEAVE" | "HEARTBEAT" | Yes | The presence signal type |
field | string | No | The field the user is focused on (sent with EDITING) |
value | unknown | No | The in-progress value being typed (sent with EDITING) |
loaded_version | integer | No | The entity version the user loaded (for stale-check) |
session_id | string (UUID) | No | Tab session identifier for multi-tab heartbeats |
The four actions:
READING— user opened the record in read mode. Adds the user to thereaderslist in the presence snapshot.EDITING— user started editing, optionally focused on a specific field. Adds the user to theeditorslist and publishes a presence delta so other clients show the editing avatar and field focus indicator.LEAVE— user closed the tab or navigated away. Removes the user from both lists and publishes aLEAVEdelta.HEARTBEAT— keep the user's presence alive. Resets the Redis TTL. Does not publish a NATS delta (no state change for other clients).
GET /api/v1/entities/:entity/:uuid/presence
Returns the current presence snapshot.
Code
The changed field is null when no change has been published within the
5-minute TTL window.
GET /api/v1/entities/:entity/:uuid/presence/events
Opens an SSE stream. The response has Content-Type: text/event-stream. Events
are sent as named SSE events with JSON data payloads.
| Event | Description |
|---|---|
snapshot | Sent once on connect. Contains the full presence snapshot. |
presence-update | Sent when a user joins, leaves, or changes field focus. Contains the presence delta. |
entity-changed | Sent when another user saves the entity. Contains the EntityChangedMarker. |
error | Sent when the initial snapshot could not be loaded. The stream stays open. |
The stream excludes presence-update events that originate from the same user
(no self-echo). A keep-alive comment (: keep-alive) is sent every 15 seconds.
GET /api/v1/entities/:entity/:uuid/audit/:auditLogId
Returns the field-level diff for a specific audit log entry. The frontend uses
this after receiving an entity-changed event to fetch the exact { old, new }
delta per field and decide whether to silently merge or surface a conflict.
Code
Returns 404 if the audit log entry does not exist or does not belong to the
requested entity UUID.
The audit hook
EntityAuditPort implements the AuditPort interface from the DAL. After
writing the audit log row to the per-entity audit table ({tableName}_audit),
it publishes an EntityChangedMarker for every UPDATE, SOFT_DELETE,
RESTORE, and HARD_DELETE action.
INSERT is excluded — a newly created entity has no concurrent viewers to
notify.
The marker is published through two channels:
- Redis —
store.setChanged(marker, 300_000)stores the marker with a 5-minute TTL. This serves late-joining SSE clients: when a user opens the record 2 minutes after a save, the initial snapshot includes thechangedfield without scanning the audit log. - NATS —
publishEntityChanged(NatsClient, marker)publishes to theentity.{entityType}.{entityUuid}.changedsubject. Connected SSE clients on any BE instance receive theentity-changedevent in real time.
Both are best-effort. If Redis or NATS is unavailable, the marker is simply not published — the audit log row itself is always written, because that is the primary responsibility of the adapter.
Code
Keyspace notifications
When a user closes their browser without sending an explicit LEAVE signal,
their presence entry in Redis expires after the TTL (30 seconds without a
heartbeat). The keyspace listener detects this expiry and publishes a LEAVE
delta on NATS so connected SSE clients remove the stale user from their presence
UI.
Requirements
Redis must be configured with keyspace notifications for expired and generic events:
Code
Or in redis.conf:
Code
E— keyevent events (events triggered by keyspace operations)x— expired eventsg— generic events (DEL, EXPIRE, etc.)
The listener subscribes to the __keyevent@0__:expired channel on a dedicated
Redis client (Redis requires a separate connection for pub/sub). It parses the
expired key name and only processes keys matching presence:*:users or
presence:*:editors.
Fallback sweep
If keyspace notifications are not enabled, the listener silently no-ops. The system still works: the next time any client fetches the presence snapshot, the expired user is no longer in Redis, so the snapshot reflects the correct state. The SSE clients will reconcile on the next snapshot fetch or heartbeat cycle.
Error mapping
The global error handler maps three DAL error codes to HTTP responses with
stable internal_code values. The frontend uses these codes to trigger the
appropriate UI flow.
| Code | HTTP | Title | Severity | Frontend action |
|---|---|---|---|---|
ERR01 | 409 | Optimistic concurrency violation | HIGH | Show conflict resolution UI |
ERR02 | 400 | Missing version field | MEDIUM | Show "refresh required" message |
ERR03 | 404 | Record vanished | MEDIUM | Show "deleted by another user" message |
All three follow RFC 7807 (application/problem+json):
Code
ERR01— theversioncolumn did not match the expected value. Another user saved the record between the time this user loaded it and tried to save. The DAL raises this viaRAISE EXCEPTION ... USING ERRCODE = 'ERR01'in PostgreSQL.ERR02— the request body did not include aversionfield, but the entity is auditable and requires optimistic locking.ERR03— the row was hard-deleted by another writer between the read and the write. The record no longer exists.
Meta auto-inject
assembleMeta() wraps each entity's static EntityMeta object with a
collaboration fragment before returning it to the frontend — it also
materializes display_name as "${" + display_field + "}" when the static
meta does not declare an explicit template. The enabled flag is derived from
the entity's persistence metadata — specifically getEntityPersistenceMeta(ctor).isAuditable.
Code
The expose_editing_value flag controls whether the frontend shows the
in-progress value being typed by another user in the avatar tooltip. When
false, only the field name is shown, not the value.
Infrastructure requirements
| Component | Required? | Purpose |
|---|---|---|
| PostgreSQL | Yes | Audit tables ({entity}_audit) store the field-level deltas |
| Redis | Best-effort | Presence state + entity-changed marker cache |
| NATS | Best-effort | Cross-instance fanout for SSE events |
Redis
Redis is best-effort. The system is fully valid without it — presence is simply
disabled and all presence endpoints return empty snapshots or 204. The audit
log and optimistic locking work independently of Redis.
When Redis is available, configure keyspace notifications for presence expiry cleanup:
Code
The presence store reuses the same redis_url as the cache port (loaded from
config_entries). It creates a separate Redis client because node-redis
does not support concurrent command queuing on a shared connection safely across
modules.
NATS
NATS is best-effort. When unavailable, SSE still works for clients connected to the same BE instance (the local bus delivers events directly). Cross-instance fanout is lost — a user on instance 2 will not see a save that happened on instance 1 until they refresh.
PostgreSQL
PostgreSQL is required. The audit tables ({entity}_audit) are created by the
standard migration flow and store every INSERT, UPDATE, SOFT_DELETE,
RESTORE, and HARD_DELETE with a field-level delta JSON column. The audit
diff endpoint reads from these tables.
End-to-end flow
The following sequence diagram shows the full flow when User A saves a change and User B is concurrently viewing the same record.
Next steps
- Optimistic Locking — how the
versioncolumn prevents lost updates and drives theERR01conflict response. - Audit Trail — how
@AuditTrail()captures field-level deltas and powers the audit diff endpoint.