Navigation

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 StorageAppend-heavy, time-ordered streams keyed by their owning entity — messages and their mirrors, moderation, survey, resource and program records (AzureTable is the full set)server/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), inbound webhook messages, scheduled message jobs, TodoList due reminders, durable blob deletion, dead-letter replay, resource purging, storage-ledger reconciliationapps/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.

Not every EventGrid handler consumes an event this app published. ReconcileStorageLedgerEntry — the deployed function, which calls the service of the same name — subscribes to the storage account's own system topic, so Microsoft.Storage.BlobCreated is what replaces a client's declared upload size with the stored object's real length in the per-user storage ledger — a fact no server of ours ever observes, because the block PUT never passes through one (storage quotas).

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 → Event Grid dead-letter
DungeonsAssetsDungeons game save state ({userId}/save)
MessageAssetsMessage file attachments ({roomId}/{fileId}|{filename}). 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/{uuid})
ResourceAssetsResource content blobs, publish snapshots, and type-owned files → resources

Table Storage tables

Table names live in the AzureTable enum (packages/db-schema/src/models/azure/table/AzureTable.ts) — that enum is the full set, and the default shape is partitionKey = <owning entity id> (a room, a resource, a survey resource) with rowKey = reverseTickedTimestamp for newest-first reads. Moderation logs and notes, resource activity and program participants are all that shape and need nothing said about them. The ones that are not:

Table (AzureTable)Departure
MessagesAscendingKey-only mirror of Messages keyed by the original timestamp, so the same rows can be read oldest-first
MessagesMetadataSidecar entities keyed to a message rather than to a point in time
ResourceViewsBest-effort view counters bucketed per resource per UTC day, not one row per event
SurveyResponsespartitionKey = survey resource id, and served as a dataset → 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 apps/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 → notification

flowchart TD
  CM["createMessage (tRPC mutation)"] --> AT["Azure Table write<br/>Messages + MessagesAscending"]
  AT --> EE["messageEventEmitter.emit — createMessage"]
  EE --> SUB["tRPC subscriptions (in-process)"]
  AT --> EG["publishNotification<br/>one typed event, unconditionally"]
  EG --> FN["ProcessNotification<br/>(Azure Function)"]
  FN --> RES["resolve recipients + channels"]
  RES --> WP["web-push to each subscription"]

EventGrid decouples the HTTP response from delivery. The Function handles retries independently of the tRPC request lifecycle, and it is the only place that asks who a notification reaches — which is why the mutation publishes without first resolving recipients (notifications).

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, storage usage from the Functions host
Media / signalingLiveKit SFUExternal serviceAudio, video, screenshare tracks and participant lifecycle

The layers split on which process holds the writer, not on how much fan-out is wanted. A change the app makes announces itself in-process; a change an Azure Function makes has no emitter the app is listening to, so it publishes to a hub instead — webhook messages on Messages, and a settled or released storage charge on Storage (storage quotas). A surface fed by both writers subscribes to both.

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.

Details

Command palette

Keyboard shortcuts