Client-side DO mental model
Mental model at a glance
Section titled “Mental model at a glance”There are two directional flows you should keep straight:
- Outbound: local collection mutation -> RPC call -> DO writes state.
- Inbound: DO emits event -> subscription handler -> local collection updates.
The Durable Object is the source of truth. Local collections are optimistic and sync through events.
Outbound flow (collection -> DO)
Section titled “Outbound flow (collection -> DO)”In both the inventory and currency collections, the pattern is the same:
- A collection mutation happens (
insert,update, ordelete). createMutationHandlersresolves the owning DO client byinventoryId.- The mutation metadata includes an
actiondescribing the RPC call to make. - The RPC method is called on the DO client.
This keeps UI logic simple: state changes are declared once, and the RPC call is derived from metadata.
Inbound flow (DO -> collection)
Section titled “Inbound flow (DO -> collection)”The DO uses createRpcServer to emit events when RPC methods finish. In the client:
createSubscriptionHandlersbinds RPC events to collection updates.- Each RPC event handler inserts, updates, or deletes local records.
- Transfers update multiple inventories by mutating multiple records.
Examples:
updateCurrencymerges new currency totals into a single record keyed byinventoryId.transferCurrencyreduces the source record and increases the target record.transferEntryupdates an entry’sinventoryIdandsortOrderin place.
The client never needs to poll because the DO broadcasts events for every write.
One important detail: events are only emitted when a mutation flows through the RPC server
(WebSocket request or callAndEmit). Direct storage writes that do not pass through the RPC
server will not broadcast events to subscribers.
InventoryCollection: updateEntry flow
Section titled “InventoryCollection: updateEntry flow”The inventory collection keeps both sides minimal because the method name is the same on both sides.
Outbound (mutation -> RPC):
update: async ({ client, metadata }) => { const action = (metadata as { action?: InventoryAction })?.action; if (action?.type === 'updateEntry') { await client.client.updateEntry(action.payload); }}Inbound (event -> collection update):
updateEntry: { update: async ({ payload, collection }) => { const entry = payload.result; if (!entry) { return; } collection.update(entry.entryId, (draft) => ({ ...draft, ...entry, })); },}Because the outbound call and inbound event share the same method name, you can reason about every mutation as a round trip with a single source of truth (the DO).
InventoryCollection: transferEntries flow
Section titled “InventoryCollection: transferEntries flow”The transfer flow is similarly straightforward: the outbound call passes through, and the inbound event updates the local records that moved inventories.
Outbound (mutation -> RPC):
update: async ({ client, metadata }) => { const action = (metadata as { action?: InventoryAction })?.action; if (action?.type === 'transferEntries') { await client.client.transferEntries(action.payload); }}Inbound (event -> collection update):
transferEntries: { update: async ({ payload, collection }) => { const result = payload.result ?? payload.args[0]; if (!result) { return; } result.results.forEach((entryResult) => { const entry = collection.get(entryResult.entryId); if (!entry) { return; } collection.update(entryResult.entryId, (draft) => { draft.inventoryId = entryResult.toInventoryId; draft.sortOrder = entryResult.sortOrder; draft.updatedAt = new Date().toISOString(); }); }); },}Why TanStack DB + WebSockets
Section titled “Why TanStack DB + WebSockets”- TanStack DB keeps a local, reactive cache so UI updates are immediate.
- Durable Objects remain authoritative, and WebSocket events reconcile every client.
- Shared RPC method names keep outbound actions and inbound events aligned.
- Reconnect + resubscribe behavior means your local cache recovers without manual refetching.
Why the “bootstrap” guard exists
Section titled “Why the “bootstrap” guard exists”Both collection files treat metadata.source === 'bootstrap' specially. This prevents a loop:
- Client seeds local state from a server snapshot.
- That insert/update would normally call the DO again.
- The
bootstrapmetadata short-circuits outbound RPC calls.
Treat it as a one-way import flag so local hydration does not re-trigger writes.
End-to-end example flow
Section titled “End-to-end example flow”sequenceDiagram participant UI participant Collection as InventoryCollection participant Handlers as Mutation handlers participant Client as DO client participant OtherClient as Other client participant InventoryDO as Inventory DO UI->>Collection: collection.update(...) Collection->>Handlers: createMutationHandlers Handlers->>Client: updateEntry(payload) Client->>InventoryDO: WS message: updateEntry(...) InventoryDO-->>Client: WS message: response InventoryDO-->>OtherClient: WS message: event (updateEntry) Client-->>Collection: update local record OtherClient-->>OtherClient: update local record Collection-->>UI: re-render
The two flows share the same method names (updateEntry, transferCurrency, etc.),
which keeps reasoning simple: outbound actions mirror inbound events.
Advanced: two-phase transfers with a coordinator DO
Section titled “Advanced: two-phase transfers with a coordinator DO”Transfers cross two inventories, so a single DO cannot update both sides atomically. The pattern here is:
- A coordinator DO (Hoarder) owns the transfer record and step progression.
- Inventory DOs perform the actual state changes (add/remove entries).
- Inventory DOs emit RPC events after their local mutation; the coordinator does not emit events.
Mermaid sequence diagram:
sequenceDiagram participant UI participant Collection as InventoryCollection participant Client as DO client participant OtherClient as Other client participant SourceInventoryDO as Source Inventory DO participant HoarderDO as Hoarder DO participant TargetInventoryDO as Target Inventory DO UI->>Collection: collection.update(...) Collection->>Client: transferEntries(payload) Client->>SourceInventoryDO: WS message: transferEntries(...) SourceInventoryDO->>HoarderDO: transferEntry(transferId,...) HoarderDO->>TargetInventoryDO: addEntry(entryPayload) HoarderDO->>SourceInventoryDO: removeEntry(entryId) SourceInventoryDO-->>Client: WS message: event (transferEntries result) SourceInventoryDO-->>OtherClient: WS message: event (transferEntries result) Client-->>Collection: update local records OtherClient-->>OtherClient: update local records Collection-->>UI: re-render
Inventory DO delegates to Hoarder and returns a payload that becomes the event data:
const result = await hoarderStub.transferEntry({ transferId, campaignId, fromCharacterId, toCharacterId, fromInventoryId, entryId, targetInventoryId: toInventoryId,});
if (result.status !== 'completed') { throw new Error(result.reason ?? 'Transfer failed');}return { transferId, fromInventoryId, toInventoryId, entryId, sortOrder: entry.sortOrder,};The Hoarder DO keeps a durable transfer record with explicit phases, so retries are safe:
if (record.status === 'pending' || record.status === 'failed') { await targetStub.addEntry({ entryId: entryPayload.entryId, ... }); this.updateTransfer(transferId, { status: 'target_added' });}
if (record.status === 'target_added') { await sourceStub.removeEntry({ entryId: entryPayload.entryId }); this.updateTransfer(transferId, { status: 'source_removed' });}
this.updateTransfer(transferId, { status: 'completed' });The client only sees a single transferEntries call and a matching event from the Inventory DO,
but the coordinator ensures the multi-step change is restart-safe and idempotent without broadcasting.
Key takeaways
Section titled “Key takeaways”- Use DOs as the source of truth; collections are a cache that stays in sync through events.
- Keep outbound RPC calls derived from mutation metadata to avoid duplicated logic.
- Make inbound subscription handlers idempotent so repeated events are safe.