Database
Aggregation
An aggregation is an ordered pipeline of stages that filters, reshapes, unwinds, groups and sorts records inside one snapshot read. Reach for it when a single query isn't enough — counts per group, top-N rollups, tag distributions — instead of pulling every record over HTTP and reducing on the client.
Aggregation is snapshot-only. There is no /aggregate/subscribe; re-run the pipeline to refresh, and keep /query/subscribe for live windows over raw records.
Endpoints
POST /v1/spaces/:spaceId/objects/aggregate cross-object — the space's objects collection
POST /v1/spaces/:spaceId/aggregate per-object dataset (objectId + dataset required)
The same two scopes as /query. Request body:
{
"objectId": "obj_abc",
"dataset": "chat_messages",
"pipeline": [ { "$match": {} }, { "$group": {} } ],
"groupLimit": 50000,
"accumArrayLimit": 10000,
"memoryLimitBytes": 268435456,
"explain": false
}
objectId and dataset apply to the per-object variant only; pipeline is required; the three limits and explain are optional. The response is {"records": [...]} — pipeline result documents, not dataset rows. With explain: true you get {"plan": "..."} instead (diagnostic, not a stable format).
Deleted records are excluded server-side: a _deletedAt-missing $match is prepended to every pipeline, so aggregates always agree with /query.
Examples
Messages per author in a chat:
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/aggregate -d '{
"objectId": "'$OBJ'", "dataset": "chat_messages",
"pipeline": [
{"$group": {"_id": "$creator", "n": {"$count": {}}}},
{"$sort": {"n": -1}}
]}'
# → {"records": [{"id": "A6Wd…", "n": 42}, {"id": "A9kQ…", "n": 17}]}
any aggregate $SPACE $OBJ --dataset chat_messages \
--pipeline '[{"$group":{"_id":"$creator","n":{"$count":{}}}},{"$sort":{"n":-1}}]'
Movie count per year over objects (property values live at <typeId>.<propId>, see Reading data):
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects/aggregate -d '{
"pipeline": [
{"$match": {"'$TYPE'.'$YEAR'": {"$gte": 1980}}},
{"$group": {"_id": "$'$TYPE'.'$YEAR'", "n": {"$count": {}}}},
{"$sort": {"id": 1}}
]}'
Tag distribution — unwind an array property, collect titles per tag:
any aggregate $SPACE --properties --pipeline '[
{"$unwind": "$'$TYPE'.'$TAGS'"},
{"$group": {"_id": "$'$TYPE'.'$TAGS'", "n": {"$count": {}},
"titles": {"$addToSet": "$'$TYPE'.'$TITLE'"}}},
{"$sort": {"n": -1}}, {"$limit": 10}
]'
Objects per calendar month of their last edit — the date operators compute on the instants any stores:
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/objects/aggregate -d '{
"pipeline": [
{"$group": {"_id": {"$dateTrunc": {"date": "$modifiedAt", "unit": "month"}},
"n": {"$count": {}}}},
{"$sort": {"id": 1}}
]}'
Just a count: any aggregate $SPACE --properties --pipeline '[{"$count": "objects"}]' → {"records": [{"objects": 128}]}.
Stages and operators
Stages: $match (the full /query filter language plus $expr), $sort, $skip, $limit, $count, $project, $addFields/$set, $unwind, $group, $facet, $lookup (self-join only — from must name the aggregated collection or be omitted).
Accumulators in $group: $sum, $avg, $min, $max, $count, $first, $last, $push, $addToSet.
Expressions take field references ("$a.b.c", including the search virtuals "$_score" / "$_distance"), literals ({"$literal": …} escapes a $-leading string), nested documents/arrays, and this closed operator set:
| Family | Operators |
|---|---|
| arithmetic | $add, $subtract, $multiply, $divide, $abs, $round |
| strings | $concat, $split, $replaceOne, $replaceAll, $trim, $ltrim, $rtrim |
| conditional | $cond, $switch, $ifNull |
| comparison | $eq, $ne, $gt, $gte, $lt, $lte, $cmp |
| dates | $dateAdd, $dateDiff, $dateTrunc, $year, $week |
Anything absent is rejected: no type conversions ($toDate, $toInt), no array operators ($map, $filter, $reduce). Date operators work on every instant — system stamps and date/datetime properties — and return instants in the same {"$date": …} wire shape (see Data types). A date property declared kind: "string" stays an ISO-8601 string and returns null from every date operator; kind is pinned at first write, so such a property needs a successor to gain date arithmetic.
Pushdown — put $match first
The longest pushable prefix — a $match chain, then at most one $sort, $skip, $limit in that order — compiles into a regular query and runs through the access planner: secondary indexes, full-text and vector sources all apply. Everything after the prefix streams in memory.
- Filter early.
[$match, $group]scans an index;[$group, $match]scans the whole dataset. $textand vector clauses are valid only inside the prefix — after$group/$unwind/$skip/$limitthey fail withaggregate.bad_pipelineinstead of matching everything.- An in-pipeline
$sortdirectly followed by$skip/$limitkeeps only the topskip+limitrows.
"explain": true shows the split.
Limits
Blocking stages ($group, in-pipeline $sort) are bounded; exceeding a bound aborts with 400 aggregate.limit_exceeded and details.limit naming which one.
| Bound | Default | Body field |
|---|---|---|
Unique $group keys |
50 000 | groupLimit |
$push/$addToSet length |
10 000 | accumArrayLimit |
| Retained bytes across blocking stages | 256 MiB | memoryLimitBytes |
Negative values mean unlimited. There is no spill to disk — filter earlier or raise the limit.
Why it matters. The pipeline runs on the replica on your device, over decrypted data that never left it. There is no query planner in a datacenter to send this to — which is also why the budget is a hard in-memory bound rather than a billable spill.
Where this differs from MongoDB
| MongoDB | any | |
|---|---|---|
$group output key |
_id |
id — accepts _id or id on input, always emits id |
$count result |
{"<name>": N} |
same, delivered as the only document in records |
| Compute operators | full library | the closed set above |
$lookup |
any collection | self-join only |
$bucket, $bucketAuto, $replaceRoot, $sortByCount, $unionWith |
yes | not supported |
$out / $merge |
write results | not supported — writes go through the CRDT |
$project |
implicit _id, exclusion mode |
strictly explicit — only listed fields appear, id only if listed |
| Numbers | int/long/double/decimal | IEEE 754 float64 only; integer precision ends at 2^53 |
$group key equality |
type-aware, field-order-insensitive | byte equality of the canonical encoding — object keys are order-sensitive |
$min/$max |
type-aware | value order across types; null/missing ignored |
$sort stability |
not guaranteed | stable |
$group output order |
unspecified | first-seen scan order — still add $sort |
| Dotted output names | allowed | rejected |
$text placement |
anywhere | pushdown prefix only |
| Memory | allowDiskUse spill |
hard budget, aggregate.limit_exceeded |
Errors
| Code | Status | Meaning |
|---|---|---|
request.missing_field |
400 | no pipeline, or missing objectId/dataset on the per-object variant |
request.schema |
400 | pipeline is not a JSON array |
aggregate.bad_pipeline |
400 | unparseable pipeline, unknown stage/accumulator, $text/vector outside the prefix |
aggregate.limit_exceeded |
400 | a blocking-stage bound was exceeded — details.limit says which |
CLI
any aggregate <spaceId> <objectId> --dataset NAME --pipeline '<json>' # per-object dataset
any aggregate <spaceId> --properties --pipeline '<json>' # objects collection
--pipeline takes inline JSON, @FILE, or - for stdin. Optional: --group-limit, --accum-limit, --memory-limit, --explain.