Cerial

Manual Mode

Explicit transaction lifecycle control with commit, cancel, and automatic cleanup via await using.

Call $transaction() with no arguments to get a transaction object (txn) with explicit lifecycle control. You decide when to commit() or cancel(), making this the right choice when a transaction spans multiple function calls or when you need fine-grained control over its lifecycle.

Basic Usage

The standard pattern wraps your operations in try/catch, committing on success and cancelling on failure:

const txn = await client.$transaction();

try {
  await txn.User.create({
    data: { name: 'Alice', email: 'alice@example.com', isActive: true },
  });
  await txn.Post.create({
    data: { title: 'Hello', authorId: aliceId },
  });

  await txn.commit();
} catch (error) {
  await txn.cancel();
  throw error;
}

Two Access Patterns

Manual mode gives you two ways to run queries inside the transaction.

Model Proxy

Access models directly on txn, just like you would on client.db:

const txn = await client.$transaction();

const user = await txn.User.create({
  data: { name: 'Alice', email: 'alice@example.com', isActive: true },
});
await txn.Post.create({
  data: { title: 'My Post', authorId: user.id },
});

await txn.commit();

Option Pattern

Pass txn to regular client.db methods via the txn option:

const txn = await client.$transaction();

const user = await client.db.User.create({
  data: { name: 'Alice', email: 'alice@example.com', isActive: true },
  txn,
});
await client.db.Post.create({
  data: { title: 'My Post', authorId: user.id },
  txn,
});

await txn.commit();

Mixing Both Patterns

You can freely mix both in the same transaction. All operations share the same atomic boundary regardless of which access pattern you use:

const txn = await client.$transaction();

// Model proxy
await txn.User.create({
  data: { email: 'alice@example.com', name: 'Proxy User', isActive: true },
});

// Option pattern
await client.db.User.create({
  data: { email: 'bob@example.com', name: 'Option User', isActive: true },
  txn,
});

await txn.commit();
// Both users created atomically

Passing to Helper Functions

The option pattern is especially useful for helper functions that accept txn as a parameter. This keeps your transaction logic composable without coupling helpers to the transaction's origin:

async function createUserWithProfile(
  client: CerialClient,
  txn: CerialTransaction,
  name: string,
  email: string,
) {
  const user = await client.db.User.create({
    data: { name, email, isActive: true },
    txn,
  });
  await client.db.Profile.create({
    data: { userId: user.id, bio: '' },
    txn,
  });

  return user;
}

// Usage
const txn = await client.$transaction();
try {
  const alice = await createUserWithProfile(client, txn, 'Alice', 'alice@example.com');
  const bob = await createUserWithProfile(client, txn, 'Bob', 'bob@example.com');
  await txn.commit();
} catch (error) {
  await txn.cancel();
  throw error;
}

All Model Operations

Every method available on client.db works with both access patterns. Here's a full walkthrough using the model proxy:

const txn = await client.$transaction();

// Create
const user1 = await txn.User.create({
  data: { email: 'alice@example.com', name: 'Alice', isActive: true },
});
const user2 = await txn.User.create({
  data: { email: 'bob@example.com', name: 'Bob', isActive: false },
});

// Read
const found = await txn.User.findOne({ where: { email: 'alice@example.com' } });
const all = await txn.User.findMany();

// Update
const updated = await txn.User.updateMany({
  where: { email: 'alice@example.com' },
  data: { name: 'Alice Updated' },
});

// Aggregates
const count = await txn.User.count();
const exists = await txn.User.exists({ email: 'bob@example.com' });

// Delete
const deleted = await txn.User.deleteMany({ where: { isActive: false } });

await txn.commit();

The same operations with the option pattern:

const txn = await client.$transaction();

await client.db.User.create({
  data: { email: 'alice@example.com', name: 'Alice', isActive: true },
  txn,
});
const all = await client.db.User.findMany({ txn });
await client.db.User.updateMany({
  where: { email: 'alice@example.com' },
  data: { name: 'Updated' },
  txn,
});
const count = await client.db.User.count(undefined, txn);
const exists = await client.db.User.exists({ email: 'alice@example.com' }, txn);
const deleted = await client.db.User.deleteMany({
  where: { isActive: false },
  txn,
});

