LiveConnection WS Client
src/lib/offline/live.ts is the client end of event push (ADR-011). It holds the live WebSocket to the user’s SyncInbox and, on each signal, triggers the existing delta-read reconcilers — the socket replaces the polling timer, not the data path.
The diagram below shows the client pieces and how an inbound signal becomes a refresh.
flowchart LR
CLC["createLiveConnection(deps)<br/>live.ts"] -->|"opens WS<br/>SYNC_STREAM_PATH"| WS{{"GET /api/v1/sync/stream"}}
WS --> DO[["SyncInbox DO"]]
WS -->|"SyncSignal"| MSG["onMessage"]
MSG --> SUP["takeUnsuppressedTags()"]
MSG --> MAP["mapSurfaceTags(tags, active)"]
MAP --> COA["createSignalCoalescer()<br/>~500 ms debounce"]
COA -->|"refresh(surfaces)"| PC["PollController<br/>poll.ts"]
CLC -->|"on close"| RD["reconnectDelayMs()<br/>backoff + jitter"]
CLC --> ST["getLiveState()<br/>subscribeLiveState()"]
Key pieces
Section titled “Key pieces”createLiveConnection(deps)— opens/closes the socket on lifecycle (foreground + online + signed-in), runs a catch-up?sinceread on connect, and reconnects with backoff.reconnectDelayMs(...)— backoff with jitter, capped atRECONNECT_CAP_MS(30 s).createSignalCoalescer(...)— debounces bursts per surface (~500 ms) so many signals collapse into one refresh.mapSurfaceTags(tags, active)— maps signal surface tags to the surfaces the client should refresh.- Self-mutation suppression —
noteLocalMutation(tags)records the surfaces a local write just touched;takeUnsuppressedTags(tags)filters those out of an incoming signal so a client does not redundantly re-read its own change.SYNC_STREAM_PATHis/api/v1/sync/stream. - State —
getLiveState()/subscribeLiveState(listener)expose transport state (live / reconnecting-fallback / offline) for the status UI.
Fallback
Section titled “Fallback”While the socket is down, a slow polling fallback (PollController, src/lib/offline/poll.ts) drives refreshes, and every reconnect performs a catch-up read — so convergence and offline-first are preserved regardless of socket health (ADR-006 → ADR-011).
The connection lifecycle — open, signal-driven refresh, drop and backoff — looks like this:
sequenceDiagram
participant L as "createLiveConnection()"
participant DO as "SyncInbox DO"
participant P as "PollController"
L->>DO: "open WS (foreground + online)"
DO-->>L: "open"
Note over L: "onOpen — setTransport('live')"
L->>P: "refresh(catchUpSurfaces()) — catch-up read"
DO-->>L: "SyncSignal {surfaces}"
Note over L: "takeUnsuppressedTags → mapSurfaceTags → coalesce"
L->>P: "refresh(surfaces)"
DO-->>L: "close / error"
Note over L: "setTransport('fallback')"
L->>L: "scheduleReconnect — reconnectDelayMs(attempt)"
L->>DO: "reconnect → catch-up read again"
Related: SyncInbox Durable Object (the server end), Sync-Core Reconcilers (what a signal triggers), Poll Controller (the fallback).