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

@nestjs-transactional/outbox

v2.0.0

Published

Persistent Event Publication Registry for @nestjs-transactional — Spring Modulith equivalent (ORM-agnostic core)

Readme

@nestjs-transactional/outbox

npm version License: MIT

The transactional outbox for NestJS — a persistent Event Publication Registry, modelled on Spring Modulith's.

In-process event handlers have one failure mode you cannot design around: if the process dies between the commit and the handler, the event is gone. This package writes one row per handler per event, in the same transaction as your business data. A worker picks the rows up afterwards, retries what fails, and resumes what a restart interrupted.

Either both happen or neither does. That is the whole point.

This package is storage-agnostic — it ships the SPI, the worker, the operator APIs and an in-memory implementation for tests, but no production backend. Add @nestjs-transactional/outbox-typeorm for that. It builds on @nestjs-transactional/core, integrates with @nestjs-transactional/cqrs, and can forward events to a broker through @nestjs-transactional/outbox-microservices.

Install

pnpm add @nestjs-transactional/outbox @nestjs-transactional/core
pnpm add @nestjs-transactional/outbox-typeorm   # a persistence backend

Module format

This package ships ESM only, matching NestJS 12, which is ESM-only across its own packages. There is no CommonJS build.

A CommonJS application still works: Node loads ESM from require() since 22.12.0, which is why engines.node is >=22.13.0. What does not follow Node here is tooling with its own module loader — Jest above all, which needs NODE_OPTIONS=--experimental-vm-modules and a few config settings. The 19 example applications in the repository all run their suites that way and can be copied from.

Reasoning and measurements: ADR-022.

Quick start

import { Module } from '@nestjs/common';
import { TransactionalModule } from '@nestjs-transactional/core';
import { OutboxModule, OutboxProcessingModule } from '@nestjs-transactional/outbox';
import { typeOrmEventPublicationRepositoryProvider } from '@nestjs-transactional/outbox-typeorm';

@Module({
  imports: [
    // Must be global: outbox providers resolve TransactionManager
    // across module boundaries.
    TransactionalModule.forRoot({ isGlobal: true }),
    TypeOrmTransactionalModule.forRoot(),
    OutboxTypeOrmModule.forRoot(),

    OutboxModule.forRoot({
      // Without this, the in-memory repository stays installed and
      // nothing is ever persisted. See the note below.
      repository: typeOrmEventPublicationRepositoryProvider(),
      republishOnStartup: true,
    }),

    // Event classes this module owns. Feature modules normally call
    // forFeature for their own events.
    OutboxModule.forFeature([OrderPlacedEvent]),

    // ONLY in worker processes. An API that merely publishes events
    // must not import this.
    OutboxProcessingModule,
  ],
})
export class AppModule {}

Pass repository. OutboxModule.forRoot() falls back to InMemoryEventPublicationRepository when the option is missing. Your application starts, publishes, and handles events perfectly — and loses every one of them on restart, with nothing in the database and no error anywhere.

Declare a handler:

@Injectable()
@OutboxEventsHandler(OrderPlacedEvent)
export class ChargeCustomer implements IOutboxEventHandler<OrderPlacedEvent> {
  async handle(event: OrderPlacedEvent) {
    // Invoked by the worker, in its own transaction. Throwing marks the
    // publication FAILED, leaving it for retry or an operator.
    await this.payments.charge(event.orderId);
  }
}

Publish from inside a transaction:

@Transactional()
async placeOrder(dto: PlaceOrderDto) {
  const order = await this.orders.save(dto);
  await this.publisher.publish(new OrderPlacedEvent(order.id));
  return order; // publication rows commit with the order, or not at all
}

Lifecycle

A publication moves through five states:

PUBLISHEDPROCESSINGCOMPLETED, or FAILEDRESUBMITTED back to PROCESSING.

The worker polls for ready rows and then claims each one with a single conditional UPDATE. That claim, not the poll, is what makes concurrent workers safe: two workers may fetch the same row, but only one wins the claim and the loser moves on without invoking the handler. So scaling out costs a wasted read, never a duplicate dispatch (DD-025).

