Guides

Stream query results

Consume read queries incrementally while keeping cursor cleanup inside the adapter’s scope.

Use an adapter implementing StreamingQueryAdapter. Only SELECT and set-operation queries can stream; mutations with RETURNING still use execute() or executeRows().

import { from, integer, qubu, select, table } from "qubu"
import type { StreamingQueryAdapter } from "qubu"

declare const adapter: StreamingQueryAdapter
const db = qubu(adapter)
const users = table("users", { id: integer() })
const readQuery = select({ id: users.id }, from(users))
declare function consume(row: typeof readQuery.row): void

for await (const row of db.stream(readQuery)) {
  consume(row)
}

The standalone equivalent is stream(readQuery, adapter). A plain QueryAdapter does not expose streaming just because its driver has a cursor API; the adapter must supply the capability.

Qubu renders the query with the selected dialect before calling stream(). It passes ordered raw parameters, query kind, result shape, and the optional AbortSignal in the same ExecutionRequest used by execute(). The adapter binds values and returns the iterable. Qubu lazily decodes each row without opening a cursor or connection, buffering rows, or imposing a fetch size.

Keep cursor resources inside their scope

The adapter owns the iterator's cleanup contract:

Event Adapter responsibility
Normal completion Close the cursor and release stream-only resources before the iterable completes.
Early iterator close Implement return() so a consumer can stop without leaking resources.
Iteration failure Close the cursor and release resources before the failure reaches the consumer.
Aborted signal Stop the driver operation and clean up any open stream resources.
Transaction callback completion Consume or close every stream before the callback resolves, then commit or release the transaction resources.

Consume or close the iterator before its transaction callback resolves. Qubu does not keep a transaction open for a stream that escapes its callback.

Inside a transaction, use a StreamingTransactionalQueryAdapter so the transaction callback receives a streaming client. The adapter must keep its cursor and connection valid until the callback's streams finish or close:

declare const transactionalAdapter: import("qubu").StreamingTransactionalQueryAdapter
const transactionalDb = qubu(transactionalAdapter)

await transactionalDb.transaction(async (transaction) => {
  for await (const row of transaction.stream(readQuery)) {
    consume(row)
  }
})

The adapter decides how next() drives driver reads, whether it prefetches, and how much data it buffers. Qubu only forwards the async-iterator protocol and the abort signal. Driver errors and cancellation errors pass through unchanged.

A call to stream() renders and invokes the adapter immediately. Row decoding is lazy. A stream that is never consumed still needs adapter-owned cleanup; Qubu cannot guarantee cleanup for an abandoned iterable. See stream hook timing when instrumenting consumption.