Multi-document transactions

Run insert, update and delete operations across collections in one call, all committed or all rolled back.

Docs menu
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() } },
]);