§ unswayed-backend · Chat & messaging (Phase 9)
Realtime chat 2.0 — the socket contract
The 2.0 frontend speaks a different Socket.IO dialect than the 1.0 backend. Rather than rename (and break in-flight 1.0 clients), the 2.0 contract was added alongside the legacy one — both event vocabularies broadcast in parallel. See ADR-0052.
Two vocabularies, one gateway
| 1.0 (kept) | 2.0 (added) | |
|---|---|---|
| Rooms | Pusher-style channels ralli.chat.{id} (via a subscribe handshake) |
Socket.IO rooms chat.{chatId} + user.{userId} |
| New message | MessageSent |
message:new (+ :updated / :deleted) |
| New conversation | ChatReceived |
chat:new |
| Typing / read / presence | — | typing/stop_typing, message:read, presence:online/:offline |
The JWT-on-connect handshake, the Redis adapter, and the Prisma participant
check were already solid and were left untouched. REALTIME_ENABLED=false still swaps
the whole gateway for a no-op adapter — which now no-ops the 9 new broadcast methods too,
so "realtime off" stays a clean no-op.
The three design decisions worth knowing
1. Extend the port, keep the resource frozen. The 2.0 ApiMessage carries metadata
(the Phase-21 N5 action-card payload) that the frozen §24 MessageResource never had. The
temptation is to add metadata to the REST resource — but that resource is a byte-frozen
contract with a wall of toEqual assertions. Instead, metadata lives only on the
socket-layer ApiMessage; the REST resource and the legacy MessageSent payload are
untouched. General lesson: when a new channel needs a richer shape, give it its own type
rather than mutating a frozen one.
2. One hub for message events. Every message path (send, update, delete) already
funnels through MessageFlowService.emitMessageBroadcasts. So the 2.0 message:new/updated/ deleted are emitted there, once, alongside the legacy messageSent — not wired into
three separate service methods. chat:new is the exception: it fires from
ChatsService.create, and only for a genuinely-new conversation (not a deduped re-open).
3. A read-recorder seam, not a DB write in the gateway. message:read has to mark
messages read in the database. But the gateway is deliberately DB-free, and having the
realtime module import a chat service would create a cycle (chat already imports realtime).
The fix mirrors the existing ChatMembershipChecker: a small Prisma-backed ChatReadRecorder
port inside the realtime module. It marks messages id ≤ messageId, sender ≠ reader, status ≠ read as read and bumps the reader's ChatParticipant.lastReadAt (a column that
was dead schema until now). The gateway stays thin; no cycle.
What's not done (on purpose)
Presence delivery to contacts. presence:online/offline currently emit to the
connecting user's own user.{userId} room — the concrete shape the spec's broadcast method
specifies. For a contact to actually see a friend come online, the server would need to
resolve that user's contacts (their chat partners) and emit to each user.{id} room. That
fan-out is a documented follow-up, not built. The frontend useChatSocket rewrite is
separate, non-backend work.
Built via two parallel TDD subagents (one per side — src/realtime, src/chat) over the
landed port contract; 518 unit tests + app-boot/chat e2e green.