Messaging
Messages are high-volume, append-heavy, and time-ordered, so they live in Azure Table Storage rather than Postgres. Everything relational around them (rooms, membership, roles) stays in Postgres.
How it works
Messages are stored in AzureTable.Messages with partitionKey = roomId and rowKey = reverseTickedTimestamp — a reverse-ticked timestamp is MAX_TICKS - now, so lexicographic row order is newest-first, which matches how a chat list paginates. AzureTable.MessagesAscending is a companion index table keyed by the original timestamp so messages can be read in both directions (thread view, jump-to-message). AzureTable.MessagesMetadata holds per-message companion data.
Sending a message flows through one server service, createUserMessage, which writes storage, notifies live subscribers, and kicks off push delivery:
sequenceDiagram
participant C as Composer (RichTextEditor)
participant T as message.createMessage (tRPC)
participant AT as Azure Table Storage
participant E as messageEventEmitter
participant EG as Azure Event Grid
participant F as processPushNotification (Azure Function)
C->>T: createMessage({ roomId, message })
T->>T: assertCanCreateMessage, then advance the slowmode clock
T->>AT: insert Messages + MessagesAscending rows
T->>E: emit("createMessage", entity)
E-->>C: onCreateMessage subscription delivers to connected members
T->>T: getPushSubscriptionsForMessage(db, entity)
T->>EG: publish PushNotificationEventGridData (only if recipients exist)
EG->>F: event delivered
F->>F: web-push to each subscription
T->>T: roomEventEmitter.emit("updateRoom") — bumps room.updatedAt
Real-time delivery is two-layered:
- In-process —
messageEventEmitter/roomEventEmitterdrive tRPC subscriptions (onCreateMessage,onUpdateMessage,onDeleteMessage,onCreateTyping). The app runs a single Node process, so this is sufficient (see cross-process event bridge). - Azure Web PubSub — used for inbound webhook message delivery; clients get a scoped access URL via
getWebPubSubClientAccessUrl.
Push notification filtering and delivery detail lives in /docs/esbabbler/push-notifications.
Conditional writes
A message is stored as one blob, so a procedure that changes part of it reads the whole entity and writes the whole entity back. Two of those running at once both compute their result from the same stored version, and the later write echoes back a body that never saw the earlier one — the earlier change is erased with nothing surfaced to either caller. Poll voting is where that is routine rather than rare: every member of the room writes into the same poll body.
getMessageProcedure therefore reads through getEntityWithEtag and carries the version it saw on the procedure context as messageEtag, alongside messageClient and messageEntity. The read already happens, so the version costs no extra round trip and any message procedure can make its write conditional. votePoll passes it as the etag option on updateEntity, so the write lands only if nothing else has written since.
sequenceDiagram
participant A as Voter A
participant B as Voter B
participant AT as Azure Table Storage
A->>AT: getEntityWithEtag — version 1
B->>AT: getEntityWithEtag — version 1
B->>AT: updateEntity with version 1 — accepted, stored as version 2
A->>AT: updateEntity with version 1 — rejected 412
A->>AT: getEntityWithEtag — version 2, carrying B's vote
A->>AT: updateEntity with version 2 — accepted, both votes stored
A rejected write means the vote is still valid and only the version it was computed against is stale, so it is re-read and re-applied rather than surfaced. votePoll does not own that loop: it hands getUpdateEntity and writeEntity to the shared updateEntityConditionally helper (conditional writes), which owns the re-read, the retry and the bound. The bound is private to the helper and shared by every conditional write in the repo, so there is no poll-specific retry budget to raise — changing it changes deleteFile, deleteLinkPreviewResponse and unpinMessage with it. A vote that still cannot land is refused with CONFLICT so the voter sends it again instead of being shown a vote that never counted, and a failed write whose re-read finds the version unchanged was never a lost race, so that error propagates as itself rather than being retried into a CONFLICT.
The stored poll body is parsed and re-serialized through pollMessageContentSchema (shared/models/message/poll/) — the one schema that owns a poll's shape, and the same one Poll.vue reads it back with. A vote sends only the option id, so nothing else in the body may change across it: a narrower server-side copy of that schema strips every field it does not name, and the first vote on a poll would take its option labels with it.
Message types
MessageType (in @esposter/db-schema) discriminates rendering and behaviour: Message, Poll, Call (call started / call-end duration system message), EditRoom, PinMessage, System (join/leave), and Webhook. Adding a type requires updating MessageEntityMap (type → entity class) and MessageComponentMap (type → Vue rendering component).
Mentions are stored as HTML: the TipTap mention suggestion inserts <span data-type="mention" data-id="..."> nodes, which the server later parses for notification targeting and the client resolves to display names (see /docs/esbabbler/nicknames).
Procedures
The message router is flat-merged at the tRPC root, with emoji, moderation, and scheduledMessageJob nested under it. Highlights:
| Procedure | Auth | Purpose |
|---|---|---|
createMessage | member | Write message, emit, trigger push |
updateMessage / deleteMessage | author | Edit/delete own messages (message-scoped procedure) |
forwardMessage | member | Forward into another room |
pinMessage / unpinMessage | member | Room-wide pins |
votePoll | member | Cast/withdraw a poll vote via a conditional write |
readMessages / readThread | member | Cursor pagination / thread view |
searchMessages | member | Filtered search via the Azure AI Search index |
readMySentMessages | authed | Cross-room sent list from the Search index |
onCreateMessage etc. | member | Live subscriptions |
createTyping / onCreateTyping | member | Typing indicators |
generate*FileSas* | member | SAS-based file upload/download URLs |
Key files
| File | Role |
|---|---|
packages/app/server/trpc/routers/message/index.ts | Message router (procedures above) |
packages/app/server/services/message/createUserMessage.ts | Send pipeline: table write, emit, EventGrid publish |
packages/db-schema/src/models/azure/table/AzureTable.ts | Table name enum (Messages, MessagesAscending, …) |
packages/db-schema/src/models/message/MessageType.ts | Message type discriminator |
packages/app/server/services/message/events/ | messageEventEmitter and friends |
Notes
- Never bypass
createUserMessagewhen adding a message-producing path — timeout, read-only, slowmode, and word-filter checks happen there and in the member procedures.forwardMessageis the one existing exception: it fans out over N rooms with a per-roomassertCanCreateMessageand cloned files, so it reproduces the pipeline instead of calling it. Any new path goes throughcreateUserMessage. - Send order is fixed on every send path, following persist then notify:
assertCanCreateMessage→ advance the slowmode clock (updateUserToRoom) → Table write →messageEventEmitter.emit→ best-effort side effects. The clock advances before the write because it is the value the next send is checked against — a send that skips it keeps comparing against a stalelastMessageAtand slowmode silently never applies, so it fails closed on a failed write rather than open on a failed update. - Azure Table has no joins: anything that must be queried relationally (e.g. who to notify) is resolved against Postgres at send time.
- Single owner per store transition. The subscription handler owns every remote-visible state change:
onCreateMessage/onUpdateMessage/onDeleteMessagewrite the message list throughstoreCreateMessage/storeUpdateMessage/storeDeleteMessage, and the same rule holds across the room, userToRoom, emoji, pin, call, and member stores. A caller-side store mutation is kept only when it is a genuine optimistic update with revert (theuseMutationapplyOptimisticblocks, and theisLoadingoptimistic send) or when the emit excludes the actor's own device (getRoomEventSubscription,getIsSameDevice) so the subscription can never reach the caller. No store applies a plain, non-optimistic mutation that a caller-reaching subscription would double-apply. - Idempotent by composite key. Because the subscription echoes back to the sender for
isSendToSelfsends (forward, pin) and on transport reconnect,storeCreateMessagededups on[partitionKey, rowKey]viacreateOperationData, and update/delete handlers are set/filter operations that re-apply cleanly. Re-emitting an already-applied event is a no-op — locked in bystore/message/data.test.tsandservices/shared/createOperationData.test.ts. - Ascending reads re-project onto the index order.
readMessages({ order: Asc })reads oldest-first ids fromMessagesAscending, then re-orders the joinedMessagesrows onto that sequence rather than trusting the Messages table's newest-first scan order — otherwise an ascending page comes back reversed. - An ascending page skips an index row the join cannot match, and always advances its cursor.
createMessagewritesMessagesAscendingfirst (so a rejection always means nothing is readable, which is what every caller's rollback assumes), and the two tables cannot be written atomically — so the index can briefly name a message the join does not find. The page must not hold its cursor on that row. Two reasons, either one sufficient: a soft delete produces the identical shape (deleteMessagestampsdeletedAt, which the join filters, and leaves the index row), so an unmatched row says nothing about whether an entity is coming; and every caller advances only bynextCursorwhilehasMoreis set —onCreateMessage's catch-up loop and the newer-messages waypoint both re-issue on the returned cursor — so echoing the incoming cursor is a hot loop, not a wait. The cost is paid on the write side instead:createMessagedeletes the index row when the entity write fails, bounding the unmatched window to one in-flight write. A page landing inside that window skips the message; the sender's ownonCreateMessagesubscription delivers it, andstoreCreateMessagededupes by composite key. That fallback is whyonCreateMessageattaches its emitter listener before it starts the catch-up, not after:onqueues what it receives until the loop consumes it, so a message committed while the catch-up is still paging is held rather than missed — attached afterwards, the one delivery path for a skipped message is the one thing not listening while the skip happens. The overlap can deliver a message twice, which the composite-key dedupe already absorbs.
Previous
Next