Skip to content
asheswookPublic

About

Declarative transaction management without ORMs

Topics

Resources

Code of conduct

Stars

4 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

120 Commits

Folders and files

Repository files navigation

TranJS

Test npm license

Node.js Bun Deno Cloudflare Workers

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.

Features

  • βœ… Declarative transactions with @Transactional, or transactional for 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 / afterRollback hooks
  • 🐘 PostgreSQL and MySQL through pg, postgres (postgres.js), and mysql2, or your own driver
  • ☁️ Runs on Node.js, Bun, Deno and Cloudflare Workers
  • ⚑ TypeScript native, no runtime dependencies in the core package

Install

# 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 mysql2

Enable 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.

Without decorators

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.

Rollback rules

  • 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, use Propagation.REQUIRES_NEW (separate transaction) or Propagation.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
    }
}

Testing

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.

Supported platforms

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

Cloudflare Workers

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.

Documentation

Contributing

Issues and pull requests are welcome. To run the tests locally:

docker compose up -d
npm install
npm run build && npm test

License

LGPL-2.1

Inspired by the Spring Framework.

About

Declarative transaction management without ORMs

Topics

Resources

Code of conduct

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages