Transactions
Typhex provides two transaction APIs. Both support nested savepoints and configurable isolation levels.
Callback API (Recommended)
Pass an async callback to db.transaction(). Any Entity.query() call inside the callback automatically uses the active transaction — no need to thread a trx argument through your code:
import { Db, Entity, createSqliteDriver } from "typhex";
await db.transaction(async () => {
const user = await User.query().insert({ name: "Alice" });
await Post.query().insert({ title: "Hello", authorId: user.id });
});BEGIN
INSERT INTO users (name) VALUES (?)
INSERT INTO posts (title, authorId) VALUES (?, ?)
COMMITIf the callback throws, the transaction is rolled back automatically:
await db
.transaction(async () => {
await User.query().insert({ name: "Bob" });
throw new Error("oops");
})
.catch((e) => console.log("rolled back:", e.message));BEGIN
INSERT INTO users (name) VALUES (?)
ROLLBACKExplicit API (Service-Layer Pattern)
Use db.beginTrx() when you need to pass the transaction handle to functions that shouldn't know about db:
import { Trx } from "typhex";
async function createUserWithPost(trx: Trx, name: string, title: string) {
const user = await User.query(trx).insert({ name });
await Post.query(trx).insert({ title, authorId: user.id });
return user;
}
const trx = await db.beginTrx();
try {
const user = await createUserWithPost(trx, "Carol", "Carol's post");
await trx.commit();
} catch {
await trx.rollback();
}Entity.query(trx) routes the query through the given transaction connection.
Nested Transactions (Savepoints)
Calling trx.transaction() (or db.transaction() inside an active transaction) creates a savepoint. Rolling back the inner transaction only undoes work since the savepoint — the outer transaction is unaffected:
await db.transaction(async (outer) => {
const dave = await User.query(outer).insert({ name: "Dave" });
// Inner savepoint — will be rolled back
await outer
.transaction(async (inner) => {
await Post.query(inner).insert({ title: "Draft", authorId: dave.id });
throw new Error("discard draft");
})
.catch(() => {}); // only the draft is lost
// Dave still exists; publish a different post
await Post.query(outer).insert({ title: "Published", authorId: dave.id });
});BEGIN
INSERT INTO users (name) VALUES (?)
SAVEPOINT sp_1
INSERT INTO posts (title, authorId) VALUES (?, ?)
ROLLBACK TO SAVEPOINT sp_1
INSERT INTO posts (title, authorId) VALUES (?, ?)
COMMITTransaction Options
Isolation Level
await db.transaction(
async () => {
// ...
},
{ isolationLevel: "SERIALIZABLE" },
);-- SQLite
BEGIN IMMEDIATE
...
COMMIT
-- PostgreSQL
BEGIN ISOLATION LEVEL SERIALIZABLE
...
COMMITSQLite maps "SERIALIZABLE" to BEGIN IMMEDIATE.
SQLite Exclusive Mode
const trx = await db.beginTrx({ sqliteMode: "exclusive" });
await User.query(trx).insert({ name: "Frank" });
await trx.commit();sqliteMode accepts "deferred" (default), "immediate", or "exclusive".
With insertGraph
insertGraph respects an active transaction — pass trx to keep the whole graph insertion atomic:
await db.transaction(async (trx) => {
await Post.query(trx).insertGraph({
title: "Hello",
author: { name: "Alice" },
tags: [{ name: "typescript" }, { name: "orm" }],
});
});