Skip to main content

TransactionContext usage

The eridu-tech/transaction-context component lets you run code inside a database transaction and access the client that belongs to the current transaction scope, without passing it around manually.

Initial configuration​

To begin using the TransactionContext class, you'll need to create and configure an instance:

./samples/transaction-context-initial-config.ts
import { ExecutionContext } from "eridu-tech/execution-context";
import { AlsExecutionContextAdapter } from "eridu-tech/execution-context/als-execution-context-adapter";
import { contextToken } from "eridu-tech/execution-context/contracts";
import { TransactionContext } from "eridu-tech/transaction-context";
import { KyselyTransactionAdapter } from "eridu-tech/transaction-context/kysely-transaction-adapter";
import Sqlite from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";

const database = new Kysely<any>({
dialect: new SqliteDialect({
database: new Sqlite("DATABASE_NAME.db"),
}),
});

const executionContext = new ExecutionContext(new AlsExecutionContextAdapter());

export const transactionContext = new TransactionContext<Kysely<any>>({
// The token the active transaction is stored under
token: contextToken("sqlite-transaction"),

// You can choose the adapter to use
adapter: new KyselyTransactionAdapter({ database }),

// The execution context that tracks the active transaction across scopes
executionContext,
});
info

Here is a complete list of settings for the TransactionContext class.

Transaction basics​

Running code in a transaction​

You can run an invocable inside a transaction scope with run(). When no transaction is active a new one is started, committed after the invocable succeeds, and the invocable's return value is returned:

./samples/transaction-context-run.ts
import { transactionContext } from "./transaction-context-initial-config.js";

// Starts a transaction, commits it once the invocable succeeds and returns its result
const user = await transactionContext.run(async () => {
return transactionContext.current
.insertInto("users")
.values({ id: "1", name: "Jose" })
.returningAll()
.executeTakeFirst();
});

Aborting a transaction​

When the invocable throws, the transaction is aborted and the original error is re-thrown:

./samples/transaction-context-abort.ts
import { transactionContext } from "./transaction-context-initial-config.js";

try {
await transactionContext.run(async () => {
await transactionContext.current
.insertInto("users")
.values({ id: "1", name: "Jose" })
.execute();

// Throwing inside the invocable aborts the transaction
throw new Error("Something went wrong");
});
} catch (error: unknown) {
// The original error is re-thrown after the transaction was aborted
console.error(error);
}

Inspecting the connection state​

A transaction context exposes four read-only members describing the current scope, plus a fail-fast accessor for the transaction client. None of them performs I/O, so they are safe to read anywhere.

client​

The base client that operates outside of any transaction. It comes straight from the adapter and never becomes transaction-scoped, so it is the same instance inside and outside a transaction.

Use it only for work that must stay outside the ambient transaction:

  • schema and migration work,
  • a repository that opts out of the caller's transaction.
./samples/transaction-context-client.ts
import { transactionContext } from "./transaction-context-initial-config.js";

async function writeAuditLog(message: string): Promise<void> {
// The base client never joins a transaction
await transactionContext.client
.insertInto("audit_logs")
.values({ message })
.execute();
}

// No transaction is active, so the base client is the client in use here
console.log(transactionContext.client); // The base client

await writeAuditLog("Started");
warning

Never use the base client for work that belongs to the transaction. It runs on a different connection than the open transaction, which means the work silently escapes the transaction and the two connections can deadlock against each other.

transaction​

The active transaction-scoped client, or null when there is none.

Use it to get the transaction client explicitly and to handle the absence yourself. It never throws, so a null check is required; for the opposite behaviour use getTransactionOrFail().

The value is scoped to the current call chain and to this context's token, so another TransactionContext with a different token sees null even while a transaction is active here.

./samples/transaction-context-transaction.ts
import { transactionContext } from "./transaction-context-initial-config.js";

// The base client is used here, because no transaction is active
console.log(transactionContext.transaction); // null

await transactionContext.run(async () => {
// The transaction-scoped client
console.log(transactionContext.transaction);

// `transaction` is nullable, so check it before using it
const trx = transactionContext.transaction;
if (trx !== null) {
await trx.insertInto("users").values({ id: "1" }).execute();
}
});

current​

The client for the current scope: the transaction-scoped client when a transaction is active, otherwise the base client. It is never null.

Prefer it in application and repository code, so that code takes part in the caller's transaction when there is one and falls back to the base client when there is none.

./samples/transaction-context-current.ts
import { transactionContext } from "./transaction-context-initial-config.js";

async function createUser(userId: string): Promise<void> {
// Joins the active transaction when there is one, otherwise uses the base client
await transactionContext.current
.insertInto("users")
.values({ id: userId })
.execute();
}

