transactions
Transactions are useful if you want to tie the success of one query to another, enabling you to guarantee a valid state for your database in the event that anything might go wrong. If the callback throws, the entire transaction rolls back.
There are two ways to start a transaction:
// Class-level
await ApplicationModel.transaction(async txn => {
const user = await User.txn(txn).create({ ... })
await UserSettings.txn(txn).create({ user })
})
// Instance-level
await user.transaction(async txn => {
await user.txn(txn).update({ status: 'active' })
await user.txn(txn).createAssociation('profile', {})
})
The pattern for this can seem strange at first, but you will get the hang of it in short order. To instantiate a transaction, you will call the transaction method on your app's ApplicationModel (or on a model instance), capturing the provided transaction in the callback function you provide, named txn by convention. With the txn variable on hand, you proceed to pass it to each subsequent query that might need it, as demonstrated above, using the txn method (named the same as the variable).
Every operation inside a transaction must be explicitly bound
Every model operation inside a transaction — creates, updates, destroys, queries, association operations — must be explicitly bound via .txn(txn). If you forget it, the operation runs outside the transaction and won't roll back if the callback later throws:
await ApplicationModel.transaction(async txn => {
const user = await User.txn(txn).create({ email: 'test@test.com' })
const post = await Post.txn(txn).create({ user, title: 'Test' })
await user.txn(txn).createAssociation('profile', {})
// Queries also need .txn(txn) to see uncommitted data
const found = await User.txn(txn).findBy({ email: 'test@test.com' })
})
On a @deco.Sortable model, forgetting .txn(txn) is worse than a lost rollback. A sortable write computes its position under a lock on its sort scope, so an unbound room.destroy() inside a Place transaction opens a second transaction on another connection and waits on a lock the enclosing transaction is holding. Only one side is waiting, so Postgres's deadlock detector never sees it — the call hangs until sortableScopeLockTimeout expires, then throws SortableScopeLockWaitTimedOut. That error advises retrying; a retry cannot help here, since the fix is adding the missing .txn(txn).
instance.txn(txn).update(attrs) mutates that same in-memory instance — it assigns the attributes to the instance before saving, exactly like non-transactional instance.update(). So the caller's reference is current after the call and can be handed onward (to a service, a serializer) without re-querying. The catch: only that instance is updated. If a helper loaded a different instance of the same row (e.g. Place.txn(txn).findBy(...)) and updated it, the caller's original instance is now stale — await original.reload() before reading from it.
Optional transaction participation with .txn(null)
.txn() accepts null as well as a transaction. When null is passed, the operation runs without a transaction — it's a no-op. This lets a method that only optionally participates in a transaction use a single code path instead of branching:
async function doWork(user: User, txn: DreamTransaction<ApplicationModel> | null = null) {
// No if/else needed — .txn(null) is a no-op
await user.txn(txn).update({ status: 'done' })
await user.txn(txn).createAssociation('posts', { title: 'New' })
}
// Call with a transaction
await ApplicationModel.transaction(async txn => {
await doWork(user, txn)
})
// Call without — works the same, just no transaction wrapping
await doWork(user)
Both Model.txn(null) (class-level) and instance.txn(null) (instance-level) work the same way.
Restrictions inside transactions
Once in a transaction builder (done by calling the txn method), you will be prohibited from using methods that rely on unique-constraint violations to function, like createOrFindBy and createOrUpdateBy, since they will not function properly in a transaction. Use their transaction-safe counterparts — findOrCreateBy and updateOrCreateBy — instead.
Keep background work and I/O out of the transaction
Do not enqueue background work from inside an open transaction. When a model lifecycle hook queues a backgrounded service, a backgrounded model method, or any helper that queues work, use a commit hook variant such as @deco.AfterCreateCommit, @deco.AfterUpdateCommit, or @deco.AfterSaveCommit so the worker only runs after the transaction commits. Otherwise the worker can race the transaction and either miss a newly-created row or read stale data after an update. This applies equally to an imperative background() call made from inside ApplicationModel.transaction(...).
More broadly, what belongs inside ApplicationModel.transaction(...) is database writes only. HTTP fetches, S3/object-storage uploads, sending email, calling third-party APIs, file I/O, sleeps, and any other non-DB work belong outside the transaction. Holding an open transaction across long-running I/O has three failure modes:
- Connection pool starvation. A long-running transaction pins a Postgres connection, so other requests block waiting for a free one.
- Vacuum interference. Open transactions hold the horizon for vacuum, preventing dead-tuple cleanup on hot tables until the transaction closes.
- Background-job race exposure. If work inside the transaction enqueues background jobs, workers dequeue immediately and race the commit.
Do the external I/O before the transaction, then record the result in a tight transaction at the end. If you invert this — commit first, then do the I/O — and the I/O fails, the database will claim work happened that actually didn't, requiring reconciliation code to find and recover those records:
// WRONG — long-running I/O inside the transaction
await ApplicationModel.transaction(async txn => {
for (const record of records) {
const place = await Place.txn(txn).create({ ... })
await fetch(record.photoUrl) // network I/O
await s3.send(new PutObjectCommand({ ... })) // network I/O
}
})
// ALSO WRONG — DB says the work happened, but if the I/O fails, external state is missing
const created: Place[] = []
await ApplicationModel.transaction(async txn => {
for (const record of records) {
created.push(await Place.txn(txn).create({ ... }))
}
})
for (const place of created) {
await s3.send(new PutObjectCommand({ ... })) // if this fails, the DB row is orphaned
}
// RIGHT — do the I/O first, then record the result in a single fast transaction
const prepared: Array<{ record: Record; bucketPath: string }> = []
for (const record of records) {
const buffer = await (await fetch(record.photoUrl)).arrayBuffer()
const bucketPath = PlacePhotoMediaService.objectKey(host.id, record)
await s3.send(new PutObjectCommand({ Bucket, Key: bucketPath, Body: Buffer.from(buffer) }))
prepared.push({ record, bucketPath })
}
await ApplicationModel.transaction(async txn => {
for (const { record, bucketPath } of prepared) {
await Place.txn(txn).create({ ...record, bucketPath })
}
})
If the I/O phase fails partway, nothing is in the database and the caller can safely retry. The transaction at the end is short, predictable, and never held open across network round-trips. If a later failure requires cleanup, orphaned S3 objects are cheaper to reconcile than orphaned DB rows pointing at nothing.