This document describes the supported way to build a stream event-list UI with:
- indexed filtering
- chronological ordering
- infinite scroll pagination
- conditional filter controls based on stream capabilities
For the default event list with no active search or filter, use:
GET /v1/stream/{name}
This is the fast path for chronological browsing. It is a stream cursor read, not a search query, so it can return the next page without evaluating or sorting the whole candidate set.
Use /_search only after the user has actually applied a query or filter.
For a user-facing filtered event list, use:
POST /v1/stream/{name}/_search- or
GET /v1/stream/{name}/_search?q=...
Do not use GET /v1/stream/{name}?filter=... as the main event-list
surface.
Why:
/_searchis the full indexed query surface- it supports exact, prefix, range,
has:field, bare text, fielded text, and phrase queries - it supports explicit sorting
- it supports cursor pagination with
search_after
GET /v1/stream/{name}?filter=... is still useful for stream-like cursor walks
and export-style reads, but it is not the primary UI search surface.
Important:
- do not switch the unfiltered default event list onto
/_search /_searchis optimized for indexed filtering and search semantics, not for plain chronological browsing of the whole stream
For the most efficient filtered event list, sort by append order, not event time:
- newest first:
["offset:desc"] - oldest first:
["offset:asc"]
This keeps the search order aligned with how the stream is actually stored and
lets the server paginate much more efficiently with search_after.
If the UI explicitly wants event-time ordering instead, it may use a sortable
timestamp field plus offset as a tie-breaker, but that path is less efficient
for deep infinite-scroll pagination.
Example:
{
"q": "service:checkout status:>=500 why:\"issuer declined\"",
"size": 100,
"sort": ["offset:desc"]
}/_search uses a server-side timeout target of 3000 ms.
- the request may set
timeout_msto a lower value - values above
3000are clamped to3000 - the reader checks that deadline cooperatively between work units
- if the budget is exhausted, the server returns
408with a normal JSON search response body instead of hanging the request - because timeout checks are cooperative, observed wall time may overshoot the configured timeout slightly while an in-flight unit of work completes
Important UI rule:
/_searchhas two timeout shapes:- the normal search timeout shape:
408with a structured partial-result body andsearch-timed-out: true - the outer generic resolver timeout shape:
408with{ "error": { "code": "request_timeout", "message": "request timed out" } }
- the normal search timeout shape:
- when
search-timed-out: trueis present, treat the response as a structured partial result, not as a transport failure - still parse the JSON body
- still render returned hits
- show that the query timed out and totals are lower bounds
- when the body is the generic
request_timeouterror, show a retry prompt instead of trying to render hits from it
Timed-out search responses include:
- body fields:
timed_outtimeout_mscoveragetotalhits
- headers:
search-timed-outsearch-timeout-mssearch-took-mssearch-total-relationsearch-coverage-completesearch-indexed-segmentssearch-indexed-segment-time-mssearch-fts-section-get-mssearch-fts-decode-mssearch-fts-clause-estimate-mssearch-scanned-segmentssearch-scanned-segment-time-mssearch-scanned-tail-docssearch-scanned-tail-time-mssearch-exact-candidate-time-mssearch-candidate-doc-idssearch-decoded-recordssearch-json-parse-time-mssearch-segment-payload-bytes-fetchedsearch-sort-time-mssearch-peak-hits-heldsearch-index-families-used
Recommended UI treatment on timeout:
- keep showing the returned hits
- if
timed_out === trueorsearch-timed-out: true, show a banner such as:Search hit its 3.0s budget. Showing the newest matches found so far.
- if the body is the generic
request_timeouterror, show a banner such as:Search request timed out before the server produced a partial result. Try a narrower query and retry.
- if
total.relation === "gte", label totals as a lower bound:50+ matches
- expose a retry affordance if the UI wants to rerun with narrower filters
/_searchno longer supports request-time exact total-hit counting
Use the next_search_after value returned by the previous /_search response.
Rules:
- keep the same
q - keep the same
sort - pass
search_afterexactly as returned - request the next page with the same
size
For newest-first append-order search, there is no separate search_before
mechanism. Use:
sort: ["offset:desc"]- then pass
next_search_afterfrom the previous page
That walks backward through append order, which is the efficient infinite-scroll pattern for a stream event list.
Example first page:
{
"q": "service:checkout status:>=500",
"size": 100,
"sort": ["offset:desc"]
}Example next page:
{
"q": "service:checkout status:>=500",
"size": 100,
"sort": ["offset:desc"],
"search_after": ["0000000000000000000000007Z"]
}Current performance note:
/_searchpagination is correct and stable for infinite scroll- the server supports
search_after, so the UI can keep scrolling without page numbers - the most efficient path is append-order pagination with
sort=["offset:desc"]orsort=["offset:asc"] - that path can prune by
search_afterbefore scanning older/newer ranges - event-time sorts are supported, but they are less efficient for deep infinite-scroll pagination
/_searchis still not the right mechanism for the unfiltered default event list
Under active ingest, /_search and /_aggregate may intentionally omit the
newest suffix instead of scanning it on the request path.
Use the response coverage object to drive the UI:
completetruemeans the response includes everything visible at the current stream headfalsemeans the newest suffix was intentionally omitted
stream_head_offset- the current append-order head for the request snapshot
visible_through_offset- the newest append-order offset included in the response
visible_through_primary_timestamp_max- the newest included primary-timestamp value when the stream defines one
oldest_omitted_append_at- the append-time watermark where the omitted suffix begins
possible_missing_events_upper_bound- an upper bound on newest events that may be omitted
possible_missing_uploaded_segments- newest published segments omitted because bundled companions are still catching up
possible_missing_sealed_rows- newest sealed but not yet published rows omitted from the response
possible_missing_wal_rows- newest unsealed WAL rows omitted from the response
Recommended UI treatment:
- render results immediately
- if
coverage.complete === false, show a subtle freshness banner such as:Results may exclude up to 26,394 of the newest events while indexing catches up.
- if
coverage.visible_through_primary_timestamp_maxis present, prefer describing freshness in time terms:Results include data through 2011-03-29T16:59:18Z.
- if
coverage.oldest_omitted_append_atis present, show when the omitted suffix began:Newest omitted events started arriving at 2026-04-01T12:57:15Z.
- treat
total.relation === "gte"on/_searchas a lower bound, not an exact total - if the HTTP status is
408, combine the freshness banner with a timeout note instead of treating the response as an error page
The current q syntax supports:
- fielded exact keyword queries:
service:checkout
- fielded keyword prefix queries:
req:req_*
- typed equality and range queries:
status:>=500duration:>1000
- existence queries:
has:why
- bare terms over
search.defaultFields:timeout
- fielded text queries:
message:timeout
- quoted phrase queries on text fields with
positions=true:why:"issuer declined"
- boolean composition:
ANDORNOT- unary
- - parentheses
Examples:
service:billing-api status:>=500
req:req_*
timeout
why:"issuer declined"
(service:billing-api OR service:worker) NOT status:<500
Current non-support:
contains:- snippets/highlighting
- multi-stream search
GET /v1/stream/{name}/_details is the supported combined descriptor endpoint
for a stream-management or event-list UI.
It returns:
streamprofileschemaindex_statusstorageobject_store_requests
That is enough for the UI to decide whether to show filter/search controls and which controls to render.
For an active stream page, /_details.stream also includes the stream head
fields needed for live/tail state:
epochnext_offsetcreated_atexpires_atsealed_throughuploaded_throughtotal_size_bytes
/_details also supports the cheap polling pattern a stream page usually
needs:
- first call
GET /v1/stream/{name}/_details - store the returned
ETag - then reissue
GET /v1/stream/{name}/_details?live=long-poll&timeout=5swithIf-None-Match: <etag>
The server responds:
200with a fresh descriptor when new events arrive or descriptor-visible metadata changes304when the timeout expires with no visible change408when the generic server-side resolver timeout fires first
Current timeout rule:
- all HTTP resolvers use a cooperative server-side timeout target of
5000 ms - keep
/_detailslong-poll requests at<= 5s - if the UI gets
408with{ "error": { "code": "request_timeout", "message": "request timed out" } }, immediately reconnect using the latestETag
This lets a stream page follow next_offset, epoch, total_size_bytes, and
indexing progress without polling the full /v1/streams list.
For a stream health or cost popover, the same /_details response is also the
supported source of truth:
storage.object_storageUploaded bytes and object counts for segments, indexes, and manifest/schema metadata.storage.local_storageCurrent retained bytes for WAL, pending sealed segments, caches, and the shared SQLite footprint. This includes:- the local segment read-through cache under
${DS_ROOT}/cache/ - the local routing/exact run caches
- the local lexicon cache under
${DS_ROOT}/cache/lexicon - the local bundled-companion cache under
${DS_ROOT}/cache/companions
- the local segment read-through cache under
storage.companion_familiesBundled companion byte breakdown forexact,col,fts,agg, andmblk.index_status.routing_key_index,index_status.exact_indexes[*], andindex_status.search_families[*]Per-family progress, lag, and bytes-at-rest for index surfaces.object_store_requestsNode-local per-stream object-store request counters, including a per-artifact breakdown.
The current contract reports lag in lag_ms, so a UI can render seconds or
minutes directly. sqlite_shared_total_bytes is shared process-local state, so
it should be labeled as shared rather than attributed as fully stream-owned.
Show the full filter/search UI only if:
details.schema.searchexists
If details.schema.search is absent, treat the stream as not search-enabled
for end-user filtering.
Use details.schema.search.fields to drive the filter builder.
Suggested mapping:
- show exact-match controls for fields with
exact: true - show prefix-capable controls for fields with
prefix: true - show range controls for fields with
column: true - show exists toggles for fields with
exists: true - show free-text search if
defaultFieldsis non-empty or there is at least one field withkind: "text" - show phrase-search help for text fields with
positions: true - use
details.schema.search.aliasesto support short field names in advanced search UIs
Relevant fields from details.schema.search:
primaryTimestampFielddefaultFieldsaliasesfields
The stream can be search-capable before every uploaded segment is fully indexed.
Use details.index_status to decide whether to show:
- a normal ready state
- an indexing-in-progress banner
- a reduced-capability message
Relevant fields:
details.index_status.exact_indexesdetails.index_status.search_families
Useful checks:
- exact filters are fully caught up when the relevant entry in
exact_indexeshasfully_indexed_uploaded_segments: true - range queries are fully caught up when the
colfamily entry hasfully_indexed_uploaded_segments: true - keyword/text queries are fully caught up when the
ftsfamily entry hasfully_indexed_uploaded_segments: true
Even while indexing is still catching up, search remains correct. The server may scan uncovered published ranges or the WAL tail to preserve correctness.
- Call
GET /v1/stream/{name}/_details. - If
schema.searchis absent, hide the advanced filter/search UI. - Build search controls from
schema.search.fields. - For filter-only event-list queries, use append-order sorting
(
["offset:desc"]for newest first). UseprimaryTimestampFieldplusoffsetonly when the UI explicitly needs event-time ordering. - Issue
POST /v1/stream/{name}/_searchfor the event list. - Use
next_search_afterfor infinite scroll. - Use
index_statusto show indexing progress or freshness indicators.
For a filtered, chronologically ordered, infinitely scrolling event list:
- use
/_search - sort by
offsetfor the efficient append-order path - paginate with
search_after - inspect
/_detailsto determine whether search is available and which query controls to render
That is the supported integration model for stream UIs.