Drill-down rows
Every bucket in an AnalyzeResult carries a signed bucketKey. Round-trip
that key to analyze.<name>.rows(bucketKey) and you get the contributing
rows, paginated, RBAC-filtered, mapping-applied — through the exact same
pipeline query uses. Drill-down is query, already narrowed to the
bucket's predicate.
Three primitives layer on top of that base:
- Search inside the bucket —
autoroutes to semantic for lenses withsearchable: truefields, otherwise falls through to substring across text fields. - Sort by any mapped field — the platform appends a stable primary-key tiebreaker server-side so cursor pagination doesn't shuffle.
- Cursor pagination — opaque, deep-scroll-safe, drains end-to-end (and is what the streaming export consumes — see Exports).
The bucketKey signing model
bucketKey is not a row id and not a UI handle. It's an
HMAC-SHA256-signed token (24h TTL) that encodes:
- The lens identity (org + env + lensId)
- The bucket's exact predicate (e.g.
{ category: 'footwear', in_stock: true }) - The original analyze's
candidates.where(so drill inherits the same pre-filter) - The
similarToID set when vector narrowing was active — so drilling a semantic-cluster bucket replays the same vector-narrowed pool
When the server receives a bucketKey it verifies the signature, re-evaluates
the lens's access rules against the current caller's identity (RBAC isn't
captured in the key — drift between the analyze run and the drill is fine),
and runs query against the union predicate. A bucketKey from one user
does not work for another user — RBAC re-evaluates server-side. A
bucketKey from a Free tier pk_ key works in sk_ mode too — the key
encodes the predicate, not the requester.
Tokens are valid for 24 hours. After that you need to re-run the analysis to get fresh bucketKeys; we trade convenience for security-margin on the HMAC window.
Search — auto, simple, semantic, hybrid
Drill-down accepts a search?: string and a searchMode?: 'auto' | 'simple' | 'semantic' | 'hybrid'.
Empty string = no filter (falls through to the cursor walk). The router
picks an engine based on the lens config:
| Mode | What runs | When to pick it |
|---|---|---|
auto (default) | Semantic if the lens has any searchable: true field; otherwise substring across text fields | The right answer for ~95% of UIs |
simple | Substring $ilike OR'd across every text field on the lens. No embedding cost | Lens has no embeddings, you want literal text matching, or you want predictable highlighting |
semantic | Embed the query, rerank candidates by cosine similarity | Lens has searchable fields, you want "summer dresses" to match "linen maxi" |
hybrid | Substring narrows to a candidate pool (10× the page size); semantic reranks within | Big buckets where pure semantic over the whole pool would over-fetch |
The mode the engine actually ran echoes back at meta.searchMode (never
'auto'). Two degenerate-fallback labels surface when one half of hybrid
no-ops:
'hybrid:simple-only'— lens has nosearchable: truefield; ran simple, no semantic rerank.'hybrid:semantic-only'— substring would have matched ≥90% of the bucket; substring was a no-op, fell through to pure semantic.
Wildcard escaping. simple and hybrid modes auto-escape %, _,
and \\ in the query — searching for "100%" matches the literal three
characters, not "anything containing 100." The substring path uses the
bridge's $ilike operator under the hood.
Sort — and the PK tiebreaker
orderBy accepts a single rule or an array (applied in order):
Default direction is 'asc'. Default ordering when orderBy is omitted is
the lens's primary key. The platform always appends the primary key as a
final tiebreaker server-side, so cursor-based pagination never shuffles
rows on ties — you don't need to remember to add it yourself. The resolved
chain echoes back at meta.orderBy:
Validating an orderBy field that isn't on the lens returns 400 with
unknown_field.
Search and sort layered together
When both search (semantic / hybrid) and orderBy are set, semantic narrows
the pool, then explicit orderBy sorts the narrowed result. The
similarity-score order is dropped in favor of the caller's chosen sort.
meta.searchMode still reflects the engine that picked the candidates;
meta.orderBy reflects the final ordering applied.
Cursor pagination
Drill-down rows always return a cursor when more pages remain:
The cursor is an offset under the hood. Drill on a static bucket is
read-only, so write-races against the source DB aren't a concern here. For
write-stable cursors over query() directly, see
Cursors & streaming.
Cross-source drill-down
When the analyze ran through a declared relation (e.g. parent products joined
to child reviews), the drill-down replays the join. crossSource: true flags
those pages so UIs can label them ("Showing reviews for the products in this
bucket"). The bucketKey carries the join-side predicate; the drill fetches
parent ids, then child rows scoped to those ids.
Putting it together
The pattern most apps land on:
useAnalyzeRows resets on bucketKey: null, refetches on bucketKey
change, and exposes cursor for "load more" / IntersectionObserver wiring.
Search + sort are one prop each — { search, orderBy } thread through to
the same /rows endpoint.
Errors
| Status | Code | When |
|---|---|---|
400 | bucket_key_required | Body missing bucketKey. |
400 | bucket_key_invalid | Signature failed, scope mismatch, or token expired (24h). |
400 | unknown_field | orderBy.field isn't declared on the lens. |
400 | unsupported_search_mode | searchMode: 'simple' against a lens with no text fields. |
403 | forbidden | The analysis's grants.analyze.<name> rule denied — re-evaluated at drill time, not at the original analyze run. |
404 | lens_not_found / analysis_not_found | The lens or named analysis doesn't exist on this env. |
Next: Exports — drain the same cursor end-to-end as streaming NDJSON or CSV.