Esposter

Azure Services

Which Azure services are used, what each one owns, and which package accesses it.

Service map

ServiceWhat it stores / doesPrimary access point
Azure Blob StorageProfile images, message file attachments, resource content + publish snapshots, game save stateserver/composables/azure/container/useContainerClient.ts
Azure Table StorageMessages (newest-first + ascending mirrors + metadata), moderation logs, survey responsesserver/composables/azure/table/useTableClient.ts
Azure AI SearchMessage full-text search index (searchMessages, readMySentMessages)server/composables/azure/search/useSearchClient.ts
Azure FunctionsAsync workers — push notifications (message, friend request, thread reply), webhook delivery, scheduled message jobs, TodoList due reminders, durable blob deletion, dead-letter replay, resource purgingpackages/azure-functions/src/functions/
Azure Event GridDecouples mutations from fire-and-forget async work; app publishes events, Functions consume themserver/composables/azure/eventGrid/useEventGridPublisherClient.ts
Azure Service BusDelayed/scheduled work — the scheduled-message-jobs queue (delivery at a future runAt) and the todo-reminders queue (TodoList due reminders)server/composables/azure/serviceBus/useServiceBusSender.ts
Azure Web PubSubWebhook message delivery and cross-process fan-out (separate from tRPC subscriptions)server/composables/azure/webPubSub/useWebPubSubServiceClient.ts
LiveKitAudio/video SFU — signaling, media tracks, participant lifecycleserver/api/webhooks/livekit.post.ts (webhook); livekit-server-sdk server-side; livekit-client browser-side

EventGrid vs Service Bus: EventGrid is fire-and-forget now (push a notification the moment a message lands); Service Bus is fire later (a scheduled message job or TodoList reminder must run at its runAt/dueAt). Both terminate in Azure Functions handlers.

Blob Storage containers

Container names live in the AzureContainer enum (packages/db-schema/src/models/azure/container/AzureContainer.ts):

Container (AzureContainer)Contents
AppAssetsApp-owned static assets
ClickerAssetsClicker game save state ({userId}/save)
DeadLetterEvent Grid dead-letter payloads plus their archived/ and quarantine/ copies → /docs/infra/eventgrid-dead-letter
DungeonsAssetsDungeons game save state ({userId}/save)
MessageAssetsMessage file attachments ({roomId}/{fileId}). Lifecycle policy tiers blobs Cool@30d → Cold@90d to cut storage cost
PrivateUserAssetsPer-user private blobs
PublicUserAssetsUser profile images ({userId}/ProfileImage), room profile images (rooms/{roomId}/ProfileImage)
ResourceAssetsResource content blobs, publish snapshots, and type-owned files → /docs/architecture/resources

Table Storage tables

Table names live in the AzureTable enum (packages/db-schema/src/models/azure/table/AzureTable.ts):

Table (AzureTable)Contents
MessagesRoom messages, partitionKey = roomId, rowKey = reverseTickedTimestamp (newest-first)
MessagesAscendingKey-only index of messages keyed by the original timestamp, for ascending reads
MessagesMetadataPer-message metadata entities
ModerationLogModeration/admin action log per room
SurveyResponsesSurvey responses, partitionKey = survey resource id/docs/architecture/datasets

Search index (messages-index)

Azure AI Search holds one index, messages-index, that powers filtered message search (searchMessages) and the Sent tab (readMySentMessages). Its full schema is a data-plane resource — not Pulumi-managed — recreated from packages/infra/data/searchIndexes/messages-index.json.

The index is populated by a scheduled Azure Table pull indexer (messages-indexer), not by a push on write: the indexer reads the Messages table on an interval measured in minutes and upserts each row as a document. A newly sent message therefore becomes searchable shortly after it lands rather than synchronously.

The indexer owns the index, so nothing in this repo writes documents to it. Its own control plane is the operational tooling: GET /indexers/messages-indexer/status reports the last runs, document counts, and per-document errors, and POST /indexers/messages-indexer/reset followed by POST .../run clears the high-water mark and re-reads the whole table. Both are one click each in the portal's indexer blade. Soft deletes need no special handling — deletedAt is a column the indexer already carries, and queries exclude it with a null clause.

flowchart TD
  MT["Messages table (Azure Table)"] -->|"messages-indexer pull, minute-scale interval"| IDX["messages-index (Azure AI Search)"]
  IDX -->|"searchMessages"| SM["Filtered full-text search"]
  IDX -->|"readMySentMessages"| SENT["Sent tab"]

Each document is keyed by RowKey (the message's reverse-ticked rowKey), so re-feeding the same message is an idempotent upsert. The fields that matter:

FieldRole
RowKeyDocument key — Edm.String, sortable, filterable
PartitionKeyOwning room id — filterable (scopes search to a room)
messageSearchable body text, analyzer en.lucene
files/filenameSearchable attachment name, analyzer standard.lucene
appUser/nameSearchable sender name, analyzer standard.lucene
userIdFilterable — the Sent tab filters to the caller's own messages
deletedAtFilterable — a null clause excludes soft-deleted messages at query time
createdAt / updatedAtFilterable + sortable — the Sent tab orders by createdAt descending
type, isEdited, isForward, isPinned, mentions, linkPreviewResponseRetrievable/filterable message attributes

Relevance uses BM25 similarity with the messageBoost scoring profile as default — the message field is weighted 3× and appUser/name 1.5×, so a query hitting the body outranks one hitting only the sender name. The canonical searchable-field list lives in SearchIndexSearchableFieldsMap (packages/db-schema).

Azure Table vs Postgres

Use Azure Table for high-volume, append-heavy, time-ordered data with no complex joins (messages, moderation logs, survey responses). partitionKey = <owning entity id>, rowKey = reverseTickedTimestamp gives newest-first ordering for free.

Use Postgres (Drizzle) for relational, queryable data — users, rooms, roles, bans, invites, push subscriptions, posts, achievements, resources, call sessions.

When adding a new feature: pick Postgres for anything relational or queryable; pick Azure Table for anything message-like (high write volume, time-ordered, no complex joins).

Event flow: createMessage → push notification

flowchart TD
  CM["createMessage (tRPC mutation)"] --> AT["Azure Table write<br/>Messages + MessagesAscending"]
  AT --> EE["messageEventEmitter.emit(#quot;createMessage#quot;)"]
  EE --> SUB["tRPC subscriptions (in-process)"]
  AT --> PS["getPushSubscriptionsForMessage()"]
  PS --> EG["EventGrid publish<br/>PushNotificationEventGridData"]
  EG --> FN["ProcessPushNotification<br/>(Azure Function)"]
  FN --> WP["web-push to offline users"]

EventGrid decouples the HTTP response from push delivery. The Function handles retries independently of the tRPC request lifecycle.

Real-time architecture (three layers)

LayerTechnologyScopeWhat it drives
In-process eventsNodeJS EventEmitter (messageEventEmitter, moderationEventEmitter, roomEventEmitter)Single server instancetRPC subscriptions (onCreateMessage, onAdminAction, etc.)
Cross-process fan-outAzure Web PubSubAll server instancesWebhook message delivery
Media / signalingLiveKit SFUExternal serviceAudio, video, screenshare tracks and participant lifecycle

tRPC subscriptions are driven by the in-process EventEmitter. The LiveKit webhook (server/api/webhooks/livekit.post.ts) feeds participant join/leave back into callEventEmitter so non-participants and tRPC subscriptions stay consistent without touching the SFU.