CODEX // oaoisme wiki

§ unswayed-backend · Chat & messaging (Phase 9)

Realtime chat 2.0 — the socket contract

updated 2026-07-01

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.