// No transaction is active, so the base client is used
console.log(transactionContext.current); // The base client

await createUser("1");

await transactionContext.run(async () => {
// A transaction is active, so the transaction-scoped client is used
console.log(transactionContext.current); // The transaction-scoped client

// The very same function now takes part in the transaction
await createUser("2");
});
info

current is typed as TClient | TTransactionClient, so what it can do depends on the two type parameters.

When they are the same, as with KyselyTransactionAdapter, current is fully usable. When they differ, as with MongodbTransactionAdapter (an ITransactionAdapter<Db, ClientSession>), only the members shared by both types are callable, so a MongoDB driver call cannot be made through current.

In that case, read the transaction getter to obtain the ClientSession and pass it to the collection settings as { session }.

isInTransaction​

Whether a transaction is active in this context. Derived from transaction, so it is cheap to call anywhere.

Use it to decide, not to do: defer a side effect through afterCommit() or run it right away, guard transactional-only code, or narrow current when the two client types differ. It is always false for TransactionContext.noOp().

./samples/transaction-context-is-in-transaction.ts
import { transactionContext } from "./transaction-context-initial-config.js";

async function createUser(userId: string): Promise<void> {
await transactionContext.current
.insertInto("users")
.values({ id: userId })
.execute();
}

async function notify(userId: string): Promise<void> {
console.log(`Created user ${userId}`);
}

async function createUserAndNotify(userId: string): Promise<void> {
if (transactionContext.isInTransaction) {
// Only notify once the active transaction commits
await transactionContext.afterCommit(() => notify(userId));
}

await createUser(userId);

if (!transactionContext.isInTransaction) {
// There is no transaction, so notify right away
await notify(userId);
}
}

console.log(transactionContext.isInTransaction); // false

await transactionContext.run(() => createUserAndNotify("1"));

getTransactionOrFail()​

Returns the active transaction-scoped client, and throws a MandatoryPropagationError when no transaction is active. Calling it opts the surrounding code into MANDATORY propagation, which is the fail-fast counterpart of checking transaction for null yourself.

./samples/transaction-context-get-transaction-or-fail.ts
import { transactionContext } from "./transaction-context-initial-config.js";

await transactionContext.run(async () => {
// Returns the transaction-scoped client, or throws when no transaction is active
const trx = transactionContext.getTransactionOrFail();

await trx.insertInto("users").values({ id: "1", name: "Jose" }).execute();
});

// Throws a MandatoryPropagationError, because no transaction is active here
transactionContext.getTransactionOrFail();

In short: client opts out of the transaction, current opts in, transaction and getTransactionOrFail() require it, and isInTransaction asks whether there is one.

Nesting runs​

Nested run() calls reuse the transaction that is already active instead of starting a new one, so a single transaction spans the whole call chain:

./samples/transaction-context-nested-run.ts
import { transactionContext } from "./transaction-context-initial-config.js";

// Only one transaction is started: nested runs join the transaction of the outer run
await transactionContext.run(async () => {
await transactionContext.run(async () => {
// Same transaction as the outer run
});
});

Patterns​

Propagation modes​

run() accepts a propagation mode that decides how it behaves in relation to an existing transaction:

ModeTransaction is activeNo transaction is active
REQUIREDJoins the existing transactionStarts a new transaction
SUPPORTSJoins the existing transactionRuns without a transaction
MANDATORYJoins the existing transactionThrows MandatoryPropagationError
NEVERThrows NeverPropagationErrorRuns without a transaction

REQUIRED is the default when no propagation mode is provided:

./samples/transaction-context-propagation.ts
import { TRANSACTION_PROPAGATION } from "eridu-tech/transaction-context/contracts";
import { transactionContext } from "./transaction-context-initial-config.js";

async function createUser(userId: string): Promise<void> {
await transactionContext.current
.insertInto("users")
.values({ id: userId })
.execute();
}

// Joins the active transaction, or starts a new one when there is none (default)
await transactionContext.run(TRANSACTION_PROPAGATION.REQUIRED, () =>
createUser("1"),
);

// Joins the active transaction, or runs without a transaction when there is none
await transactionContext.run(TRANSACTION_PROPAGATION.SUPPORTS, () =>
createUser("2"),
);

// Requires an active transaction, otherwise a MandatoryPropagationError is thrown
await transactionContext.run(TRANSACTION_PROPAGATION.MANDATORY, () =>
createUser("3"),
);

// Must run outside of a transaction, otherwise a NeverPropagationError is thrown
await transactionContext.run(TRANSACTION_PROPAGATION.NEVER, () =>
createUser("4"),
);
info

Here is a complete list of propagation modes for the TRANSACTION_PROPAGATION constant.

