Refactor persistent text streaming onto Stream core - #58
Draft
robelest wants to merge 5 commits into
Draft
Conversation
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.
robelest
force-pushed
the
robel/stream-core
branch
from
July 23, 2026 17:43
1994db2 to
10a871d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, storedStreamIds, 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
readStreamquery 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
@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 receives205and reads durably rather than starting a second producer.readStream.Compatibility
readStreamand itsoptsfield are additive. Apps that omit them keep the full-body read.readStreamquery, which returns persisted text. Put the check where you already guardgetStreamBody. Possessing aStreamIdis not authorization.Before release, publish
@convex-dev/streamand replace theb153faapreview pin, and raise the version past0.3.3.Validation
npm run test:occ:realruns the claim and delete/append races against a real deployment.