npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typeorm-resilient-transactional

v0.1.2

Published

@Transactional() for NestJS + TypeORM that survives deadlocks and serialization failures — so you can actually use SERIALIZABLE in production.

Readme

typeorm-resilient-transactional

@Transactional() for NestJS + TypeORM that survives deadlocks and serialization failures — so you can actually use SERIALIZABLE in production.

npm CI coverage bundle size runtime deps license

The problem

@Transactional({ isolation: 'SERIALIZABLE' })
async transfer(from: string, to: string, amount: number) {
  const balance = await this.accounts.findOneByOrFail({ id: from });
  if (balance.amount < amount) throw new InsufficientFunds();

  await this.accounts.decrement({ id: from }, 'amount', amount);
  await this.accounts.increment({ id: to }, 'amount', amount);
}
// 💥 QueryFailedError: could not serialize access due to read/write dependencies

PostgreSQL can raise 40001 at the conflicting statement or at COMMIT, after everything has already appeared to succeed — so there is no single statement you could usefully re-issue. Under 100 concurrent workers, we measured 87% of these transactions failing. That is why teams quietly drop back to READ COMMITTED and ship write-skew bugs.

The fix

@Transactional({ isolation: 'SERIALIZABLE', retry: { maxAttempts: 5 } })
async transfer(from: string, to: string, amount: number) {
  /* unchanged */
}
// ✅ rolls back, backs off with full jitter, re-runs the whole transaction

Same workload, same concurrency: 0% failures.

Install

npm i typeorm-resilient-transactional

Peers: typeorm@^0.3.31 || ^1, and @nestjs/common@^10 || ^11 if you use the NestJS module. Zero runtime dependencies. Node ≥ 20.

Quickstart

// main.ts — before anything creates a repository
import {
  initializeResilientContext,
  addResilientDataSource,
} from 'typeorm-resilient-transactional';

initializeResilientContext();
addResilientDataSource(dataSource);
// app.module.ts
import { ResilientTransactionalModule } from 'typeorm-resilient-transactional/nestjs';

@Module({
  imports: [
    TypeOrmModule.forRoot({/* ... */}),
    ResilientTransactionalModule.forRoot({
      defaultIsolation: 'READ COMMITTED',
      retry: { maxAttempts: 3 },
      onRetry: (info) => metrics.increment('tx.retry', { code: info.sqlstate }),
    }),
  ],
})
export class AppModule {}
// ledger.service.ts
import { Transactional, runOnCommit } from 'typeorm-resilient-transactional';

class LedgerService {
  @Transactional({ isolation: 'SERIALIZABLE', retry: { maxAttempts: 5 }, timeoutMs: 5_000 })
  async post(cmd: PostEntry) {
    const id = await this.entries.insert(cmd);

    // Side effects go here — the body re-runs on retry, this does not.
    runOnCommit(() => this.events.publish(new EntryPosted(id)));

    return id;
  }
}

Repositories injected the normal way resolve to the transactional manager automatically. No TransactionHost, no threading a manager through every signature.

A runnable version is in examples/nestjs-bank-transfer.

Comparison

| | this library | typeorm-transactional | nestjs-cls + plugin | hand-rolled loop | | ---------------------------------------- | :----------: | :-------------------------: | :-------------------: | :---------------------------: | | @Transactional() with propagation | ✅ | ✅ | via TransactionHost | ❌ | | Automatic retry on 40001 / 40P01 | ✅ | ❌ | ❌ | you write it, in every method | | Runtime dependencies | 0 | 3 | 0 | 0 | | NESTED = real savepoints | ✅ | ❌ (acts as REQUIRES_NEW) | ✅ | — | | Hooks discarded on a failed attempt | ✅ | n/a | n/a | rarely | | Lock-ordering helpers | ✅ | ❌ | ❌ | ❌ | | Retry telemetry / OTel span attributes | ✅ | ❌ | ❌ | ❌ | | Repositories work unchanged | ✅ | ✅ | ❌ | ✅ |

Migrating from typeorm-transactional is one import line. The behavioural parity is asserted in CI against the real package, not claimed.

⚠️ Retry re-runs your method body

Everything the method does outside the database happens again — emails, webhooks, Stripe charges, queue publishes. Put them in runOnCommit(), which fires once, after commit, and never for an attempt that failed.

Connection errors are not retried by default. If the connection drops during COMMIT, nobody knows whether the commit landed; retrying could apply the transaction twice. Refusing to guess is a feature.

Six ways this bites and how to avoid each: docs/safety.md.

Benchmarks

100 concurrent workers, 600 contended transfers, 1,000 accounts, PostgreSQL 17 (full matrix, pnpm bench):

| Strategy | Throughput | p99 | Failure rate | | -------------------------------- | ----------: | -------: | -----------: | | SERIALIZABLE, no retry | 109 ops/s | 168 ms | 87% | | SERIALIZABLE + retry | 109.8 ops/s | 3,794 ms | 0% | | READ COMMITTED + ordered locks | 382 ops/s | 619 ms | 0% |

That first p99 is low because failing is fast. 87% of those transactions did no useful work — a latency figure measured mostly over transactions that gave up is not a number to optimise for.

