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 op is authorized as the single call it runs as (insert as
POST /v1/{collection}, update as PATCH /v1/{collection}/{id}, delete
as DELETE /v1/{collection}/{id}): the write gate (a signed-in user who is
not an app administrator writes only where the collection's rules grant
create, write or delete), the collection's rules, and for an update or delete
of an existing record its record ACL (owner-only collections).
Every op is authorized before any op runs, in both modes. The first
refused op refuses the whole batch with exactly the response that single call
gives (403 or 401, same error), and nothing of the batch is written. A
batch is therefore all-or-nothing for authorization whether atomic is true
or false; atomic governs only failures while the ops run (rolled back with
400 above, or reported per op in results). The app's own 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() } },
]);