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);
Note

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.

Note

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.

Note

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.

Note

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 PATCH on 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.

Note

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.