await txn.commit();

Multiple Models

Transactions can span any number of models. All operations share the same atomic guarantee:

const txn = await client.$transaction();

const user = await txn.User.create({
  data: { email: 'alice@example.com', name: 'Alice', isActive: true },
});
await txn.Post.create({ data: { title: 'Post A', authorId: user.id } });
await txn.Tag.create({ data: { name: 'welcome' } });

await txn.commit();

Nested Operations

Nested create and connect work with both access patterns. The parent and all nested children are part of the same transaction.

// Proxy pattern
const txn = await client.$transaction();
await txn.User.create({
  data: {
    email: 'charlie@example.com',
    name: 'Charlie',
    isActive: true,
    posts: {
      create: [{ title: 'Nested Post 1' }, { title: 'Nested Post 2' }],
    },
  },
});
await txn.commit();

// Option pattern
const txn2 = await client.$transaction();
await client.db.User.create({
  data: {
    email: 'dave@example.com',
    name: 'Dave',
    isActive: true,
    posts: {
      create: [{ title: 'Post A' }, { title: 'Post B' }],
    },
  },
  txn: txn2,
});
await txn2.commit();

Select

Use select to narrow returned fields. The return type adjusts automatically, just like outside transactions:

// Proxy pattern
const txn = await client.$transaction();
const user = await txn.User.create({
  data: { email: 'alice@example.com', name: 'Alice', isActive: true },
  select: { name: true, email: true },
});
await txn.commit();
// user: { name: string; email: string }

// Option pattern
const txn2 = await client.$transaction();
const user2 = await client.db.User.create({
  data: { email: 'bob@example.com', name: 'Bob', isActive: true },
  select: { name: true, email: true },
  txn: txn2,
});
await txn2.commit();
// user2: { name: string; email: string }

Transaction State

Every manual transaction tracks its current state. Check it with txn.state:

const txn = await client.$transaction();
console.log(txn.state); // 'active'

await txn.commit();
console.log(txn.state); // 'committed'

Or after cancelling:

const txn = await client.$transaction();
console.log(txn.state); // 'active'

await txn.cancel();
console.log(txn.state); // 'cancelled'

Possible states: 'active', 'committed', 'cancelled'.

Automatic Cleanup with await using

Use await using (TypeScript 5.2+) to guarantee cleanup without try/catch. When the txn variable goes out of scope, the transaction automatically cancels itself if commit() was never called:

{
  await using txn = await client.$transaction();

  await txn.User.create({
    data: { name: 'Alice', email: 'alice@example.com', isActive: true },
  });
  await txn.commit();
}
// If commit() wasn't called (e.g., an exception was thrown),
// the transaction is automatically cancelled when txn goes out of scope

This replaces try/catch/cancel boilerplate entirely.

Isolation

Uncommitted changes are not visible outside the transaction. Other queries against client.db won't see in-progress writes until commit() succeeds:

const txn = await client.$transaction();

await txn.User.create({
  data: { email: 'alice@example.com', name: 'Alice', isActive: true },
});

// Outside the transaction, Alice is not visible yet
const outsideView = await client.db.User.findMany({
  where: { email: 'alice@example.com' },
});
console.log(outsideView.length); // 0

await txn.commit();

// After commit, Alice is now visible
const afterCommit = await client.db.User.findMany({
  where: { email: 'alice@example.com' },
});
console.log(afterCommit.length); // 1

Error Handling

Using a committed transaction throws immediately when you try to access a model:

const txn = await client.$transaction();
await txn.commit();

// Throws: 'Transaction already ended'
txn.User.create({ data: { email: 'a@b.c', name: 'Stale', isActive: true } });

Same for a cancelled transaction:

const txn = await client.$transaction();
await txn.cancel();

// Throws: 'Transaction already ended'
txn.User.create({ data: { email: 'a@b.c', name: 'Stale', isActive: true } });

Always pair commit() with a cancel() fallback, or use await using. Forgetting to call either causes "transaction dropped" warnings from SurrealDB.

No nesting. Accessing $transaction on a txn client throws immediately. SurrealDB doesn't support savepoints.

On this page