findAll() builds every row into memory before it returns. For a result setModel.iterate() / Model.stream() page through findAll() with aFindOptions, need no SQL, and work ondialect.queryStream(sql) takes a SQL string and returns a NodeReadable in object mode, using a native server-side cursor where the driverMapTransform / FilterTransform / BatchTransform / StreamIteratorusers,orders, products). The SQL shown was captured from the ORM.Model.iterate() walks every matching row without loading them all, and takesfindAll():import { createWriteStream } from 'node:fs';
const out = createWriteStream('paid-orders.csv');
out.write('id,userId,total\n');
for await (const o of Order.iterate({ where: { status: 'paid' } }, { batchSize: 500 })) {
out.write(`${o.id},${o.userId},${o.total}\n`);
}
out.end();findAll(). The first page carries your where; every laterbatchSize is the last one, so the walk ends without anbatchSize is 100.limit and offset apply to the whole walk, not to each page. Withlimit: 3 and batchSize: 2, the second page asks for only the one row stillfor await (const o of Order.iterate({ where: { status: 'paid' }, limit: 3 }, { batchSize: 2 })) {
// three orders in total
}Model.stream() gives the same rows as a Node Readable in object mode, forpipeline() and the transforms below:import { pipeline } from 'node:stream/promises';
import { BatchTransform } from 'ts-prorm-orm';
await pipeline(
User.stream({ where: { status: 'active' } }, { batchSize: 500 }),
new BatchTransform({ batchSize: 500 }),
async function* (batches) {
for await (const batch of batches) yield await search.bulkIndex(batch);
},
);findInBatches() does, which isfindAll().[!NOTE]
The keyset only works in primary-key order, so a caller-suppliedorderis
replaced withORDER BY "id" ASCrather than silently producing wrong
pages. If you need another order, sort after the walk, or usedialect.queryStream()with your ownORDER BY.
stream() is Readable.from() over the iterate() generator, so the next pagelet n = 0;
for await (const o of Order.stream({}, { batchSize: 3 })) {
if (++n === 2) break;
}
// SELECT * FROM "orders" ORDER BY "id" ASC LIMIT 3 (and nothing else)[!TIP]
Every transform stage in apipeline()buffers up to itshighWaterMark
objects (default 1000) before it stops pulling, so a pipeline reads ahead
by roughly that many rows per stage. To bound memory for large rows, pass a
smallerhighWaterMarkto the transforms.
findInBatches() and findEach() remain for handler-style iteration:await Order.findInBatches({ where: { status: 'paid' } }, async (batch) => {
await ship(batch);
}, { batchSize: 500 });dialect.queryStream() takes SQL and returns a Node Readable in object mode:const dialect = prorm.getDialectInstance()!;
const stream = dialect.queryStream('SELECT id, sku, stock FROM products ORDER BY id', {
batchSize: 1000,
highWaterMark: 500,
});
for await (const row of stream) {
await ship(row);
}Readable, pipeline(), pipe(), for await,destroy() all behave the way you expect from Node streams. Afor await[!NOTE]
Prefer the model methods: they are typed, they respect scopes, paranoid
filtering and hooks, and they require no SQL. Reach forqueryStream()when
you want a native server-side cursor on PostgreSQL or CockroachDB, or an order
other than the primary key, and are willing to write the query.
| Dialect | Mechanism |
|---|---|
| PostgreSQL, CockroachDB | A server-side cursor (DECLARE … CURSOR, then FETCH batchSize). |
| MySQL, MariaDB | The driver's row stream: one execution on a connection from mysql2's callback pool, released when the stream ends or fails. |
| SQL Server | The mssql request's row events: one execution, paused while the consumer is behind. |
| SQLite | better-sqlite3's Statement#iterate(). |
| Everything else | A paginated fallback: repeated pages of the query, one round trip per page. |
BEGIN / COMMIT;prorm.transaction() (pass transaction), it declares the cursor on[!IMPORTANT]
A stream holds its connection until it ends or is destroyed. Always consume it
to the end, or calldestroy(); an abandoned cursor stream keeps a pooled
connection and an open transaction. See
Connection pooling.
SELECT id, email FROM users ORDER BY id withbatchSize: 1000:[!WARNING]
The fallback pages withOFFSET, so the query must have a stable order or
rows can be skipped or repeated between pages.
[!NOTE]
Before 4.9.2, MySQL'squeryStream()calledpool.createQueryStream(),
which themysql2/promisepool does not have, so it always failed; and SQL
Server used the paginated fallback withORDER BY (SELECT NULL), which is no
order at all, so rows could be skipped or repeated between pages. Both now
stream one execution of the statement.
| Option | Default | Effect |
|---|---|---|
batchSize | 1000 | Rows fetched per page / cursor FETCH. |
highWaterMark | 1000 | Stream buffer size. |
model + mapToModel | — | Map each row to a model instance instead of a plain object. |
transaction | — | Run the stream inside a transaction. |
logging | — | Per-stream logging override. |
Transform streams with a default highWaterMark ofMapTransformimport { MapTransform } from 'ts-prorm-orm';
stream.pipe(new MapTransform({
mapFn: async (row) => ({ ...row, region: await lookupRegion(row.ip) }),
}));mapFn may be sync or async; a throw destroys the stream with that error.FilterTransformimport { FilterTransform } from 'ts-prorm-orm';
stream.pipe(new FilterTransform({ filterFn: (row) => row.total > 100 }));[!TIP]
Filtering in the database is cheaper: every row aFilterTransformdrops was
still read, sent and parsed. Use it only for predicates SQL cannot express, or
that depend on something outside the database.
BatchTransformimport { BatchTransform } from 'ts-prorm-orm';
stream
.pipe(new BatchTransform({ batchSize: 500 }))
.on('data', async (rows) => { await bulkIndex(rows); });StreamIteratorfor await:import { StreamIterator } from 'ts-prorm-orm';
const it = someStream.pipe(new StreamIterator());
for await (const row of it) { /* … */ }import { pipeline } from 'node:stream/promises';
import { MapTransform, FilterTransform, BatchTransform } from 'ts-prorm-orm';
await pipeline(
Order.stream({ where: { status: 'paid' } }, { batchSize: 2000 }),
new FilterTransform({ filterFn: (o) => Number(o.total) > 12 }),
new MapTransform({ mapFn: (o) => ({ id: o.id, total: Number(o.total) }) }),
new BatchTransform({ batchSize: 500 }),
async function* (batches) {
for await (const batch of batches) {
await search.bulkIndex(batch);
yield `${batch.length}\n`;
}
},
process.stdout,
);pipeline() propagates errors and destroys every stage, which is what you wanttotal > 12 filter belongs in the where in real| Situation | Use |
|---|---|
| Result fits comfortably in memory | findAll() |
| Walking a large result | Model.iterate() |
| Piping it somewhere, with transforms | Model.stream() |
| Handler-style, a batch at a time | findInBatches() / findEach() |
| A native cursor or a custom order, and you will write the SQL | dialect.queryStream() |
findInBatches keyset iteration