Completed rows can be kept (UPDATE, the default), deleted, or moved to an archive table — completionMode. Kept rows need purging eventually; CompletedEventPublications.purge(olderThan) does it.

Operating it

OutboxModule.forRoot({
  repository: typeOrmEventPublicationRepositoryProvider(),
  republishOnStartup: true,
  processor: {
    pollingInterval: 1000, // end-to-end latency is dominated by this
    batchSize: 100,
    maxConcurrent: 10,
    // How long shutdown waits for an in-flight batch. Keep it under
    // your platform's grace period; 0 disables the wait.
    shutdownTimeout: 10_000,
  },
  // Flip publications stuck in a non-terminal state to FAILED. 0 = off.
  staleness: { processing: 60_000, monitorInterval: 30_000 },
  // Automatic retry, off unless you ask for it. maxAttempts counts the
  // first delivery, so 3 means the original plus two retries.
  retry: { maxAttempts: 3, baseDelay: 1_000, factor: 2, maxDelay: 300_000 },
  // Retention for COMPLETED publications, also off unless asked for.
  // 0 = off, which leaves purging to you.
  cleanup: { interval: 3_600_000, retention: 7 * 24 * 3_600_000, batchSize: 500 },
});

Shutdown is drained, not cut off: OutboxProcessingModule awaits the batch already running before NestJS tears down the DataSource, so a publication is not stranded mid-transition.

Retry is opt-in because Spring Modulith has none either — recovery is otherwise an operator action. A publication that exhausts its attempts stays FAILED; there is no separate dead-letter state, and it remains visible and resubmittable (DD-026).

Under the default UPDATE completion mode a delivered publication stays as an audit row and nothing removes it, so the table grows until someone purges it. cleanup puts that purge on a timer: each pass takes up to batchSize publications whose completionDate is older than retention and deletes them. The bound matters, because the first pass after enabling this on an existing deployment meets the table at its largest, and one unbounded DELETE there is a long transaction holding locks your writers need. Passes drain from the oldest end, so a job that cannot keep up still shrinks the backlog rather than stranding its tail.

completed.purge(olderThan) is unchanged and remains the right tool for a deliberate one-shot bulk purge.

Three injectable APIs for operators, matching Spring Modulith's:

await failed.findAll({ minAge: 60_000 }); // triage
await failed.resubmit({ maxAttempts: 5 }); // retry by hand
await incomplete.count(); // anything not COMPLETED
await completed.purge(olderThan); // retention

Listener ids

Each publication row is keyed by ${baseId}#${EventName}, where baseId defaults to the handler's class name. Renaming the class therefore orphans any stored publication for it. Pass an explicit id when that is a risk:

@OutboxEventsHandler({ events: [OrderPlacedEvent], id: 'Billing.charge' })

Testing

The /testing subpath ships an in-memory repository and fluent assertions, so outbox behaviour is testable without a database:

import {
  AssertablePublishedEvents,
  InMemoryEventPublicationRepository,
} from '@nestjs-transactional/outbox/testing';

(await assertable.contains(OrderPlacedEvent))
  .matching((e) => e.orderId, 'o-1')
  .hasSize(1);

// The negative path matters just as much: after a rollback, the
// in-memory repository has removed the publication.
await assertable.doesNotContain(OrderCancelledEvent);

Sending events to a broker

@Externalized marks an event for forwarding after its local handlers complete. The EVENT_EXTERNALIZER SPI is transport-agnostic; outbox-microservices implements it over @nestjs/microservices.

A publication is marked COMPLETED when the externalizer resolves, and what that proves depends on the transport. Kafka and RabbitMQ wait for a broker acknowledgement, so a broker that is down produces a FAILED row the retry and resubmit machinery can act on. NATS core and TCP acknowledge nothing, and gRPC cannot be used for externalization at all. The per-transport table is in that package's README and in ADR-021.

Documentation

License

MIT