Declarative transactions for JavaScript and TypeScript, without an ORM. You write SQL with your usual driver (pg, postgres, or mysql2); TranJS manages the transaction around it, with semantics modelled on Spring's @Transactional.
import { Transactional, Propagation } from "@tranjs/core";
import { ctx } from "@tranjs/pg";
class AccountService {
@Transactional()
async transfer(from: string, to: string, amount: number) {
await this.withdraw(from, amount);
await this.deposit(to, amount);
} // commits here, or rolls back both updates if anything threw
@Transactional(Propagation.MANDATORY)
private async withdraw(id: string, amount: number) {
await ctx().query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, id]);
}
@Transactional(Propagation.MANDATORY)
private async deposit(id: string, amount: number) {
await ctx().query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, id]);
}
}ctx() returns the connection of the current transaction, so nested calls share it without passing it around.
- β
Declarative transactions with
@Transactional, ortransactionalfor plain functions - π All 7 Spring propagation types:
REQUIRED,REQUIRES_NEW,MANDATORY,SUPPORTS,NOT_SUPPORTED,NEVER,NESTED(savepoints) - π‘οΈ Spring rollback rules: a failure in a joined method marks the whole transaction rollback-only
- ποΈ Isolation level, read-only and timeout per transaction
- ποΈ Multiple datasources, each with independent transactions
- πͺ
afterCommit/afterRollbackhooks - π PostgreSQL and MySQL through
pg,postgres(postgres.js), andmysql2, or your own driver - βοΈ Runs on Node.js, Bun, Deno and Cloudflare Workers
- β‘ TypeScript native, no runtime dependencies in the core package
# PostgreSQL (pg)
npm install @tranjs/core @tranjs/pg pg
# PostgreSQL (postgres.js)
npm install @tranjs/core @tranjs/postgres postgres
# MySQL
npm install @tranjs/core @tranjs/mysql2 mysql2Enable decorators in tsconfig.json:
{ "compilerOptions": { "experimentalDecorators": true } }Register a transaction manager once at startup:
import { Pool } from "pg";
import { usePostgreSQLTransactionManager } from "@tranjs/pg";
usePostgreSQLTransactionManager(new Pool({ connectionString: process.env.DATABASE_URL }));import { createPool } from "mysql2/promise";
import { useMySQLTransactionManager } from "@tranjs/mysql2";
useMySQLTransactionManager(createPool(process.env.DATABASE_URL!));Register managers once, not inside a request handler. Re-registering per request breaks concurrent requests.
import { transactional, IsolationLevel } from "@tranjs/core";
import { ctx } from "@tranjs/pg";
const createOrder = transactional(async (userId: string) => {
const { rows } = await ctx().query("INSERT INTO orders (user_id) VALUES ($1) RETURNING *", [userId]);
return rows[0];
}, { isolationLevel: IsolationLevel.SERIALIZABLE, timeout: 5000 });
const order = await createOrder(userId);transactional(fn, config?) wraps a function and keeps its arguments and the this used when you call it. The function can be synchronous or asynchronous. The wrapper always returns a Promise: it waits for the result and rejects if the call fails. It copies the configuration when you create it and looks up the manager when you call it.
- If a method throws, its transaction rolls back and the error is rethrown.
- If an inner method that joined the transaction throws, the whole transaction is marked rollback-only. Catching the error in the outer method does not save it; the commit turns into a rollback and throws
UnexpectedRollbackError. To let an inner step fail on its own, usePropagation.REQUIRES_NEW(separate transaction) orPropagation.NESTED(savepoint).
@Transactional()
async placeOrder(order: Order) {
await this.saveOrder(order);
try {
await this.audit.record(order); // @Transactional(Propagation.REQUIRES_NEW)
} catch {
// audit failure is rolled back on its own; the order still commits
}
}Use @tranjs/testing to check transactions and roll back test data. This example uses the PostgreSQL manager and docs_users table from the guide's setup.
import { expect, test } from "@jest/globals";
import { transactional } from "@tranjs/core";
import { ctx } from "@tranjs/pg";
import { withTestTransaction } from "@tranjs/testing";
import "@tranjs/testing/jest";
const repository = {
async save(name: string) {
await ctx().query("INSERT INTO docs_users(name) VALUES ($1)", [name]);
},
};
function createUserService(repo: typeof repository) {
return { create: transactional(async (name: string) => repo.save(name)) };
}
test("create saves a user in a transaction", () => withTestTransaction(async tx => {
const trackedRepository = tx.track(repository);
const service = createUserService(trackedRepository);
await service.create("Ada");
expect(trackedRepository.save).toHaveCommitted();
}));See connection lifetime for connection rules and the testing guide for setup and examples.
| Platform | Status | How to use |
|---|---|---|
| Node.js | β Supported | Pool mode (usePostgreSQLTransactionManager, useMySQLTransactionManager) |
| Bun | β Supported | Same as Node.js |
| Deno | β Supported | Same as Node.js, with npm: specifiers and experimentalDecorators in deno.json |
| AWS Lambda, Vercel Functions (Node.js runtime) | β Supported | Pool mode, registered once at module scope |
| Cloudflare Workers | β Supported | Connection-per-transaction mode with Hyperdrive |
| Edge runtimes without TCP sockets (e.g. Vercel Edge) | β Not supported | The drivers need TCP connections |
Workers can't share a connection pool between requests. Use the connection-per-transaction mode and register it at module scope:
import { env } from "cloudflare:workers";
import { usePostgreSQLClientPerTransaction } from "@tranjs/pg";
usePostgreSQLClientPerTransaction(() => ({ connectionString: env.HYPERDRIVE.connectionString }));MySQL works the same way with useMySQLConnectionPerTransaction, but Hyperdrive ignores MySQL isolation levels, so this mode rejects them. See Cloudflare Workers + Hyperdrive.
- API: propagation, options, hooks, multiple datasources, errors
- Drivers: PostgreSQL, MySQL, Cloudflare Workers
- Testing: mockTransactions, withTestTransaction, Jest, Vitest
- Writing your own driver
- Examples
Issues and pull requests are welcome. To run the tests locally:
docker compose up -d
npm install
npm run build && npm testInspired by the Spring Framework.