Replacing the browser stream database with server reads and live state
The design for retiring the browser-hosted stream database/processor host so a
stream feed is just useLiveState + useItxQuery like every other screen.
Reviewed against the code 2026-07-16; companion to
docs/frontend-development.md (which
documents the seam) and the
roadmap (which sequences it).
The problem#
The browser consumes streams via two models:
- Model 1 (itx-native, thin):
useLiveState((itx) => itx.liveState, sel)— the server owns a reduced view and pushes snapshot + diffs. The ⌘K stream switcher and the stream tree use it. - Model 2 (the browser database, heavy):
useBrowserStreamStore()+useStreamQuery(db, sql)— download the whole event log once per(projectId, streamPath)and run two processors client-side into an OPFS SQLite database, with cross-tab Web-Locks leader election, two-cursor durable checkpoints, a transactional write buffer, and a self-healing connection state machine. ~4,850 LOC:client-libraries/browser/(~4,140) + the two canonical processors (~706).
The seam: the browser database bypasses the itx hooks. ProjectStreamView
(apps/os/src/components/project-stream-view.tsx:133-182) builds a
createStreamClient factory that imperatively dials
connectItx()/connectIterateSession() and hands the stub to
acquireStreamRuntime, which drives its own React listeners, cross-tab writer
election, processors, and checkpoints. PR #2048 unified the socket (the browser writer uses the shared
session and only reports transport suspicion) but not the consumption
model.
The core realization: the two processors collapse asymmetrically.
- The raw
eventslog is immutable/historical/paged → maps ontouseItxQueryover the already-existingStream.getEventspager (server clamp 1–500,stream-durable-object.ts;getEvent({ offset })also exists). Low risk, no server work. - The
feed_itemsprojection +AgentUiStatelive tail is a reduced view → maps onto.liveState, but the fold that produces it (packages/ui/.../agent-ui-reducer.ts, ~1,064 LOC, + the browser-feedprojector.ts, ~243 LOC) runs only in the browser today. The server-side replacement is net-new materialized-view work in the stream DO. That is the long pole.
What the two browser processors produce, and who reads it#
Raw-events processor (processors/browser-raw-events/implementation.ts):
events— the verbatim wire log (raw_jsonbsource of truth; offset/type/ idempotency-key as generated columns; aBEFORE INSERTtrigger enforces idempotent replay + monotonic offsets). Offsets are dense:local_index = offset − 1, so an offset IS a position.event_type_counts— trigger-maintained per-type counts (avoidsCOUNT(*)full scans; ~1M rows ≈ 12s tail lag otherwise).- Readers: the raw-event inspector (point + prev/next by offset), the LLM
request inspector (events ≤
llmRequestOffset+ that request'sllm-response-chunks, reconstructed byllm-request-replay.ts), the pause-state read, and the header/filter counts.
Feed processor (processors/browser-feed/implementation.ts):
feed_items— one row per rendered feed item (local_indexPK; pretty and raw interleaved in one total order;kind=agent.<itemKind>|raw.group|raw.<component>).- Persisted
AgentUiStatereduced state — the live tail:{ live, queuedUserMessages, eventCount, presence, tokenUsage }. - Readers:
StreamFeedView(one virtualizedORDER BY local_indexlist; every mode is a filter over it),ProjectStreamView(busy/presence/running-LLM/ token-usage/queued), the trailingAgentLiveActivity.
The runtime itself also serves: appendBatch (the composer's target),
noteExternalAppend, runtimeState, metrics, nudge, clearLocalDatabase.
The server-side replacement#
itx.streams.get(path).liveState — the bounded feed live view#
A new server-hosted feed processor in the stream DO folds the same two lenses and exposes:
type StreamFeedLiveState = {
agent: AgentUiState; // live tail, queued, presence, token usage — verbatim reducer output
recentItems: FeedItem[]; // a BOUNDED window of the newest settled feed items
tailLocalIndex: number; // highest allocated local_index (feed length; also the event total)
paused: { paused: boolean; reason: string | null };
};The plumbing pattern already exists — the repo DO's get liveState()
(repo-durable-object.ts), LiveStateRelayRpcTarget (rpc-targets.ts), and
the project DO's registry getLiveState wiring
(project-durable-object.ts:86-108). What's missing is only the feed processor
itself. Consumed as useLiveState((itx) => itx.streams.get(path).liveState, sel).
Paged history — mostly already exists#
- Raw events:
getEvents({ beforeOffset | afterOffset, limit })paged reads underuseItxQuery; adjacency vialimit: 1; totals fromtailLocalIndex(offsets dense). No new server capability. - Feed history: one new server verb,
getFeedItems({ beforeLocalIndex, limit }), reading the feed processor's own settled rows — recommended, so the client never re-runs the projector for history (otherwise we re-import the fold we're deleting).
Why the live view must be bounded#
The live-state engine sends the whole state to each new listener and re-broadcasts on change — an unbounded array can't ride it. So the live view carries a bounded recent window and older history is a paged read, stitched in the UI. That is the correct shape, not a compromise: history is immutable, and immutable data is a finite read (the frontend guide's first rule of thumb).
What the feed view becomes#
ProjectStreamView keeps its shell, URL state, composer, header, and panels;
only the data plumbing changes:
- One
useLiveStatelistener replacesuseAgentUiReducedState(→feed.agent), the counts (→tailLocalIndex), and the pause read (→feed.paused). - Paged
useItxQueryovergetFeedItems/getEventsreplacesuseStreamQuery(db, sql)in the feed and both inspectors. - Mutations become direct capability calls (
appendon the stream / the agent chat door);noteExternalAppend/nudgedisappear — the listener's next push is the confirmation, anduseStreamConnection's watchdog already owns silent-death recovery. StreamFeedViewkeeps the virtualizer, stick-to-bottom, retention (useRetainedFeedRows) machinery verbatim — the row source changes from a SQL handle to live-window ⊕ paged rows in the same retained map.
What gets deleted (~4,850 LOC)#
Once nothing reads the browser database: stream-browser-store.ts (2,215),
stream-browser-db.ts (520) + stream-db.worker.ts (212) +
stream-database-registry.ts (31) + wa-sqlite.d.ts (11),
processor-state-storage.ts (403) + projection-write-buffer.ts (123),
browser-stream-processor-group.ts (182),
browser-stream-processors.ts (65), catch-up-page.ts,
core-processor-state.ts, ensure-schema-once.ts, stream-runtime-utils.ts,
stream-transport.ts, hooks/use-browser-stream-store.ts, hooks/use-stream-query.ts;
processors/browser-feed/ + processors/browser-raw-events/; and the consumer
plumbing (useAgentUiReducedState, useStreamPauseState,
clearClientDatabases, the streamClientFactory/resetTransport wiring, all
store/database props).
Not deleted, moved: the agent-ui-reducer + projector fold relocate into
the server feed processor. They are pure functions with no React dependency —
a packaging concern, not a rewrite.
What we lose (honest)#
- Cross-tab single-download (Web-Locks leader). Matters little: N tabs cost N cheap bounded live-state listeners, and the fold is shared by ALL tabs everywhere (it runs once, in the DO) — better in aggregate.
- Cold-reload instant paint from OPFS. The main honest regression, and it's
minor: first paint becomes one round trip, like every other itx surface.
useLiveStatealready keeps its last value across transport gaps. - Local scroll latency into uncached history. Mitigated: the bounded live
window covers the tail (the common case),
useItxQuerycaches pages, and the feed's skeleton machinery already tolerates async rows. Deep back-scroll on a 1M-event stream is slightly less instant — a fair trade for ~4,850 LOC. - Event scrubbing round-trips. Fully mitigable — identical behavior at one
RTT per navigation via
getEvent/getEvents. event_type_counts. Free to replace: totals fromtailLocalIndex; per-type filter counts (if still wanted) become a server aggregate.- Measured browser RTT metrics. Re-source from
stream.runtimeState(); the consume-own-append metric existed to debug the browser database being deleted.
Phasing + risks#
Incremental, not a flag-day — the browser database is acquired per-view and refcounted; keep it until the last reader migrates.
- Phase A — raw events →
useItxQuery(low risk, no server work). Rewire the two inspectors + pause state + counts ontogetEvent/getEvents+tailLocalIndex. Verify againststream-resume-after-suspend+ inspector e2es. - Phase B — the server feed processor +
.liveState(the long pole; all the risk). Move the reducer + projector fold into a stream-DO-hosted processor; exposeStreamFeedLiveStatevia the existing LiveState relay pattern; addgetFeedItems. A new load-bearing materialized view: its init/persist/rebuild/version-skew/cold-DO behavior must be explicit and tested; idempotent on replay; it rides the runner's durable checkpoints (not a hand-rolled cursor store). Watch DO CPU/storage on the hot append path; keep settled rows in the DO's SQLite. - Phase C — migrate the feed UI per surface behind the existing mode/URL
machinery (main feed → agent chat → raw browser); composer to direct
append. The subtle UI risk is the bounded-window ⊕ paged-history stitching seam — the virtualizer's end-anchor/retention was tuned for one dense SQL table (stream-feed-view.tsxdocuments the failure modes); reuse the retained-rows map and the skeleton-measures invariant. - Phase D — delete
client-libraries/browser/+ both canonical processors.
Irreducible client-side: the virtualizer/stick-to-bottom/retention machinery (pure UI, stays), and paged-not-live history (the correct shape). Everything else in Model 2 — OPFS, leader election, checkpoints, write buffer, composite drive, the connection state machine — is replaceable and should go.
Critical files#
apps/os/src/components/project-stream-view.tsx(:133-231is the seam)apps/os/src/rpc-targets.ts(StreamRpcTarget; copy the repoliveStategetter + relay pattern)apps/os/src/domains/streams/stream-durable-object.ts(host the feed processor, following the pattern inproject-durable-object.ts:86-108)packages/ui/src/components/events/agent-ui-reducer.ts+apps/os/src/domains/streams/client-libraries/processors/browser-feed/projector.ts(the fold that moves server-side)apps/os/src/components/stream-feed-view.tsx(the row-source rewire)