Aggregation
collection.aggregate(pipeline) runs a MongoDB-style pipeline inside the engine, so summaries are computed in Rust over the B-tree instead of loading every document into JavaScript. A pipeline is an array of stages; each stage transforms the stream of documents produced by the previous one.
const byStatus = await orders.aggregate([
{ $match: { status: { $ne: 'draft' } } },
{ $group: { _id: '$status', total: { $sum: '$amount' }, n: { $sum: 1 } } },
{ $sort: { total: -1 } },
])
// → [{ _id: 'paid', total: 4820, n: 37 }, { _id: 'pending', total: 910, n: 6 }]TypeScript type
type AggregatePipeline<T extends Document = Document> = AggregateStage<T>[]
aggregate<R = Document>(pipeline: AggregatePipeline<T>): Promise<R[]>The result type R is unconstrained — aggregation produces new shapes (grouped keys, computed fields), so you pass the shape you expect:
type Row = { _id: string; total: number; n: number }
const rows = await orders.aggregate<Row>([/* … */])Stages
| Stage | Purpose |
|---|---|
$match | Keep only documents matching a filter. Uses an index when it is the first stage. |
$group | Group documents by a key and compute accumulators per group. |
$sort | Order documents. 1 ascending, -1 descending. |
$skip | Drop the first n documents. |
$limit | Keep at most n documents. |
$project | Choose which fields to keep. Truthy value keeps a field. |
$match
Takes the same filter object as find. When $match is the first stage, the query planner uses an index if one is available, so filtering happens before any documents are materialised.
{ $match: { status: 'active', amount: { $gte: 100 } } }$group
_id defines the grouping key. Use a field reference ('$field') to group by that field, or null to aggregate the whole stream into a single group. Every other key is an accumulator whose value names the operation.
{ $group: {
_id: '$customerId',
spent: { $sum: '$amount' },
orders: { $sum: 1 }, // count — $sum of the constant 1
avgOrder: { $avg: '$amount' },
firstSeen: { $min: '$createdAt' },
} }The output document of each group has _id set to the group key plus one field per accumulator.
Accumulators
| Accumulator | Result |
|---|---|
{ $sum: '$field' } | Sum of the field across the group |
{ $sum: 1 } | Count of documents in the group |
{ $count: {} } | Count of documents in the group |
{ $avg: '$field' } | Mean of the field |
{ $min: '$field' } | Minimum value |
{ $max: '$field' } | Maximum value |
{ $first: '$field' } | Field of the first document in the group |
{ $last: '$field' } | Field of the last document in the group |
{ $push: '$field' } | Array of the field's values, in order |
{ $addToSet: '$field' } | Array of the field's distinct values |
$sort, $skip, $limit
{ $sort: { total: -1, name: 1 } } // total desc, then name asc
{ $skip: 20 }
{ $limit: 10 }$project
Reshape each document. A projection is either an inclusion or an exclusion — not both.
{ $project: { name: 1, city: 1 } } // inclusion — keep ONLY these fields
{ $project: { description: 0 } } // exclusion — keep everything EXCEPT this_id is kept unless you set _id: 0, and that is the one exclusion allowed to sit alongside an inclusion:
{ $project: { total: 1, _id: 0 } } // fine
{ $project: { name: 1, bulky: 0 } } // throws — mixed inclusion and exclusionChanged in 0.9.3
Before 0.9.3 the engine implemented inclusion only. An exclusion such as { description: 0 } parsed to an empty inclusion list, so every document came back stripped of every field but _id — silently, with no error. Exclusion now works, and mixing the two modes raises an error instead of resolving to something arbitrary.
Paging: sort + skip + limit
A $sort followed by $skip/$limit is a bounded query, and the engine treats it as one. When the sort's first key is indexed and no $match precedes it, the page is served by walking that index — no document outside the page is decoded at all:
await listings.aggregate([
{ $sort: { rating: -1 } }, // `rating` is indexed
{ $skip: 0 },
{ $limit: 24 },
{ $project: { name: 1, city: 1, rating: 1 } },
]);Paging a 10,000-document collection for 24 rows costs about the same as reading 24 documents, not 10,000. Multi-key sorts qualify too — only the first key needs an index; the rest order within the run of ties at the page boundary. Without an index on that first key the query still works, and still avoids a full sort (only the reachable skip + limit documents are ordered), but it must read the collection to do it.
Paging is stable: ties are broken on the unique _id, so a document can never appear on two pages or fall between them as $skip grows.
Runtime availability
aggregate() is available on every runtime — Node.js, the browser (both the direct main-thread build and the OPFS worker), and React Native. The pipeline is parsed and executed inside the engine on each platform; only the transport differs (native call, WASM, worker message, or JSI).
Key ordering
$group accumulator fields and multi-key $sort are keyed from the pipeline object. Because pipelines are parsed with a BTreeMap, multiple keys within a single $sort object are applied in alphabetical key order, not the order you wrote them. Accumulator output names within a $group are unaffected — each names its own field. When multi-key sort precedence matters, prefer a single sort key whose ordering already reflects your intent.