How do demos/tanstack-ai-durable-session streams work?

HEAD
41a9a3dMerge pull request #10 from electric-sql/thruflo/tweaks

tell me how demos/tanstack-ai-durable-session work. is it a single stream per session? how are non-ai events (like presence) emmitted? in a child stream, or everything's in the same stream? when i reload, the stream loads in the client? so no SSR possible?

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

Now I have a complete picture. Here's the comprehensive answer:


##How demos/tanstack-ai-durable-session works

###Single Stream Per Session — Yes, Everything in One Stream

The entire architecture is built around one Durable Stream per session. All data types — AI chat chunks, presence events, and agent registrations — are multiplexed onto the same stream using a STATE-PROTOCOL with typed events.

The stream is created in the proxy (packages/durable-session-proxy/src/protocol.ts):

const stream = new DurableStream({
  url: `${this.baseUrl}/v1/stream/sessions/${sessionId}`,
})
await stream.create({ contentType: 'application/json' })

And the client connects to it via a single URL (packages/durable-session/src/collection.ts):

const streamUrl = `${baseUrl}/v1/stream/sessions/${sessionId}`
const rawDb = createStreamDB({
  streamOptions: { url: streamUrl, headers, signal },
  state: sessionStateSchema,
})

###How Non-AI Events (Presence, Agents) Are Emitted

There are no child streams. Everything goes through the same stream, differentiated by a type field in each event. The schema (packages/durable-session/src/schema.ts) defines three event types:

export const sessionStateSchema = createStateSchema({
  chunks: {    // AI message chunks
    schema: chunkValueSchema,
    type: 'chunk',
    primaryKey: 'id',
  },
  presence: {  // User/agent online status
    schema: presenceValueSchema,
    type: 'presence',
    primaryKey: 'id',
  },
  agents: {    // Registered webhook agents
    schema: agentValueSchema,
    type: 'agent',
    primaryKey: 'agentId',
  },
})

The @durable-streams/state library (createStreamDB) reads this single event stream and demuxes events by type into separate TanStack DB collections on the client. So when the proxy writes a presence event:

// protocol.ts:writePresence()
const event = sessionStateSchema.presence.upsert({
  key: `${actorId}:${deviceId}`,
  value: { actorId, deviceId, actorType, name, status, lastSeenAt },
})
await stream.append(event)  // same stream as chat chunks!

…it goes into the same stream as chat chunks and agent registrations, but the client-side stream-db routes it to db.collections.presence based on the type: 'presence' field.

On top of these three raw collections, the DurableChatClient builds derived collections (packages/durable-session/src/client.ts:createCollections()):

CollectionSourceDescription
chunksRaw from streamAll chunk events
messagesDerived from chunksMaterialized MessageRow[] (chunks grouped by messageId)
toolCallsDerived from messagesMessages filtered to tool-call parts
pendingApprovalsDerived from messagesTool calls awaiting approval
activeGenerationsDerived from messagesIn-flight assistant responses
presenceDerived from raw presenceAggregated per-actor (not per-device)
agentsRaw from streamRegistered agents
sessionMetaLocal-onlyConnection status etc.
sessionStatsDerived from chunksAggregated stats

###On Reload: Stream Replays on the Client

When you reload the page, the useDurableChat hook:

  1. Constructs the DurableChatClient synchronously (creates empty collections)
  2. Connects via client.connect() in a useEffect (the autoConnect: true default):
    // use-durable-chat.ts
    useEffect(() => {
      if (autoConnect && client.connectionStatus === 'disconnected') {
        client.connect().catch(...)
      }
      return () => { client.dispose() }
    }, [client, autoConnect, providedClient])
    
  3. connect() calls PUT /v1/sessions/${sessionId} to ensure the session exists, then calls this._db.preload() — which reads the entire stream from the beginning (all historical events) into the local TanStack DB collections.

So yes, on reload, the full message history, presence state, and agent registrations are all replayed from the stream into the client. There is no separate database query — the stream is the source of truth.

###SSR: Partially Possible, But Limited Today

The demo is not fully SSR'd for the chat data. Here's why:

  1. The route loader only does login — it calls POST /v1/sessions/${sessionId}/login and returns params. It does NOT fetch or render messages server-side.

  2. useDurableChat connects client-side — the stream connection (preload()) happens in a useEffect, which only runs in the browser.

  3. useLiveQuery doesn't support SSR — TanStack DB's useLiveQuery lacks getServerSnapshot, so every component that queries collections is wrapped in a ClientOnly pattern:

    // chat.$sessionId.$username.tsx
    function ClientOnlyPresenceBar(...) {
      const [isClient, setIsClient] = useState(false)
      useEffect(() => { setIsClient(true) }, [])
      if (!isClient) return <div>Loading...</div>
      return <PresenceBar ... />
    }
    

    There's even a comment referencing TanStack/db#709 as the upstream fix.

  4. However, useDurableChat itself does try to be SSR-safe — the hook uses useSyncExternalStore with a getServerSnapshot for its core data (messages, isLoading, connectionStatus):

    // use-durable-chat.ts:useCollectionData()
    return useSyncExternalStore(
      subscribeRef.current,
      getSnapshotRef.current,
      getSnapshotRef.current  // same fn for server snapshot → empty array
    )
    

    This means the hook itself won't crash during SSR — it'll just return empty messages. The shell renders fine, but the actual chat data only appears after the client-side stream connection completes.

In summary: SSR of the page shell works fine, but the chat messages/presence/agents data is client-only because it depends on connecting to the Durable Stream, which is an inherently client-side operation (long-lived event stream). You could theoretically pre-fetch messages from the proxy in the route loader and pass them as initial data, but the current architecture doesn't do this — the stream is the single source of truth and it's consumed client-side.


END OF POST

How do demos/tanstack-ai-durable-session streams work? — electric-sql/transport