Batch and aggregate
Run multiple queries or writes in one request and compute grouped aggregates with the SDK.
#Batch and aggregate
The SDK provides five ways to reduce round trips: batch() for parallel reads across collections, batchWrite() for multi-operation (optionally atomic) writes, createMany() and deleteMany() for bulk operations on one collection, and aggregate() for server-side GROUP BY queries.
#batch
sdk.batch(queries: BatchQuery[]): Promise<{ results: BatchQueryResult[] }>
Executes multiple collection queries in a single request. Each query specifies its own collection name plus any list options (filter, sortBy, limit, and the rest). Queries run in parallel on the server, and individual query failures are returned inline without failing the whole batch.
This is a read-only endpoint — it does not support mutations. Maximum 20 queries per call.
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com',
auth: { apiKey: 'sk-your-api-key' }
});
const { results } = await sdk.batch([
{ collection: 'products', limit: 5, sortBy: '-created_at' },
{ collection: 'categories', filter: { active: true } }
]);
const [products, categories] = results;
console.log(products.data, categories.data);
results is positional: results[i] is the outcome of queries[i].
#Partial failure
A batch resolves even when some sub-queries fail — that is the point of running them together. Every entry carries data, so a failed one is an empty list alongside its error:
| Field | Description |
|---|---|
data |
Matching records. [] when this sub-query failed |
total, page, limit, hasMore |
The usual pagination fields (total: 0, hasMore: false on failure) |
error |
Present only on failure: not_found, forbidden, invalid_query, invalid_collection, internal_error |
message |
Human-readable detail for error |
status |
The HTTP status this sub-query would have returned on its own (403, 422, …) |
So results[i].data is always an array and a section that could not be loaded renders empty rather than throwing. Check error (or the exported isBatchFailure helper) wherever an empty section has to be reported as a failure rather than as "no rows":
import { isBatchFailure } from '@emuview/sdk';
const { results } = await sdk.batch(queries);
for (const [i, entry] of results.entries()) {
if (isBatchFailure(entry)) {
console.warn(`query ${i} failed (${entry.status}): ${entry.error} — ${entry.message}`);
}
}
// Safe regardless: a failed entry contributes no rows instead of throwing.
const allRows = results.flatMap((entry) => entry.data);
The SDK serialises each query the same way collection.list() does — filter objects are JSON-encoded, fields arrays are joined, and a - prefix on sortBy becomes order: 'desc' — so the two calls behave identically for the same options. The server accepts a filter as either an object or a JSON string, but only from 2026-08-10 onward; against an older gateway, filtered sub-queries fail with 422 invalid_query.
includeDeleted and deletedOnly require the view_deleted permission, exactly as on the list endpoint. Without it that sub-query comes back forbidden while the others still return their rows.
sdk.collection(name).batch(queries) also exists but is deprecated — it is misleadingly scoped to a collection when the endpoint is collection-agnostic. It will be removed in v1.0. Use sdk.batch() instead.
#batchWrite
sdk.batchWrite(input): Promise<{ atomic: boolean; results: unknown[] }>
Executes multiple write operations (create, update, delete) across collections in a single request — by default as one atomic transaction. Maximum 25 operations per call.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
input.atomic |
boolean |
No | Run as one atomic transaction. Defaults to true |
input.ops |
array |
Yes | Ordered list of operations (see shapes below) |
Each operation is one of:
| Operation | Shape |
|---|---|
| Create | { op: 'create', collection, data, as? } |
| Update | { op: 'update', collection, id, data, version?, as? } |
| Delete | { op: 'delete', collection, id, permanent? } |
#Referencing earlier results
Operations run in order. Give an operation an alias with as, then use $alias.field strings in a later operation's data (or in an update/delete id) to reference fields from the earlier result — including server-generated IDs:
const { results } = await sdk.batchWrite({
ops: [
{ op: 'create', collection: 'authors', data: { name: 'Ursula' }, as: 'a' },
{
op: 'create',
collection: 'books',
data: { title: 'The Dispossessed', author: '$a.id' },
as: 'b'
},
{ op: 'update', collection: 'books', id: '$b.id', data: { status: 'published' } }
]
});
#Atomic vs non-atomic mode
Atomic mode (default). All operations must target the same database and run in a single D1 transaction — if any operation fails, the whole batch rolls back and nothing persists. Cross-database atomic batches are rejected with status 422. Operations that write a geo or R2-backed field are rejected — use the single-record endpoints for those. Per-operation RBAC (permission checks, item_filter, presets, validation) and optimistic concurrency (version) are enforced.
An op's version is checked while the batch is being prepared, not inside the
transaction, so a writer that lands between preparation and commit is not detected — the batch commits and that op's write wins. If a genuinely concurrent contended update matters, use
PATCHon the single-record endpoint, where the version is re-asserted as part of the write.
Non-atomic mode (atomic: false) is best-effort and may span databases. Each operation is attempted independently; failures are reported inline in results without aborting the rest.
Batch writes do not currently run automation flows or JS hooks. Use the single-record endpoints when you depend on those.
#createMany
sdk.collection(name).createMany(records): Promise<{ created: number; records: (CollectionRecord & T)[]; errors: Array<{ index: number; message: string }> }>
Creates multiple records in one collection in a single request. Maximum 200 records per call. Per-record failures are reported in errors with the failing record's index.
const result = await sdk.collection('products').createMany([
{ title: 'Widget A', price: 9.99 },
{ title: 'Widget B', price: 14.99 }
]);
console.log(`Created ${result.created} records`);
for (const err of result.errors) {
console.warn(`Record ${err.index} failed: ${err.message}`);
}
#deleteMany
sdk.collection(name).deleteMany(ids: string[]): Promise<{ deleted: number }>
Deletes multiple records by ID in a single request. Maximum 200 IDs per call. Returns the number of records actually deleted.
const result = await sdk
.collection('products')
.deleteMany(['rec_abc123', 'rec_def456', 'rec_ghi789']);
console.log(`Deleted ${result.deleted} records`);
#aggregate
sdk.collection(name).aggregate(spec): Promise<{ data: Array<Record<string, unknown>>; hasMore: boolean }>
Groups records and computes aggregates in a single request — the SQL GROUP BY / HAVING you can't express through list(). RBAC applies before grouping, and you can only group or aggregate by columns your role can read.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
spec.aggregates |
Record<string, { op, field? }> |
Yes | Named aggregates. op is 'count', 'sum', 'min', 'max', or 'avg'; field is the column (or '*' for count) |
spec.filter |
object | array |
No | Same grammar as list() — local fields, relation dot-paths, _some/_none |
spec.groupBy |
string[] |
No | Columns to group by |
spec.having |
Record<string, object> |
No | Post-grouping filter, keyed by aggregate alias or groupBy column |
spec.sort |
Array<Record<string, 'asc' | 'desc'>> |
No | Sort order, keyed by aggregate alias or groupBy column |
spec.limit |
number |
No | Page size |
spec.offset |
number |
No | Page offset |
Group cardinality is capped at 1000 — page through larger result sets with limit and offset. The response's hasMore tells you whether more groups exist.
const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
const { data, hasMore } = await sdk.collection('topics').aggregate({
filter: { created_at: { _gte: since } },
groupBy: ['forum_id'],
aggregates: {
count: { op: 'count', field: '*' },
last_post: { op: 'max', field: 'created_at' }
},
having: { count: { _gte: 5 } },
sort: [{ count: 'desc' }],
limit: 50
});
// data: [{ forum_id: 'frm_01HXK5M...', count: 12, last_post: 1751500000 }, ...]
#Choosing the right tool
| You want to | Use |
|---|---|
| Load several unrelated lists for one page | sdk.batch() |
| Create a parent and children in one transaction | sdk.batchWrite() |
| Insert many rows into one collection | createMany() |
| Remove many rows from one collection | deleteMany() |
| Count, sum, or group records server-side | aggregate() |
#Errors
| Status | When |
|---|---|
400 |
Malformed operations, too many queries/ops/records, or invalid aggregate spec |
403 |
An operation's RBAC check failed (atomic batches roll back entirely) |
409 |
An atomic batchWrite update's version didn't match (optimistic concurrency) |
422 |
Atomic batchWrite spanning databases, or writing geo/R2-backed fields |
All failures throw ApiError — see error handling. In non-atomic batchWrite, batch(), and createMany(), per-item failures are returned inline instead of thrown.