Skip to content

Refactor persistent text streaming onto Stream core - #58

Draft
robelest wants to merge 5 commits into
get-convex:mainfrom
robelest:robel/stream-core
Draft

Refactor persistent text streaming onto Stream core#58
robelest wants to merge 5 commits into
get-convex:mainfrom
robelest:robel/stream-core

Conversation

@robelest

@robelest robelest commented Jul 22, 2026

Copy link
Copy Markdown

Summary

New streams persist through @convex-dev/stream, and followers read over a Convex subscription instead of an HTTP connection. The public API, hook signature, stored StreamIds, and POST body do not change.

The old follow path re-read every chunk and re-sent the whole prefix on each append, so cost grew with the square of the message length. A 2000-token reply cost about 820 document reads and pushed roughly 160 KB to deliver 8 KB of text; ten times the length cost about 82,000 reads. Followers now subscribe to an app-owned readStream query that returns append-only pages of at most 16 events, so each append reads and sends only the delta. One websocket subscription serves a viewer, with no HTTP action held open and no idle polling.

What changed

  • New streams store ordered events and lifecycle through @convex-dev/stream. Rows from earlier releases keep their chunk storage and stay readable, writable, timeout-aware, and deletable. No migration.
  • stream() claims the stream before running the writer, so a duplicate request receives 205 and reads durably rather than starting a second producer.
  • The producing browser reads its raw HTTP body at a fixed 50 ms cadence. Every other client, and drive recovery, reads through readStream.
  • A failed drive connection recovers through the same durable read. The raw text is a prefix of the durable text, so it stays on screen until the replay passes it rather than rewinding to empty.
  • Durable writes flush at sentence punctuation, after 100 ms, or at 16 KiB.

Compatibility

readStream and its opts field are additive. Apps that omit them keep the full-body read.

  • Convex 1.39 or newer is required.
  • Authorize both the HTTP action, which generates text, and the readStream query, which returns persisted text. Put the check where you already guard getStreamBody. Possessing a StreamId is not authorization.

Before release, publish @convex-dev/stream and replace the b153faa preview pin, and raise the version past 0.3.3.

Validation

  • 38 tests pass with typechecking. The example app and its Convex functions typecheck.
  • Build, ESLint, Prettier, lockfile verification, and package dry-run pass.
  • npm run test:occ:real runs the claim and delete/append races against a real deployment.

robelest added 5 commits July 23, 2026 13:20
Newly created streams delegate ordered persistence and lifecycle to a
`textStreams` coordination row while the existing `streams` table remains the
stable public handle. Rows written by earlier releases keep their `chunks`
storage and stay readable, writable, and deletable; the optional `coreId`
selects the path.

Add an atomic `claim` so exactly one request may run a producer, and a bounded
`read` that returns forward-only pages with a stream-bound cursor. Legacy rows
expose the same envelope through a compatibility adapter that splits
JSON-expanding chunks below the frame limit.
`stream()` now claims the stream before invoking the writer, so a duplicate
request receives `205` instead of starting a second producer. Persistence runs
through a single ordered queue that flushes at sentence punctuation, after
100 ms, or at 16 KiB, and splits oversized appends on code point boundaries. A
producer failure flushes pending text before recording `error`, and the raw
response is bounded so an unread body cannot stall durable writes.

Add `readStream`, a passthrough over the component's bounded `read` intended
for an app-owned query.
The driving browser still reads its raw HTTP body at a fixed 50 ms cadence.
Everyone else -- followers, reloads, and drive recovery -- subscribes to
bounded append-only pages through the app's `readStream` query, so a viewer
costs one Convex subscription that reads only the delta rather than re-reading
the whole body on every append. Apps that do not pass `readStream` keep the
previous full-body behavior.

Recovery hands off to the same durable read and holds the raw prefix until the
replay passes it, so an interrupted stream never rewinds to an empty message.
Headers stay out of the transport restart key so a rotating auth token does not
tear down a live stream, and drive retries are capped rather than unbounded.
convex-test cannot model concurrent OCC retries, so the one-winner claim
guarantee and the delete/append race were only covered by sequential
assertions. Add an internal action that runs both against a real dev
deployment and a script that invokes it, reports the counters, and fails on
any round that elects more or fewer than one producer.

Run with `npm run test:occ:real`; it requires a configured personal dev
deployment and is not part of `npm test`.
Describe the two paths, the `readStream` query apps must expose to use the
bounded read, and where authorization belongs. Both the HTTP action and
`readStream` reach assistant text, and neither infers app ownership rules, so
the check must live in the app: in the action before `stream()`, and in the
query handler alongside the one already guarding `getStreamBody`.

Wire the example app to `readStream` and note the Convex 1.39 floor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant