Two things:
1. FIX a live regression the async conversion missed: chat.js calls repos via the
lazy repos() helper (not the R. prefix), so my sweep skipped it — effectiveStatus
/ broadcastPresence read `repos().users.byId(userId)` synchronously, but that's a
Promise now, so presence broadcasts always reported status 'active' and dropped
last_seen. Now awaited (effectiveStatus/broadcastPresence async); touchSeen is a
fire-and-forget UPDATE with .catch. Audited all non-R. repo calls — only chat.js
was affected (media.js backfill was already awaited).
2. Swappable pub/sub for multi-instance real-time fan-out (the actual blocker to
running >1 instance — not the DB). server/pubsub.js picks a backend by
PUBSUB_BACKEND (default 'memory'). Local socket delivery is UNCHANGED; publish is
additive — memory = no-op (zero hot-path cost, identical single-instance
behaviour), redis = fan-out to other instances with a self-echo guard. chat.js
pushToUser/broadcastPresence now also publish; each instance subscribes to deliver
remote events to its local sockets. Interface is tiny so Redis is one swappable
file (Postgres LISTEN/NOTIFY or NATS could drop in the same way — never hardwired,
as requested). Dormant redis service added to compose behind the 'scale' profile;
redis dep added; PUBSUB_BACKEND/REDIS_URL documented.
Validated: smoke 22/22 (memory), e2e chat delivery green. NOTE: full multi-instance
also needs distributed presence (isOnline is per-process) + meeting-signaling
sharing — chat/presence fan out via this layer; those are follow-ups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>