Throughput vs concurrency for all three strategies, at high and low contention

Read that honestly: retry makes SERIALIZABLE usable, not fast. When you can name the rows a transaction will touch, ordered pessimistic locking is faster and degrades more gracefully — which is why lockRowsInOrder() ships here too. Use SERIALIZABLE + retry when you need a correctness property only serializability provides; use ordered locks when you can enumerate the rows. Measure your own workload.

Money conservation was asserted at every point on the matrix.

API

initializeResilientContext(): void;
addResilientDataSource(ds: DataSource | { dataSource, name?, patch? }): DataSource;
getDataSourceByName(name?: string): DataSource;

Call initializeResilientContext() before anything creates a repository — the Repository.prototype patch has to be installed first.

@Transactional(options?: TransactionOptions)

runInResilientTransaction<T>(fn: (manager) => Promise<T>, options?): Promise<T>;
wrapInResilientTransaction<F>(fn: F, options?): F;

interface TransactionOptions {
  propagation?: Propagation;          // REQUIRED (default) | REQUIRES_NEW | NESTED | SUPPORTS
                                      // | NOT_SUPPORTED | MANDATORY | NEVER
  isolation?: IsolationLevel;         // also accepts `isolationLevel`
  retry?: RetryConfig | false;
  timeoutMs?: number;                 // wall-clock across ALL attempts
  dataSourceName?: string;            // also accepts `connectionName`
  onRetry?: (info: RetryInfo) => void;
  onExhausted?: (info: RetryInfo) => void;
}

interface RetryConfig {
  enabled?: boolean;
  maxAttempts?: number;               // default 3
  retryOn?: readonly string[];        // default ['40001', '40P01', '55P03']
  backoff?: {
    strategy?: 'fixed' | 'linear' | 'exponential' | 'exponential-full-jitter'
             | ((attempt: number) => number);   // default 'exponential-full-jitter'
    baseMs?: number;                  // default 25
    capMs?: number;                   // default 500
  };
}

Retry is only valid where the call owns its transaction. Configuring it on a method that joins one throws RetryNotPermittedError rather than silently doing nothing — why.

runOnCommit(cb: () => void | Promise<void>): void;
runOnRollback(cb: (error: unknown) => void | Promise<void>): void;
runOnComplete(cb: (error: unknown) => void | Promise<void>): void;
runOnRetry(cb: (info: RetryInfo) => void): void;

Commit hooks run exactly once, after COMMIT, outside the transaction context, awaited. Hooks registered during an attempt that failed are discarded. A hook that throws is logged, not rethrown — the transaction is already durable.

runOnTransactionCommit / Rollback / Complete are aliases, for drop-in compatibility.

lockRowsInOrder(manager, Entity, ids, options?): Promise<Entity[]>;
withLockTimeout(manager, ms, fn): Promise<T>;      // bounded lock wait  → 55P03 (retryable)
withStatementTimeout(manager, ms, fn): Promise<T>; // bounded execution  → 57014 (not retried)

lockRowsInOrder sorts and deduplicates, then locks in one statement — verified to acquire in ORDER BY order across index-scan, bitmap-heap-scan, and sequential-scan plans. Pass Entity, not a table name: the primary key is resolved through TypeORM metadata.

getTransactionContext(name?): TransactionContext | undefined;
isInTransaction(name?): boolean;
currentAttempt(name?): number;                     // 1-based; 0 outside a transaction

setResilientDefaults(defaults: ResilientDefaults): void;
setDiagnosticHandler(handler): void;               // route warnings to your logger

RetryMetrics is an interface with no implementation and no dependency — implement the two or three methods your monitoring needs. If @opentelemetry/api is installed, the active span is annotated with db.transaction.attempt, db.transaction.isolation, and db.transaction.retry_reason; if it is not, that is a silent no-op.

FAQ

Does it work without NestJS? Yes. The core imports nothing from NestJS; the module lives at a separate /nestjs entry point so the root import never requires @nestjs/common.

MySQL or SQL Server? PostgreSQL is first-class and the only dialect we test. RetryableErrorMap is exported so you can supply your own codes — but we will not claim support we have not measured.

Why did my retry setting throw? It was on a method that joins a caller's transaction. Move it to the outermost @Transactional(), or use REQUIRES_NEW.

Is NESTED really different here? Yes — real savepoints, where typeorm-transactional opens an independent transaction. It is the one intentional behavioural difference, asserted in both directions in test/compat/. See ADR 0003.

Non-goals

Not an ORM, query builder, or repository abstraction. No TypeORM 0.2.x. No distributed transactions, 2PC, or sagas. No HTTP-layer idempotency keys. No MongoDB.

Documentation

| | | | -------------------------------------- | ------------------------------------------------------------- | | Safety | What retry re-executes, and the six ways it bites | | Lock ordering | The ORDER BY … FOR UPDATE experiment, with EXPLAIN output | | Internals | Exactly what we patch and why | | Migration | From typeorm-transactional | | Prior art | What we copied, improved, and rejected | | ADRs | Why each decision went the way it did |

Contributing

See CONTRIBUTING.md. Every claim in this README is backed by a test or a benchmark; please keep it that way.

License

MIT