After commit hooks​

You can register an invocable that only runs once the active transaction commits. This is useful for side effects that must not happen when the transaction is rolled back, such as sending emails or publishing events:

./samples/transaction-context-after-commit.ts
import { transactionContext } from "./transaction-context-initial-config.js";

async function sendWelcomeEmail(userId: string): Promise<void> {
// ...
}

await transactionContext.run(async () => {
await transactionContext.current
.insertInto("users")
.values({ id: "1", name: "Jose" })
.execute();

// Registered as an after-commit hook, so it only runs once the transaction commits
await transactionContext.afterCommit(() => sendWelcomeEmail("1"));
});

Hooks are attached to the transaction scope they were registered in, so they are discarded when that transaction aborts. When several hooks are registered, they run in registration order.

Hooks without a transaction​

When no transaction is active, afterCommit() decides what to do based on the runIfNoTransaction setting:

./samples/transaction-context-after-commit-no-transaction.ts
import { transactionContext } from "./transaction-context-initial-config.js";

async function sendWelcomeEmail(userId: string): Promise<void> {
// ...
}

// No transaction is active, so the hook runs immediately
await transactionContext.afterCommit(() => sendWelcomeEmail("1"));

// No transaction is active, so the hook is discarded
await transactionContext.afterCommit(() => sendWelcomeEmail("2"), {
runIfNoTransaction: false,
});

Non-transactional contexts​

You can create a context that never uses transactions with TransactionContext.noOp(). This is useful when a component expects a transaction context, but the underlying storage cannot support transactions:

./samples/transaction-context-no-op.ts
import { TransactionContext } from "eridu-tech/transaction-context";
import Sqlite from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";

const database = new Kysely<any>({
dialect: new SqliteDialect({
database: new Sqlite("DATABASE_NAME.db"),
}),
});

// A context that never uses transactions:
// `run()` invokes its invocable directly and `afterCommit()` hooks run immediately
export const noOpTransactionContext = TransactionContext.noOp(database);
info

With a no-op context, run() invokes its invocable directly, isInTransaction is always false, transaction is always null, and getTransactionOrFail() always throws a MandatoryPropagationError.

Multiple databases​

You can fan out after-commit hooks to several transaction contexts with MultiTransactionHooks, so a single consumer stays transaction-aware across every database a project uses, for example PostgreSQL and SQLite at the same time:

./samples/multi-transaction-hooks.ts
import { ExecutionContext } from "eridu-tech/execution-context";
import { AlsExecutionContextAdapter } from "eridu-tech/execution-context/als-execution-context-adapter";
import { contextToken } from "eridu-tech/execution-context/contracts";
import {
MultiTransactionHooks,
TransactionContext,
} from "eridu-tech/transaction-context";
import { KyselyTransactionAdapter } from "eridu-tech/transaction-context/kysely-transaction-adapter";
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import { transactionContext as sqliteTransactionContext } from "./transaction-context-initial-config.js";

const postgresDatabase = new Kysely<any>({
dialect: new PostgresDialect({
pool: new Pool({
database: "DATABASE_NAME",
host: "DATABASE_HOST",
user: "DATABASE_USER",
// DATABASE port
port: 5432,
password: "DATABASE_PASSWORD",
}),
}),
});

const executionContext = new ExecutionContext(new AlsExecutionContextAdapter());

const postgresTransactionContext = new TransactionContext<Kysely<any>>({
token: contextToken("postgres-transaction"),
adapter: new KyselyTransactionAdapter({ database: postgresDatabase }),
executionContext,
});

// Fans out after-commit hooks to every transaction context
export const transactionHooks = new MultiTransactionHooks([
postgresTransactionContext,
sqliteTransactionContext,
]);

async function publishUserCreatedEvent(userId: string): Promise<void> {
// ...
}

// No wrapped context is in a transaction, so the hook runs immediately
await transactionHooks.afterCommit(() => publishUserCreatedEvent("1"));

// Only the postgres context is in a transaction, so the hook is registered on
// it and runs once that transaction commits
await postgresTransactionContext.run(async () => {
await transactionHooks.afterCommit(() => publishUserCreatedEvent("1"));
});

// Only the sqlite context is in a transaction, so the hook is registered on it
// and runs once that transaction commits
await sqliteTransactionContext.run(async () => {
await transactionHooks.afterCommit(() => publishUserCreatedEvent("1"));
});
info

The hook is registered on every wrapped context that currently has an active transaction, and therefore runs once per transaction that commits. When none of the wrapped contexts is in a transaction, the hook runs immediately unless runIfNoTransaction is false.

Further information​

For further information refer to eridu-tech/transaction-context API docs.