import Substrio from '@substrio/sdk';
const db = new Substrio({ url: 'https://my-app.substrio.com', tenant: 'my-app', key: process.env.SUBSTRIO_KEY });
// Atomic bank transfer: debit Alice, credit Bob, log the transfer
const result = await db.transaction([
{ op: 'update', collection: 'accounts', id: '123', data: { $set: { balance: 900 } } },
{ op: 'update', collection: 'accounts', id: '456', data: { $set: { balance: 600 } } },
{ op: 'insert', collection: 'transfers', doc: { from: '123', to: '456', amount: 100 } },
]);
// result: { ok: true, count: 3, atomic: true, results: [...] }
Running a batch
batch.execute (POST /v1/_batch) runs a batch of operations, atomically by default.
Headers: Authorization: Bearer <key or token> (required), X-Substrio-Tenant: <app> (when the host does not name the app), Content-Type: application/json.
{
"atomic": true,
"ops": [
{ "op": "insert", "collection": "users", "doc": { "name": "Alice", "role": "admin" } },
{ "op": "update", "collection": "config", "id": "settings", "data": { "$set": { "user_count": 42 } } },
{ "op": "delete", "collection": "temp", "id": "cleanup_target" }
]
}
| Field | Type | Default | Description |
|---|---|---|---|
atomic |
boolean | true |
All ops succeed, or all are rolled back |
ops |
array | required | 1 to 100 operations |
X-Substrio-Namespace runs the whole batch inside that namespace.
Operation types
insert requires collection (string) and doc (object, the document to insert):
{ "op": "insert", "collection": "users", "doc": { "name": "Alice", "age": 30 } }
update requires collection, id (string or number) and data (the update operators data.update takes: $set, $inc, and so on):
{ "op": "update", "collection": "users", "id": "123", "data": { "$set": { "age": 31 } } }
delete requires collection and id:
{ "op": "delete", "collection": "users", "id": "123" }
Success response (200)
{
"ok": true,
"atomic": true,
"count": 3,
"results": [
{ "_id": 1, "status": "created" },
{ "_id": "123", "status": "updated" },
{ "_id": "456", "status": "deleted" }
]
}
Rollback response (400)
When atomic: true and an op fails, every earlier op in the batch is undone:
{
"error": "Batch rolled back",
"atomic": true,
"failed_index": 2,
"failed_op": "update",
"reason": { "error": "Record not found" },
"rolled_back": 2
}
| Field | Description |
|---|---|
failed_index |
Zero-based index of the operation that failed |
failed_op |
Type of the failed operation |
reason |
Error details from the failed operation |
rolled_back |
Number of operations that were undone |
Authorization
Each operation is authorized as its corresponding single call: insert as
POST /v1/{collection}, update as PATCH /v1/{collection}/{id}, and delete
as DELETE /v1/{collection}/{id}. The write gate and collection rules apply.
A signed-in user who is not an app administrator can write only where the
collection's rules grant create, write, or delete access. The record ACL also
applies to updates and deletes of existing records in owner-only collections.
Every operation is authorized before any operation runs, in both modes. The first
unauthorized operation rejects the whole batch with the same 401 or 403 response
as the corresponding single call. Nothing in the batch is written. Authorization
is therefore all-or-nothing whether atomic is true or false. The atomic flag
governs failures during execution: they either roll back the batch with a 400
response or are reported per operation in results. The app key is not subject
to rules.
SDK: db.transaction(ops)
const result = await db.transaction([
{ op: 'insert', collection: 'orders', doc: { item: 'widget', qty: 5 } },
{ op: 'update', collection: 'inventory', id: '42', data: { $set: { qty: 95 } } },
]);
Throws a plain Error for an empty or non-array ops; throws SubstrioError with status 400 when the server refuses the batch. Always sends atomic: true.
Rollback semantics
The undo log tracks three compensation strategies:
| Original op | Undo strategy |
|---|---|
insert |
Delete the newly created record |
update |
Restore the original value (captured before mutation) |
delete |
Re-insert the original value (captured before deletion) |
Rollback runs in reverse order: the last successful operation is undone first.
Validation errors
Every validation runs before any operation executes:
| Error | Cause |
|---|---|
Missing 'ops' array |
Request body has no ops field |
'ops' must be an array |
ops is present but not an array |
ops array must not be empty |
Empty ops array |
Invalid op type 'X' |
Op is not insert, update, or delete |
Each op must have a 'collection' field |
Missing collection |
insert op requires a 'doc' field |
Insert without doc |
update op requires an 'id' field |
Update without id |
update op requires a 'data' field |
Update without data |
delete op requires an 'id' field |
Delete without id |
Examples
Inventory management
// Decrease stock, create order, log audit: all atomic
await db.transaction([
{ op: 'update', collection: 'products', id: 'SKU-001', data: { $inc: { stock: -1 } } },
{ op: 'insert', collection: 'orders', doc: { sku: 'SKU-001', customer: 'C-42', qty: 1 } },
{ op: 'insert', collection: 'audit_log', doc: { action: 'purchase', sku: 'SKU-001', ts: Date.now() } },
]);
User deletion with cascade
// Delete user and all their related data atomically
await db.transaction([
{ op: 'delete', collection: 'users', id: userId },
{ op: 'delete', collection: 'sessions', id: sessionId },
{ op: 'delete', collection: 'preferences', id: userId },
{ op: 'insert', collection: 'deletion_log', doc: { user_id: userId, deleted_at: Date.now() } },
]);
Cross-collection consistency
// Move item between collections atomically
const item = await db.col('inbox').get(itemId);
await db.transaction([
{ op: 'delete', collection: 'inbox', id: String(itemId) },
{ op: 'insert', collection: 'archive', doc: { ...item, archived_at: Date.now() } },
]);