Dyrected
Deployment & OperationsInfrastructureDatabase Adapters

Transactions

Group multiple database writes so they all succeed or all roll back, and understand how transactions behave across Dyrected's adapters.

A transaction groups several database operations into one all-or-nothing unit. Either every write commits, or if anything throws, they all roll back and the database looks like none of them happened. You reach for one whenever a single logical action touches more than one record — creating a document and writing an audit entry, moving a value between two records — and a half-finished result would be wrong.

Dyrected uses transactions internally so you often get this for free, and it exposes them where you'd write multi-step logic of your own.

Where you already get transactions

Workflow operations run inside a transaction automatically. When a document moves between workflow states, the state change, its revision bump, and its history events all commit together or not at all — so you never end up with a document stuck half-way between states. The same applies when a workflow document is created or a new revision is saved.

Because of this, workflows require a transaction-capable database adapter. Every adapter Dyrected ships qualifies; a custom adapter that doesn't implement transaction will be rejected when a workflow operation runs.

Running a transaction yourself

The full database adapter is available in the write-side hooks — afterChange, afterDelete, and their global and workflow-transition equivalents — and it exposes a transaction method. Grouping writes is always explicit in Dyrected: there's no request-level transaction and no SDK transaction to reach for, so you call db.transaction at the point you need it. Pass it a callback; every operation you run on the adapter it hands back (tx below) is part of one atomic unit. Return normally to commit, or throw to roll everything back.

import { defineCollection, defineTextField, defineSelectField } from '@dyrected/core'

export const Posts = defineCollection({
  slug: 'posts',
  fields: [
    defineTextField({ name: 'title', label: 'Title', required: true }),
    defineSelectField({ name: 'status', label: 'Status', options: ['draft', 'published'] }),
  ],
  hooks: {
    afterChange: [
      async ({ doc, operation, db }) => {
        if (operation !== 'create') return

        // The post already exists here. Record an activity entry in the
        // same transaction so we never log a post that didn't stick.
        await db.transaction(async (tx) => {
          await tx.create({
            collection: 'activity',
            data: { type: 'post.created', postId: doc.id },
          })
        })
      },
    ],
  },
})

This assumes an activity collection defined in the same config. Use the tx argument inside the callback rather than the outer db — that's what ties each operation to the transaction.

Keep transactions short

A transaction holds resources for as long as its callback runs, so keep the work inside it to reading and writing the database — and nothing slow.

Don't do slow work inside a transaction. Calling an external API, sending an email, processing a file, or waiting on anything network-bound inside the callback keeps the transaction open the whole time. Do that work before the transaction (and pass the result in) or after it commits.

Why it matters, per adapter:

  • Postgres and MySQL hold a database connection and any row locks for the life of the transaction. A slow callback ties up a pooled connection and makes other writers wait on the locked rows — under load this can exhaust the pool.
  • SQLite runs transactions one at a time through a single queue. A long-running transaction blocks every other transaction in your app, including workflow transitions, until it finishes.
  • MongoDB aborts a transaction that runs past its server time limit (60 seconds by default), so slow work can make the whole transaction fail and roll back.

The pattern to follow: gather inputs, open the transaction, read-modify-write quickly, commit, then do any follow-up work outside it.

Read-only hooks don't get transactions. Hooks like beforeChange, beforeRead, and afterRead receive a read-only view of the database with no write methods. Grouped writes belong in afterChange and afterDelete, where the full adapter is available.

How each adapter behaves

Every shipped adapter implements transactions, but the guarantees underneath differ, so it's worth knowing your database:

  • Postgres and MySQL run true database transactions. Reads inside a transaction take row locks, so a concurrent writer waits until you commit. That makes read-modify-write safe under load.
  • SQLite supports transactions but runs them one at a time — Dyrected serializes them rather than running them concurrently. Correct and simple, and rarely a bottleneck at the scale SQLite is a good fit for.
  • MongoDB uses sessions, which means multi-document transactions require a replica set or sharded cluster. A standalone mongod can't run them; managed MongoDB and Atlas already run as replica sets. See MongoDB.

Custom adapters

transaction is an optional part of the database adapter contract, but every adapter Dyrected ships implements it, and workflow transitions depend on it. If you write your own adapter and want workflows or your own grouped writes to be safe, implement transaction with your database's real transaction mechanism — don't emulate it with independent writes, which defeats the all-or-nothing guarantee.

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend