Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
query() consumes streaming endpoints the same way it consumes REST endpoints — pass an SDK call that returns a StreamDescriptor<T>, and the data accumulates in a reactive array. For ad-hoc / hand-rolled iterables (a WebSocket, an EventSource, an agent.stream()), pass a function thunk with an explicit key.
Two ways to consume a stream
1. Generated SDK methods (recommended)
@vertz/openapi emits stream endpoints as StreamDescriptors. The call site is identical to a REST endpoint:
_key (derived from method + path + args, identical to REST descriptors), so cache identity and dedup work the same way they do for query(api.tasks.list({ page: 1 })). No manual key, no wrapper thunk.
Note: invalidate(descriptor) operates on entity-backed REST queries today and is not yet supported for stream descriptors. To restart a stream manually, call events.refetch() on the result.
2. Function thunks (escape hatch for ad-hoc iterables)
When the source isn’t a generated SDK method — e.g., a raw WebSocket, a hand-rolled async generator, an SDK from outside@vertz/openapi — use the function-thunk overload with an explicit key:
method:path?args string. Use descriptors otherwise.
When to reach for it
- Agent run output.
agent.stream(sessionId)yieldsAgentEvents (assistant tokens, tool calls, tool results) as they arrive. Render them progressively without manual subscribe / unsubscribe wiring. - Chat transcripts. Each message is a yield; the array IS the transcript.
- Live dashboards. Stock ticks, sensor readings, deploy events.
- Log tails / SSE streams. Server-sent events become an iterable with one line.
refetchInterval).
The shape of the result
A stream-backedquery() returns a QueryStreamResult<T> — slightly different from the promise overload’s QueryResult<T>:
data is never undefined. Render it directly:
if (data) { ... } guard needed. While the stream is connecting, loading is true and data is [].
Lifecycle
The query owns the iterator’s lifecycle:dispose()(or auto-cleanup on component unmount) callssignal.abort()anditerator.return?.()so producers can release resources.refetch()cancels the current iterator, resetsdatato[], setsreconnectingtotrue, and starts a fresh iterator.- HMR triggers
dispose()on the old query before re-evaluating the module — no leaked iterators.
AbortSignal bound to that lifecycle. Wire it to your producer:
signal to your producer, dispose() still stops yields from landing in data (the framework checks signal.aborted between iterator steps), but the underlying socket / fetch / etc. keeps running. Always wire the signal.
Reactive keys
Stream queries support reactive keys the same way promise queries do — read a signal in your thunk and the iterator restarts when that signal changes:sessionId.value changes, the previous iterator aborts (signal fires, iterator.return?.() fires), data resets to [], a new iterator starts for the new id. No useEffect, no manual diffing.
Tuple keys (['session', id, 'messages']) serialize deterministically so two queries with equivalent shapes share a cache slot.
Built-in helpers
- Try
JSON.parse(event.data); yield the parsed value (or the raw string on parse failure). - Close the underlying source on
signal.abort(). - Throw inside the generator on socket-level errors (lands on
.error).
new Error('source error'). For diagnostic detail, use DevTools’ network panel or wrap your messages in a richer envelope ({ ok, data, error }).
What stream queries are not
This is intentionally a minimal v1. The following are non-goals — recipes below explain how to handle them in user-land.- No SSR for streams. A stream query renders with
data: []during the SSR pass; the iterator attaches on hydration.messages.data.map(...)produces an empty list during SSR, then progressively fills. - No accumulated-state cache across navigation. When you navigate away and back, the iterator re-attaches from scratch. Use cursor semantics in your producer (next recipe) if you need replay.
- No
refetchIntervalinterop. Polling and streaming are mutually exclusive — passing both throws aQueryStreamMisuseError. - No reducer / select hooks. Produce the merged shape inside your iterator (next recipe).
- No multi-tenant re-auth on stream queries. Component remount on auth change is the contract — the iterator does not re-validate tokens mid-flight.
- No source-type swap inside one query. A thunk that returns an
AsyncIterableon Tuesday and aPromiseon Wednesday throws — split into two queries with distinct keys. - No shared entity-store with REST queries. A
query(api.tasks.list())and aquery(sdk.tasks.events())for the same entity type don’t share cache state. If you mutate a task via the REST API, the optimistic update flows into the REST query immediately — but the stream’sdataarray only updates when the server pushes the change back through the stream. For mixed REST + stream rendering of the same entity, invalidate the REST query manually when stream events arrive (or treat the stream as authoritative and skip the REST query for that entity).
Recipes
Cursor / replay pattern
When the user navigates back to a chat session, you want to resume from the last seen message — not replay the entire history. Push the cursor into your producer:Dedup wrapper
Wrap your iterator with an async generator that filters duplicates:Forgetting to wire the AbortSignal
This will appear to work but leaks the underlying socket on dispose:signal through:
fromWebSocket and fromEventSource helpers handle this for you.