Exports
Two endpoints stream rows end-to-end with no in-memory buffering: drill-down
exports drain the bucket cursor, and query exports drain a where predicate
against any lens with grants.query. Both share one server-side helper, one
client-side helper, and one tier-aware cap.
| Endpoint | Use case |
|---|---|
POST /v1/analyze/:lens/:name/rows/export | Full set of rows behind a chart bucket |
POST /v1/query/:lens/export | Full set of rows for a where predicate (the lens-wide dump) |
Both accept format: 'ndjson' | 'csv' (default 'ndjson'). NDJSON is one
JSON object per line, terminated by \n. CSV is RFC-4180-compliant with
\r\n line endings, quote-wrapping any field containing ,, ", \n, or
\r.
Why streaming
The analyze surface lets you click a bucket and see the rows behind it. "See all the rows" is the next obvious ask, and naïve implementations fall apart at scale — buffering 10M rows in memory before sending the body holds the connection open, OOMs the worker, and times out the proxy. The streaming exports handle this correctly:
- Server pages through the cursor at 1000 rows per page, flushing each page to the wire as it arrives.
- Client decodes line-by-line —
BeamClient.exportRows()returns anAsyncIterable<RowChunk>, never holds more than one page in memory. - Tier caps stop the loop loud, with the
X-SemiLayer-Export-Truncatedtrailer, instead of OOMing.
The drill-down endpoint reuses the drill-down cursor
internally — anything you can drill, you can export. The query endpoint
walks the lens via the same offset+orderBy contract /v1/query already
serves.
From Beam — the easy path
For "I just want a downloadable file" cases, the …ToBlob convenience
collapses the iterable into a single Blob you can pipe to URL.createObjectURL:
From React — useExportRows
The hook owns the AsyncIterable lifecycle: start() opens the stream,
cancel() aborts it cleanly (the bridge closes the cursor on its side, no
leaked transactions), and progress aggregates as bytes drain. State
flips to truncated automatically when the trailer fires.
From the CLI
--export - writes to stdout. Progress (scanned 2,500,000 rows…) goes to
stderr so long-running pipelines don't look hung.
From cURL (raw HTTP)
curl -N is important — it disables curl's output buffering so chunks land
on disk as they stream. Without it you wait for the whole body before any
bytes hit the file.
Tier-aware caps + the truncation trailer
Each tier has a per-call row cap. Exports stop at the cap and the body
ends gracefully — last NDJSON line is well-formed, CSV ends with \r\n.
| Tier | Per-export rows |
|---|---|
| Free | 10,000 |
| Pro | 100,000 |
| Team | 1,000,000 |
| Scale | 10,000,000 |
| Enterprise | unlimited |
When the cap halts the stream before the cursor drained, the response sets HTTP trailers:
Trailer support is uneven across HTTP clients — modern Chrome / Firefox
fetch() and curl --raw see them; older proxies sometimes strip them.
The Beam client + React hook surface the truncation as a final
{ kind: 'truncated', actualRows, maxRows } chunk so consumers don't
need to read trailers themselves.
A separate X-SemiLayer-Export-Truncated-Hint: true header (regular,
not trailer) fires up-front when the server can determine the total
will exceed the cap — useful for confirmation prompts before the
download starts.
NDJSON vs CSV
| NDJSON | CSV | |
|---|---|---|
| One row per | Newline-delimited JSON object | Comma-separated row, RFC-4180-quoted |
| Schema preserved | Yes — types come back as JSON values | No — every cell is a string |
| Nested objects | Inline ({"address":{"city":"Paris"}}) | JSON-stringified into one cell |
| Streaming-decodes well? | Yes — split on \n | Yes — but watch out for \n inside quoted cells |
| Best for | Programmatic pipelines, jq, JS consumers | Spreadsheets, ad-hoc analyst handoff |
The CSV path quote-wraps any field containing ,, ", \n, or \r,
and escapes inner " as "" per RFC-4180. The first line is the column
header. Nested objects (metadata: { city: 'Paris' }) get
JSON.stringify'd into one cell since CSV is flat by design.
Cancellation
Both BeamClient.exportRows() and useExportRows().cancel() propagate
cancellation through to the server. The handler observes the closed
connection on its next page-fetch tick and aborts the cursor walk; the
bridge closes its cursor on its side. No leaked transactions, no
half-streamed-and-still-running fan-out.
Errors
| Status | Code | When |
|---|---|---|
400 | bucket_key_required | Drill export body missing bucketKey. |
400 | bucket_key_invalid | Signature failed or token expired. |
400 | analyze_input_too_large | Body > 8KB. Tighten the export request body. |
403 | forbidden | grants.analyze.<name> (drill) or grants.query (query export) denied. |
404 | lens_not_found / analysis_not_found | Resource doesn't exist on this env. |
502 | (bridge-classified) | Bridge error; classified message + detail in the body. |
Mid-stream errors can't change the status code (the response is already
200 once the first byte flushed). They surface via the
X-SemiLayer-Export-Error trailer with <code>: <message>.
Next: Cursors & streaming — the
mental model for pagination across query, drill-down, and the streaming
exports.