Streaming Queries
Streaming lets you iterate over large component datasets record by record without loading everything into memory at once. The server emits results as NDJSON (newline-delimited JSON) — a metadata line first, followed by one record per line.
When to use streaming
| Use case | Recommendation |
|---|---|
| Paginated UI — fetching a page of results | find() (standard) |
| Processing all records — exports, migrations, background jobs | find(..., { stream: true }) |
| Large datasets where memory is a concern | find(..., { stream: true }) |
Usage
Pass { stream: true } as the second argument to find(). Instead of returning a Promise, it returns a FindChainable — a fluent builder you configure before calling .stream() to begin the request.
await platform.component('orders')
.find({ currentPage: 0, perPage: 100 }, { stream: true })
.onMeta((meta) => {
console.log(`Total records: ${meta.total}`)
console.log(`Total pages: ${meta.total_pages}`)
})
.onData((record) => {
// Called once per record
console.log('Record:', record)
})
.onError((err) => {
console.error('Stream error:', err)
})
.onEnd(() => {
console.log('Stream complete')
})
.stream()
Note:
.stream()starts the HTTP request and returns aPromise<void>that resolves when the stream ends. Alwaysawaitit (or chain.catch()) to handle errors.
Chainable methods
| Method | Callback signature | Description |
|---|---|---|
.onMeta(cb) |
(meta: FindStreamMeta) => void \| Promise<void> |
Called once with pagination metadata before any records |
.onData(cb) |
(record: Model) => void \| Promise<void> |
Called once per record |
.onError(cb) |
(error: Error) => void |
Called if the stream fails |
.onEnd(cb) |
() => void |
Called when all records have been emitted |
.stream() |
— | Starts the request; returns Promise<void> |
All methods return this, so they can be chained in any order before calling .stream().
FindStreamMeta
The first line emitted by the server contains metadata:
type FindStreamMeta = {
total: number // total records matching the filter (before pagination)
total_filtered: number // same as total when a filter is applied
total_pages: number // total number of pages at the requested perPage
page: number // current page index (0-based)
next_cursor?: string // opaque cursor token for the next page (absent on last page)
}
Working with async callbacks
Both onMeta and onData callbacks support async functions. The stream processor awaits each callback before processing the next line, so back-pressure is naturally applied — you won't receive the next record until your handler resolves.
await platform.component('orders')
.find({ perPage: 500 }, { stream: true })
.onData(async (record) => {
await processRecord(record) // stream pauses until this resolves
})
.stream()
Pagination within a stream
perPage controls how many records the server fetches per internal cursor page. For most use cases the default of 10 is too small — set a larger value to reduce round-trips:
await platform.component('orders')
.find({ perPage: 500, filter: 'status:paid' }, { stream: true })
.onData((record) => { /* ... */ })
.stream()
TypeScript types
The following types are exported from @ptkl/sdk:
| Type | Description |
|---|---|
FindStreamMeta |
Metadata object emitted before the first record |
FindStreamMetaCallback |
(meta: FindStreamMeta) => void \| Promise<void> |
FindChainable |
Fluent builder returned by find(..., { stream: true }) |
HTTP API
The streaming endpoint is available directly if you are not using the SDK:
POST /v4/system/component/{ref}/models/stream
Accept: application/x-ndjson
Content-Type: application/json
Request body — same shape as the standard find endpoint:
Response — NDJSON stream. First line is metadata, subsequent lines are records, and an optional final line contains the cursor for the next page:
{"total":1500,"total_filtered":1500,"total_pages":15,"page":0}
{"uuid":"...","data":{...},"CreatedAt":"..."}
{"uuid":"...","data":{...},"CreatedAt":"..."}
...
{"next_cursor":"eyJ2IjoiMjAyNi0wNi0yMlQwOToxMjowMFoiLCJpZCI6ImFiYy0xMDAifQ"}
The next_cursor line is only emitted when there are more pages. It contains exactly one key (next_cursor) so it can be distinguished from record lines.
Cursor pagination with streaming
Cursor-based pagination works with the streaming endpoint the same way as with the standard Find API. Pass the cursor from a previous response to resume from where you left off:
// Page 1
let nextCursor = null
await platform.component('orders')
.find({ perPage: 100, sortBy: 'UpdatedAt', sortDesc: -1 }, { stream: true })
.onData((record) => { /* process record */ })
.onCursor((cursor) => { nextCursor = cursor.next_cursor })
.stream()
// Page 2 — pass the cursor
if (nextCursor) {
await platform.component('orders')
.find({ perPage: 100, sortBy: 'UpdatedAt', sortDesc: -1, cursor: nextCursor }, { stream: true })
.onData((record) => { /* process record */ })
.onCursor((cursor) => { nextCursor = cursor.next_cursor })
.stream()
}
HTTP API example
Page 1:
POST /v4/system/component/{ref}/models/stream
Content-Type: application/json
Accept: application/x-ndjson
Page 2 — pass the cursor from the previous response:
{ "offset": 0, "limit": 100, "sortBy": "UpdatedAt", "sortDesc": -1, "cursor": "eyJ2IjoiMjAyNi0wNi0yMlQwOToxMjowMFoiLCJpZCI6ImFiYy0xMDAifQ" }
When cursor is present the server uses an indexed range query to seek directly to the correct position instead of scanning and skipping records. See cursor pagination for details and important notes on sort/filter consistency.