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