zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Schema and ORM

Streaming ResultsSupported

repo.stream() returns a single-shot AsyncIterable and AsyncDisposable. Prefer await using: a started database cursor owns its connection until the iterator closes, and disposal covers paths that never reach another iterator.next().

await using rows = repo.stream(
  { active: true },
  {
    batchSize: 500,
    signal: request.signal,
  },
);

for await (const row of rows) {
  await exportRow(row);
}

for await closes the iterator on break or a thrown error. await using additionally closes a stream whose iterator was started and then handed to code that returns without finishing it. A stream that was never started acquires no connection. Manual iteration carries the corresponding manual responsibility: call iterator.return() when stopping early.

Rows cross the same database-value decoder as find; the decoder is resolved once before iteration. Reads are not schema-validated on either path.

The HTTP response layer also supports streams through WebResponse.body.

Driver capability and fallback#

If the driver has no stream method, the repository calls execute once and yields the buffered rows. The first fallback per driver is reported through onQuery(query, { filters: [], buffered: true }). Pass requireCursor: true to refuse that fallback.

What breaks without a cursor#

Reading a million rows means a million rows in memory:

const all = await repo.findAll(); // the whole table, resident

Node's default heap will end the process somewhere in the low millions of rows, which is exactly when you wanted a stream.

Paginate instead#

Keyset pagination is the correct workaround, and for a batch job it is barely worse than a cursor — bounded memory, and it survives a restart because the cursor is a value you can persist:

async function* allUsers(repo: UserRepository, batch = 1_000) {
  let after = 0;
  for (;;) {
    const page = await repo.list({
      where: { id: { gt: after } },
      orderBy: [{ column: 'id', dir: 'asc' }],
      page: { limit: batch },
    });
    const last = page.items.at(-1);
    if (last === undefined) return;
    yield* page.items;
    after = last.id;
  }
}

for await (const user of allUsers(repo)) {
  /* ... */
}

That gives the _consumer_ an AsyncIterable, which is usually what the calling code wanted. It costs one round trip per batch and it holds a batch in memory, not a table.

📝 Note

Order by a unique column. Ordering by createdAt with ties can skip or repeat rows across page boundaries. Add the primary key as a tie-break — see Cursor-based pagination.

Bundled driver behaviour#

sqliteDriver calls StatementSync.iterate() and decodes one stepped row at a time. batchSize has no effect because there is no client/server round trip to batch. An abort is observed between native steps; node:sqlite exposes no sqlite3_interrupt, so JavaScript cannot interrupt one slow step already running inside SQLite.

postgresDriver(pool) checks out one connection for the iterable, opens an explicit transaction, declares a parameterised cursor, fetches batchSize rows per round trip, and closes the cursor and releases the connection in finally. Breaking a for await loop therefore returns the connection to the pool. A consumer that manually calls iterator.next() still owns calling iterator.return() when it stops early.

Other drivers can implement the same optional Driver.stream contract. When they do not, use requireCursor: true to refuse buffering or use keyset pagination as above.

Fallback limits#

The bundled SQL Server adapter and third-party drivers without stream still buffer. A bare Postgres Client also omits cursor streaming; pass a Pool so the adapter can own and release one checked-out connection. Request abort wiring remains explicit at the application boundary.

---

See also: Cursor-based pagination · Writing a Driver · Streaming Files