TRW Chat System — Evidence for Boja
Prior-art analysis of TRW's message model, pagination, scrolling, outbox, and sync lessons.
Generic offline-mutation-queue and CDC/replay client-sync material below describes TRW. Boja uses Rocicorp Zero for synced reactive data and an app-owned durable message-send outbox. CDC/event-outbox lessons apply to server-side effects such as search, audit, notifications and jobs.
Source: /Users/raynor/dev/trw (Bun/TypeScript monorepo). Investigated 2026-07-16. Primary references: ARCHITECTURE.md, docs/CHAT-CONTEXT-PACK.md, app/AGENTS.md, plus direct code reads cited inline.
0. System Topology
Chat spans six runtimes:
| Layer | Runtime | Owns |
|---|---|---|
| UI | app/ (React 19, TanStack Router/Query, Jotai, legacy MobX) | Message list, input, embeds, uploads |
| Transport | app/src/client.js/ | WebSocket client, MobX entity maps (Users/Channels/Messages/Members) |
| HTTP API | eden/ (Elysia on Bun) | messages, channels, reactions, search, unreads |
| Realtime fanout | nile/ (Bun WS at /live/ws) | sessions, topic subs, auth, replay, presence |
| Change streams | sink/ | Mongo change streams → EventV2, Meilisearch indexing, live cache |
| Background jobs | serf/ (BullMQ Pro) | notifications, unreads batching, embed unfurl, scheduled sends |
Key architectural invariant: write handlers never emit realtime events directly — writes go through models/context/* helpers, and fanout is either explicit pubsub from serf workers (chat/EventV1) or derived from Mongo change streams in sink (EventV2). Chat uses EventV1 only; EventV2 is job-portal/video-calls.
The frontend chat data plane is a four-layer sandwich: TanStack Query infinite cache → MobX entity maps (appClient) → Jotai mirror (app/src/utils/atomWithQueryData.ts) → derived Jotai selectors (messagesState.ts) → an imperative runtime (chatRuntime.ts). This layering is the single biggest source of complexity.
1. Message Data Model
Collections (models/)
messages(models/Message.ts):_id(ULID),channel,author,sort_id(ULID, the pagination key),content,attachments,embeds,mentions,role_mentions,replies,links,timestamp,edited,reaction_counts(denormalized Map),poll,actionBlock,system,nonce(client idempotency key).message_reactions(models/MessageReaction.ts): composite_id: { emoji, user, message }; indexes on_id.message + _id.userand_id.message + _id.emoji. Counts are denormalized ontomessage.reaction_countsand reconciled viaMessageReactionCountdelta events.channels(models/Channel.ts): union of 5 types —SavedMessages,DirectMessage,Group,TextChannel,JobWorkspace. Channels denormalizelast_message_id,last_message_author,first_message_id,active— updated byserf/tasks/messagePublishTask.tsafter each send.user_unreads(models/UserUnreads.ts): read-state store, sharded (see §5).attachments(models/Attachment.ts): separate collection; messages reference them,attachment.message_idback-links.
Indexes
messages:{ channel: 1 }— backs every channel-scoped query. Without this index they all collection-scan. Prod indexes:channel + sort_id,channel + timestamp, author/channel composites.- Notably commented out in
models/Message.ts:222-229: a unique partial index{ channel: 1, nonce: 1 }for send idempotency — dedupe is currently enforced client-side + soft server-side, not by a DB constraint. A rewrite should make this a real unique index.
Denormalization inventory
| Denormalized data | Where | Reconciliation |
|---|---|---|
reaction_counts Map on message | models/Message.ts | MessageReactionCount delta events; optimistic client increments |
last_message_id / first_message_id / active on channel | models/Channel.ts | messagePublishTask post-send; delete-time repair findOne |
| Hydrated users/members in message query responses | models/Message.ts (60s Redis hydration cache) | TTL only — schema changes within 60s can serve stale hydration |
| Message search documents | Meilisearch via sink/src/streams/messageSearchStream.ts (batch 100 docs / 30s flush) | change-stream driven, resume token sink:search_sync_resume_token |
| Legacy NaN timestamps | repaired on read via repairMessageTimestamps → decodeTime(sort_id) | permanent read-path band-aid |
ULID _id + separate sort_id gives you time-ordered ids that double as cursors — this works well. Denormalized channel tail pointers (last_message_id) are load-bearing for unreads, entry planning, and pagination termination; a sync engine should make them first-class server-maintained fields.
2. Infinite-Scroll Pagination
Server side (eden/src/routers/messagesRouter.ts → models/Message.ts queryMessages)
Single bulk endpoint POST /messages/query supports: before, after, before_sorted, after_sorted, nearby, ids, sort (Latest/Oldest), limit (capped 100), throw_on_not_found, has_attachment_types, has_link.
- Cursors are
sort_id(ULID) range scans:filter.sort_id = { $lt: beforeCursor }. Clients may pass either a message_id(resolved to itssort_idviaresolveSortCursor) or thesort_iddirectly (*_sortedparams). nearby: fetches the anchor message, then runs two parallel range queries (sort_id < anchorandsort_id > anchor) and stitches[...before, anchor, ...after]. If the anchor doesn't exist (deleted message, or synthetic ULID from an unread ack), it falls back to rawsort_idbounds — this is what makes "jump to last-read" work with an ack cursor that was never a real message id.- Response (
MessagesResponse) is denormalized:messages,users,members,related_messages(reply parents),my_reactions,related_threads,my_thread_memberships— one round trip hydrates everything the UI needs.
Client side (app/src/modules/Chat/ChatMessageList/messagesApi.ts)
TanStack useInfiniteQuery with a typed page-param discriminated union MessageFetchAction = {type:"top"|"bottom"|"nearby", target?, target_sorted?}:
queryKey: [MESSAGES_QUERY_KEY_PREFIX, channelId];staleTime: Infinity,gcTime: 60min.- Page sizes (
LIST_PAGE_SIZES):initial=60,top=20,bottom=15,nearby=60desktop /30touch. - Window cap + trim:
LIST_SIZE_LIMIT = 150desktop /100touch; after every fetch and realtime append,trimChannelMessagesevicts pages from the opposite side. So the in-memory window slides bidirectionally. presentTailMessageIdis stored on the InfiniteData itself — the newest message id known to be "present".getRealtimeAppendPlanuses it to decide whether a WS message may append to the cache (only when the loaded window actually reaches present) vs. be dropped (user scrolled deep into history). This prevents realtime appends from teleporting messages into a historical window.
Bidirectional scroll + anchoring (ChatV4.tsx)
- Virtualizer:
virtua<VList reverse shift={isPrependRef.current} overscan={20}>.reversemode; on top-prepend,isPrependRefflips true for one render so virtua usesshift(offset compensation). - Settle loop (
ChatV3/settleAtBottom.ts): rAF loop keeps callingscrollTo(bottom)untilscrollSizeis stable for 3 frames and withinBOTTOM_THRESHOLD_PX = 5, with a 1.5s safety timeout. - Follow-present hysteresis (
resolveFollowPresentOnScroll.ts): content growing below a bottom-pinned list would un-pin and show a "See present" bar even though the user never scrolled. Fixed with a drift buffer + "if offset didn't move up, re-pin" heuristic.
Bidirectional infinite scroll over a reverse virtualized list works, but requires ~5 dedicated correction mechanisms (shift flag, settle loop, drift buffer, bottom threshold, prepend layout-effect). In a sync-engine design, keep the cursor model (sort_id ranges + nearby stitching + short-page boundary detection + present-tail tracking) — it's genuinely good — but pick/build a virtualizer with first-class reverse-list anchoring.
3. Replies and Threading
Flat replies (Discord-style, primary)
message.replies: string[]— but only the first reply is processed. So in practice it's a singlereply_topointer stored as an array (schema legacy).- Reply parents come back as
related_messagesin query responses soMessageReplyBarcan render inline previews without extra fetches. - Full chain:
GET /messages/:messageId/ancestryreturns ancestors + children.
Threads (newer, channel-backed)
- Backend on
eden/src/routers/channelRouter.ts:POST /:channelId/messages/:messageId/thread,GET /:channelId/threads. - Threads are channels (query responses include
related_threads: Channel[]andmy_thread_memberships: ThreadMember[]).
Replies-as-degenerate-array is confusing schema debt; model reply_to as a scalar. Threads-as-channels is a solid choice (reuses pagination/unreads/permissions wholesale) but the related_* hydration side-channel shows the cost of not having a normalized client store — a sync engine with proper entity normalization gets this for free.
4. Jump-to-Message (Permalinks, Old History, Window Re-establishment)
- Routes: permalink routes just redirect to the base route with
?messageId=. - Entry plan (
chatRouteEntryPlan.ts): route loader computesChatEntryPlanwith 3 intents —selected-message(permalink →nearbyfetch on messageId),last-read(nearby fetch onlastReadId),latest(top fetch, land at bottom). - Bootstrap:
useChatRouteBootstrap.tswriteschatEntryPlanAtomFamily(channelId)+selectedMessageIdByChannelAtom(channelId)into Jotai. - Jump while mounted (
ChatViewRuntime.jumpToMessage):- Target already in loaded slice: look up index in
messageItemIndexMapAtomFamily, scroll across several frames, flash highlight. - Target not loaded: fetch
nearby→ reset Jotai state + destroy query cache → seed cache with the new slice. The context pack is blunt: "This is why permalink jumping outside the current slice feels like a local reload — it is one".
- Target already in loaded slice: look up index in
- Return to present (
restartAtPresent): if the slice already reacheschannel.last_message_id, just settle to bottom; otherwise phase →recovering-present, cancel in-flight queries, freshtopfetch, replace pages, settle.
The nearby fetch + synthetic-cursor fallback is the right server primitive. The pain is client-side: because the window is a single TanStack infinite query per channel, a far jump requires destroying the whole window and re-seeding it. A sync engine with a local message store indexed by sort_id could re-window locally without a destructive reset.
5. Unread Tracking / Read States
Storage: models/UserUnreads.ts — sharded
- Per-user read state:
channels: Record<channelId, { last_id, mentions[] }>. - Sharded across 8 docs (
NUM_SHARDS = 8, doc_id = ${userId}_${shardIndex}), shard chosen by hashing the last 4 chars of channelId. Feature-flagged, legacy single-doc auto-migrates lazily on write. Reason: a single unboundedchannelsmap per user hit Mongo document-size/write-contention limits.
Write path
- Acks:
POST /:channelId/ack/:lastId,POST /:channelId/ackMention/:mentionId,POST /:serverId/ack(ack-all). - All unread mutations funnel through
serf/tasks/unreadsTask.ts— deduped/batched via Redis (10k max, 500 min, 5s timeout, grouped per channel, concurrency 1). - Mentions/DM messages add unreads in
notifyMessageTaskper-recipient.
Read path / client
- Client seeds unreads at bootstrap (
Readypacket), listens toUserUnreadsevents, keepsappClient.unreadsin MobX. - Unread rendering is derived:
UnreadIndicatoritem inserted bymapMessagesListItemsbefore the first unread incoming message. - "Mark unread" in the message popup menu writes a synthetic ack cursor — which is why the server's nearby-fallback for non-message ULIDs exists.
Server-side read-state with per-channel last_id + mention id list is simple and works, but the async batched write path means read-state is eventually consistent with real UX lag. The 8-shard hack is a scar from unbounded per-user maps. In a rewrite: model read state as a first-class synced entity per (user, channel) row, update it locally-first, and let the sync engine reconcile — no batching pipeline needed.
6. Frontend Resumability & Caching
Caching layers
- TanStack Query infinite cache per channel (
staleTime: Infinity,gcTime: 60min) — messages never go stale by time. - MobX entity maps (
app/src/client.js/maps/Messages.ts,Users.ts,Channels.ts,Members.ts) — the legacy entity cache shared with the websocket layer. Every fetched page is hydrated into MobX and stored in TanStack pages (dual write). - Jotai mirror (
app/src/utils/atomWithQueryData.ts) subscribes to TanStack cache events and mirrors intomessagesAtomFamily→ derivedmessageItemsAtomFamily(renderable items). - Drafts persisted to IndexedDB via
persistStore(chatState.tsmessageDraftByChannelFamily— migrated out of localStorage for space).
React <Activity/> offscreen rendering
app/src/modules/Chat/ChatChannelActivityPool.tsxrenders every recently-visited channel inside<Activity mode={id === selectedChannelId ? "visible" : "hidden"}>. Pool: LRU withmaxEntriesPerGroup: 30,maxEntriesTotal: 60,maxAgeMs: 30min.- Channel switching is therefore mostly an Activity visibility flip, not a remount.
Runtime suspension (channel switch)
- One
ChatViewRuntimeper channel (getOrCreateChatRuntime,chatRuntime.ts) that survives unmount as suspended once initialized. ChatV4resumes it ifhasInitializedOnce &&selected message (if any) is still in the loaded slice; otherwise a full reset:resetAllMessagesState(channelId)+messagesApi.destroyMessagesCache(channelId)+runtime.resetForFreshLoad().
Reconnect / tab switch
- Cursors
event_v1_last_seq/event_v2_last_seqpersisted in localStorage; on reconnect the client sends the cursor and nile replays from a Redis stream; expired cursor →connectFresh(). - On
resync(replay window missed): all hidden pooled channels are evicted and the active channel emitsrestart_at_present. Major query keys invalidate. - Boot watchdog: 5s timer; first timeout destroys cache + retries, second →
errorphase.
TRW effectively hand-built a partial sync engine: replay cursors, entity maps, offscreen keep-alive, resumable runtimes. The <Activity/> pool + per-channel runtime registry is the strongest idea here — instant channel switching with warm state. The weakness is that cache validity is binary (replay-or-nuke): a missed replay window throws away everything instead of diffing. A real sync engine with server-authoritative versions per channel removes the nuke path.
7. Optimistic Sends, Retries, Dedupe
Send flow (app/src/modules/Chat/ChatFooter/useSendMessage.ts + chatOutbox/):
- Draft merged; client generates
_id = clockDriftedUlid()and a separatenonceULID. - Optimistic message created via
appClient.messages.createObjand appended to the query cache immediately; uploads awaited first. - Entry pushed into the outbox (
chatState.ts:outboxAtom), processed bychatOutbox/outboxProcessor.ts:- In-flight set prevents a WS disconnect/reconnect cycle from firing a duplicate HTTP send for the same optimistic id.
- Offline pause: entries move to
pausedwhen WS is disconnected, rescheduled onconnected. - Error classification (
outboxErrorClassifier.ts): retryable = network failure / timeout / 502-503-504 → infinite retry with exponential backoff; terminal = SlowMode, TimedOut, 4xx/429, and unknown errors default terminal ("avoids infinite loops on logic bugs") →failedstate + toast. - Retry id refresh: on retry, the optimistic message is re-registered under a fresh ULID so its timestamp stays current — display-only, because "idempotency on the server is protected by
nonce."
- Server:
POST /:channelId/messages→messagesContext.sendMessage(validation, cooldown, anti-spam 30s slowmode >60msg/min, attachments, mentions caps) → insert →messagePublishTask→MessageWithUserfanout. - Dedupe on echo:
messagesApi.appendChannelMessagematches bynoncefirst,_idsecond and replaces the optimistic entry in-place rather than double-appending.
Caveat: the server-side channel + nonce unique index is commented out, so true server-side idempotency depends on soft checks; a rapid retry racing a slow success could theoretically double-insert.
The message-send outbox design (state machine per entry: sending/paused/retrying/failed, error classification, nonce dedupe, in-flight guard) is exactly the right shape for the selected message-only durable outbox. Enforce nonce uniqueness in the DB and make this outbox durable (it is in-memory Jotai today; a page refresh loses queued sends even though drafts persist to IndexedDB). Do not generalize it into an offline queue for other mutations.
8. Attachments / Media
- Separate service
ark/: multipart upload → validation/tag constraints → S3-compatible storage. Attachment metadata:_id,tag,filename,content_type,size,metadata(Audio/Video/Image/File/Text),message_id?,deleted?,saved_sizes[]. - Upload-before-send:
UploadDrafts.tsxqueue with per-file progress;useSendMessageawaits all uploads, then sends message referencing attachment ids. Limits: 5 per message (100 in JobWorkspace). - Post-processing in serf:
attachmentPlaceholderTask(blurhash),attachmentResizeTask(per-maxSidecached insaved_sizes),videoThumbnailBackfillTask,attachmentCleanupTask(orphans). - Rendering:
Attachment/Attachment.tsxdispatcher (Image with blurhash placeholder + fullscreen, Video with server thumbnail, Audio inline player, Text preview, File download, Spoiler overlay), message-levelAttachmentGrid.tsxviaoptimizeGridLayout.ts. - Link embeds are separate from attachments:
processEmbedsTaskunfurls links post-send (idempotency keyprocess-embeds-${messageId}-${contentHash}; replaces rather than appends embeds to prevent accumulation) and fans outMessageAppend.
Attachments-as-entities with server-side variant generation, blurhash placeholders, and upload-before-send is all solid — keep the shape. The late-arriving embed/MessageAppend mutation is one of the things that breaks scroll anchoring (content grows after settle — APP-3067); a rewrite should reserve layout (aspect-ratio boxes from metadata) before media/embeds resolve.
9. Known Pain Points
- Five-layer state plumbing. TanStack Query pages + MobX entity maps + Jotai mirror + derived atom families + imperative
ChatViewRuntime. The context pack warns: "Cache reset and atom reset must stay aligned... Always pairmessagesApi.destroyMessagesCache+resetAllMessagesState". MobX is officially a legacy boundary no new feature may extend. - Jump = local reload. Permalink outside the loaded slice destroys and rebuilds the entire window. Called "Expensive but correct" internally.
- Scroll jank management is a subsystem of its own.
settleAtBottom.tsrAF loop (3-stable-frame heuristic + 1.5s safety timeout),isPrependRef/shiftdance,BOTTOM_THRESHOLD_PX=5,POST_PRESENT_SCROLL_DRIFT_BUFFER_PXhysteresis, and the APP-3067 fix inresolveFollowPresentOnScroll.ts. - Boot watchdog as loading-screen band-aid. 5s timeout → destroy cache and retry → second timeout → error phase. The loading curtain needs an explicit
shouldBypassResumeLoadingescape hatch. - Resync = refetch storm. On missed replay window: evict all pooled hidden channels +
restart_at_present+ invalidate "major query keys". Every warm channel becomes a cold boot at once. - Missing DB idempotency index —
channel + nonceunique index commented out. - Read-path repairs: NaN-timestamp repair on every read;
replies[]array where only element 0 counts; legacyChannelUnreadbeside shardedUserUnreads. - 60s Redis hydration cache for users/members in message responses can serve stale author data after deployments.
- DOM node contention — hover focus, popup targeting, swipe transforms, reply-arrow portal, and highlight styling all target the same message row DOM; changing DOM structure in
ChatMessage.tsxcan break multiple interaction subsystems at once. - Multi-hop realtime chain fragility —
models write → serf/sink → Redis pubsub → nile → WS → client; a failure in any link breaks live updates without necessarily failing the originating request. - Unreads eventual consistency + shard hack — batching pipeline exists purely to survive write volume of a chatty schema.
- Assorted in-code TODOs: menu-flicker
setTimeout(..., 1)hack, embed addon needing "a better way to get server & channel", kick error swallowed infinally. - In-memory outbox — queued/failed sends don't survive refresh (drafts do, via IndexedDB).
10. What to Keep / What to Avoid in a Rewrite
Keep (proven, well-shaped ideas)
- Client-generated ULID identity plus a separate authoritative
sort_idcursor; preservebefore/after/nearby, parallel two-sidednearbystitching, and the synthetic-cursor fallback for deleted/never-existed anchors. Identity and ordering remain conceptually separate even when both encodings are lexically sortable. - Short-page boundary detection +
first_message_id/last_message_idchannel pointers for pagination termination and present detection. presentTailMessageId/ append-plan gating — realtime appends only land when the loaded window reaches present. A sync engine needs the same concept (live tail vs historical window).- The outbox: per-entry state machine, retryable/terminal error classification (with unknown-defaults-terminal), exponential backoff, offline pause, nonce-based dedupe, in-flight guard. Make it durable and DB-enforced.
<Activity/>channel pool + suspended per-channel runtimes — instant channel switching. In a sync-engine world this becomes even cheaper because state lives in the local store, not component trees.- Denormalized single-round-trip page responses (messages + users + members + reply parents + threads + my_reactions) — as a wire format; normalize into the local store on arrival.
- Threads as channels — reuses pagination, permissions, unreads.
- Attachment pipeline: upload-before-send with progress, entity-per-attachment, blurhash, server-side variants.
- Write-path discipline: all writes through
models/context/*; fanout owned by one pipeline. Retain EventV2/CDC lessons for server-side search, audit, notifications and jobs. - Entry-plan concept: explicit
latest | last-read | selected-messagelanding intents computed at route time.
Avoid (the scar tissue)
- Layered dual/triple state stores. No MobX-entity-map + query-cache + atom-mirror sandwich.
- The query cache as the message window. Storing the visible window as TanStack infinite pages forces destructive resets on jump, paired cache/atom resets, trim bookkeeping, and append-plan gymnastics.
- Replay-or-nuke reconnects. Let Zero reconcile authoritative row changes; do not build a second per-channel version/checkpoint protocol.
- Imperative phase machine + event emitter as the control plane (
chatRuntime.tstoken cancellation,chatEmitter, watchdog). - Reverse-virtualization fights: budget for scroll anchoring as a first-class feature (height reservation from attachment/embed metadata, anchor-based scroll restoration).
- Async batched read-state pipeline (Redis batch → BullMQ → sharded Mongo docs → pubsub).
- Client-only idempotency — enforce
(channel, nonce)uniqueness in the DB from day one. - Post-hoc mutation events that grow content (
MessageAppendembeds) without reserved layout. - Read-path repair code as permanent fixtures (NaN timestamps,
replies[0], legacy unread model). - Six-runtime fanout for a single message send. Fewer hops = fewer partial-failure modes; a sync engine collapses "write" and "notify" into one log.
Files most worth reading before designing the replacement: docs/CHAT-CONTEXT-PACK.md (1,434 lines, excellent), app/src/modules/Chat/ChatMessageList/messagesApi.ts, app/src/modules/Chat/ChatV3/chatRuntime.ts, app/src/modules/Chat/chatOutbox/outboxProcessor.ts, models/Message.ts, models/UserUnreads.ts, app/src/modules/Chat/ChatChannelActivityPool.tsx.