The Cloudflare microservices story: one app, two Durable Objects, each its
own sync stream. RoomDO owns a chat room's messages; InboxDO owns a user's
notifications. They share no storage and no connection — the browser opens
one transport per DO and stitches the two collections back together
client-side.
The example imports the library from source (../../src), so it always tracks
the current code. A published consumer would import from
tanstack-do-db-collection / .../client instead.
npm install
npm run dev # builds the client bundle, then `wrangler dev`Open the printed URL (default http://localhost:8787). Post messages, hit “notify
me”, then rooms.clearRoom() / inbox.markAllRead() and watch the merged feed
update. Open a second tab to see the room sync live across clients.
npm run build:client— bundle the React client topublic/client.js(esbuild)npm run watch:client— rebuild on change (run alongsidewrangler dev)
There is no muxed connection. The Worker routes straight into each DO:
/rooms/:room/sync → env.ROOM_DO.get(idFromName(room)).fetch(req)
/inbox/:user/sync → env.INBOX_DO.get(idFromName(user)).fetch(req)
everything else → ASSETS (index.html + client.js)
A DO's sync stream is a single ordered WebSocket with one client cursor (ADR-0002). That invariant is per DO — so two DOs means two transports, each the ordered stream for exactly one DO:
const roomTransport = new WebSocketTransport<RoomApi>({ url: ".../rooms/lobby/sync?user=…" })
const inboxTransport = new WebSocketTransport<InboxApi>({ url: ".../inbox/<me>/sync?user=…" })Each transport is parameterized by that DO's Api (imported as a type
only — nothing server-side is bundled). So roomTransport.call.* exposes only
RoomDO's commands and inboxTransport.call.* only InboxDO's, fully typed.
Command names are scoped to a DO, not global. RoomDO could name a command
markAllRead too, and it would be a different command on a different stream.
To keep that collision-safe on the client, the transports are exposed through a
SyncProvider keyed by DO, read with a useSync hook:
const rooms = useSync("rooms") // WebSocketTransport<RoomApi>
const inbox = useSync("inbox") // WebSocketTransport<InboxApi>
await rooms.call.clearRoom() // RoomApi — zero-arg command, returns { deleted }
await inbox.call.markAllRead() // InboxApi — returns { marked }rooms.call.* and inbox.call.* are two disjoint, independently-typed
namespaces. There is no global command table to collide in: the DO you reached
is the namespace.
The DO never joins, aggregates, or runs IVM (ADR-0001) — reads are client-side.
A “cross-DO view” is therefore assembled in the browser: two useLiveQuery
hooks (one per DO collection) merged into a single sorted timeline in render.
Both inputs stay live, so the merged feed updates whenever either DO emits a
delta. There is no server-side join across DOs — there couldn't be; they're
separate objects on separate streams.
- Mutations are typed single-row writes on a collection and ride the
optimistic path: posting a message (
messages.insert), marking one notification read (notifications.updatewith{ read: 1 }). - Commands are everything else — bulk ops or anything returning a value:
clearRoom(deletes all, returns the count) andmarkAllRead(returns how many it flipped). Their own SQL still flows through the CDC triggers, so the bulkDELETE/UPDATEfans out to every connected tab as ordinary deltas.
src/env.ts— sharedClaims+Env(both DO bindings); kept separate so the schemas never import the worker.src/room-schema.ts— RoomDO'sdefineSyncschema (messagesinsert +clearRoomcommand); exportsRoomApi.src/inbox-schema.ts— InboxDO's schema (notificationsinsert/update +markAllReadcommand); exportsInboxApi.src/worker.ts—RoomDO+InboxDO(eachregisterSyncs its schema) and the upgrade router.src/client.tsx— two typed transports behind a per-DOSyncProvider/useSync, two collections, and the merged cross-DO feed viauseLiveQuery.