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

@nestarc/outbox

v0.4.0

Published

Transactional outbox for NestJS, Prisma, and PostgreSQL with atomic event writes, local handlers, pluggable broker publishing, retries, and tenant-scoped administration.

Readme

@nestarc/outbox — PostgreSQL transactional outbox for NestJS and Prisma

Store a domain event in the same PostgreSQL transaction as your business data, then deliver it to NestJS handlers or an application-owned message broker publisher. Includes retries, renewable claim leases, tenant metadata, and APIs for inspecting and retrying failed events.

npm version license

Documentation version: 0.4.0. For the 0.3.0 package, read the v0.3.0 README. See the 0.4.0 upgrade notes before using this version in an existing application.

Contents

Installation

Use an existing NestJS application with Prisma, Node 22 or 24, and PostgreSQL. Other Prisma database providers are not supported. PostgreSQL 16 is the automated test baseline; a lower supported minimum has not been verified. Match NestJS, Schedule, and Prisma versions using the compatibility table.

Install with the Schedule major that matches your NestJS application; run one command:

# NestJS 10
npm install @nestarc/outbox @nestjs/schedule@4
# NestJS 11
npm install @nestarc/outbox @nestjs/schedule@5
# NestJS 12
npm install @nestarc/outbox @nestjs/schedule@12

These commands install the published npm release and preserve your existing Prisma client. Before 0.4.0 is published, use the local-tarball instructions in the complete quick-start application to run this checkout.

NestJS core/common, Schedule, Prisma Client, and reflect-metadata are peer dependencies; NestJS core/common and reflect-metadata normally already exist in the application. Keep the Prisma CLI and @prisma/client on matching versions from major 5, 6, or 7. If Prisma is not configured yet, start from the complete quick-start application linked above.

For Prisma 7, also install its PostgreSQL adapter and driver:

npm install @prisma/adapter-pg@7 pg

Construct your application-owned Prisma client with PrismaPg; see Prisma 7 setup. pg is also used by the optional default LISTEN/NOTIFY client. Prisma 5/6 applications using their native engine do not need pg for ordinary polling. Outbox uses your configured client and does not replace its connection pool.

SQL Migration

Apply the SQL before starting the Nest application. The outbox table uses raw PostgreSQL SQL; do not add an OutboxEvent model to schema.prisma.

For a fresh installation, set DATABASE_URL to the intended database and run:

# Print the path to the bundled SQL file
node -e "console.log(require.resolve('@nestarc/outbox/src/sql/create-outbox-table.sql'))"

# Apply with psql
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$(node -e "console.log(require.resolve('@nestarc/outbox/src/sql/create-outbox-table.sql'))")"

The shipped file creates the table, indexes, and constraints and can be reapplied to the current schema. See the versioned fresh SQL source.

For an existing 0.1.x/0.2.x database, use the upgrade procedure instead. Startup validates the schema and fails with OutboxSchemaError (OUTBOX_SCHEMA_MISMATCH) when required objects are missing; it never migrates your database automatically.

Quick Start

The snippets show the application wiring. The complete quick-start application supplies all imports, Prisma models, service implementations, startup code, and a transaction-based consumer deduplication example.

The defaults enable polling every 5 seconds, process up to 100 events per cycle, and allow 5 delivery attempts. Configure only the values your application needs to change.

1. Register the module

import { OutboxModule } from '@nestarc/outbox';

@Module({
  imports: [
    PrismaModule,
    OutboxModule.forRoot({
      prisma: PrismaService,
    }),
  ],
  providers: [OrderService, OrderNotificationListener, EmailService],
})
export class AppModule {}

When passing a class reference to prisma in forRoot(), the class must be provided by a @Global() module (e.g. PrismaModule) so NestJS can resolve it across module boundaries.

2. Define an event class

import { OutboxEvent } from '@nestarc/outbox';

export class OrderCreatedEvent extends OutboxEvent {
  static readonly eventType = 'order.created';

  constructor(
    public readonly orderId: string,
    public readonly total: number,
  ) {
    super();
  }
}

3. Emit inside a transaction

import { OutboxEmitter } from '@nestarc/outbox';

@Injectable()
export class OrderService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly outbox: OutboxEmitter,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    return this.prisma.$transaction(async (tx) => {
      const order = await tx.order.create({ data: dto });
      await this.outbox.emit(tx, new OrderCreatedEvent(order.id, dto.total), {
        tenantId: dto.tenantId,
        aggregateType: 'Order',
        aggregateId: order.id,
        partitionKey: order.id,
        idempotencyKey: dto.requestId,
        correlationId: dto.requestId,
        headers: { source: 'orders-api' },
      });
      return order;
    });
  }
}

emit() stages the event using the caller's transaction client. Pass the same tx used for the business write; the business row and event then commit or roll back together.

The third argument is optional. Use it when downstream consumers need stable metadata for broker routing, idempotency, tracing, replay, or tenant-aware operations.

4. Handle the event

import { OnOutboxEvent, OutboxHandlerContext } from '@nestarc/outbox';

@Injectable()
export class OrderNotificationListener {
  constructor(private readonly emailService: EmailService) {}

  @OnOutboxEvent(OrderCreatedEvent)
  async handleOrderCreated(
    payload: { orderId: string; total: number },
    context: OutboxHandlerContext,
  ) {
    await this.emailService.sendOrderConfirmation(payload.orderId, {
      idempotencyKey: context.eventId,
    });
  }
}

Your application-owned EmailService and email provider must durably enforce this idempotency key. Passing the key alone does not make Outbox deduplicate email delivery; the provider must make repeated attempts safe.

If an event type has no registered handlers, the event is marked FAILED with an explanatory last_error to prevent silent data loss. Check your handler registrations if you see unexpected FAILED events.

Verify delivery

After creating an order, wait for a polling cycle and inspect the newest events:

SELECT id, event_type, status, retry_count, last_error
FROM outbox_events
ORDER BY created_at DESC, id DESC
LIMIT 10;

A successful local delivery changes the row from PENDING through PROCESSING to SENT. The confirmation handler should have run. A rolled-back order transaction must leave no outbox row. FAILED with a missing-handler error means the listener provider was not registered.

Enable shutdown handling in your application bootstrap:

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

The complete example includes these imports and bootstrap. The poller allows up to a fixed 30 seconds to drain; this does not forcibly cancel callbacks or external side effects. See shutdown behavior.

Delivery Contract and Duplicate Handling

Polling is the durable source of truth for both delivery modes. Local handlers and publisher callbacks are delivery attempts inside that polling loop, so all three paths have at-least-once semantics.

| Contract | Meaning | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Transactional persistence | When emit() uses the same transaction as the business write, both commit or both roll back. | | Claim isolation | SKIP LOCKED, claim tokens, and renewable leases prevent a healthy active claim from being completed by another poller. They do not make external side effects exactly once. | | Local SENT | Every registered local handler returned successfully and the fenced SENT update was stored. An earlier handler may already have produced a side effect before a later handler failed. | | Publisher SENT | The publisher callback resolved and the fenced SENT update was stored. It does not mean that a downstream broker consumer or Jobs handler completed. | | idempotency_key | Application metadata carried with the record. The package does not enforce uniqueness or deduplicate producers or consumers with it. | | partition_key | Routing metadata for a custom publisher. It does not serialize claims or provide partition, aggregate, or global FIFO. | | Ordering | Strict global, aggregate, and partition FIFO are not guaranteed. Retries, multiple pollers, and callback duration can change observation order. |

A callback can succeed before the process stops without recording SENT; retries then repeat its effects. Multiple local handlers also restart from the first handler after a later handler fails. Use a durable consumer key, normally context.eventId/record.id, and couple deduplication with the consumer side effect atomically. See duplicate windows and the consumer contract.

Usage and API reference

| Task | Reference | | -------------------------------------------------------- | ------------------------------------------------------------ | | Configure polling, retries, leases, hooks, and providers | Configuration options | | Emit metadata or batches | Event metadata and bulk emit | | Inspect failures, page through events, retry, or purge | Admin and DLQ API | | Select tenant attribution rules | Tenant policies | | Interpret hooks and transaction timing | Observability hooks | | Operate retries, reconnect, leases, and shutdown | Usage and operational reference | | Run complete application code | Quick-start project |

The reference belongs to this checkout and is included in the package. Prefer the documentation shipped with the installed version over historical design plans.

AI agent usage

Start with the packaged llms.txt for the documentation reading order, then use the complete quick-start project and API reference. Check the installed package version before copying an API or migration command: this documentation describes 0.4.0, while the 0.3.0 contract has its own versioned README.

The quick-start project supplies real imports and application services. The packed example tests check the README fragments against an installed artifact; historical plans and reports are background records, not instructions for package consumers.

Configuration

Use runtime values in forRoot() or the async factory. With forRootAsync(), register transport, tenantProvider, and isGlobal at the top level. The full option reference separates these paths.

Async registration

For dynamic configuration (e.g. reading from ConfigService), this publisher example imports modules that export PrismaService, ConfigService, TenantContext, and KafkaProducer, respectively. RequestTenantProvider is defined in Tenancy; KafkaTransport is defined in Custom Transport:

OutboxModule.forRootAsync({
  imports: [PrismaModule, ConfigModule, TenantContextModule, KafkaModule],
  useFactory: (config: ConfigService, prisma: PrismaService) => ({
    prisma,
    polling: { interval: config.get('OUTBOX_POLL_INTERVAL') },
    delivery: { mode: 'publisher' },
    tenancy: { policy: 'required' },
  }),
  inject: [ConfigService, PrismaService],
  tenantProvider: RequestTenantProvider,
  transport: KafkaTransport,
  isGlobal: true,
});

useFactory and OutboxOptionsFactory own runtime values only. Provider graph registrations (transport, tenantProvider) and module scope (isGlobal) are top-level forRootAsync options; returning any of them from the factory is rejected during module compilation instead of being silently ignored. Nest constructs top-level provider classes, so their constructor dependencies must be exported by one of the modules listed in imports. Passing an already created tenant provider value is also supported.

Tenancy

Tenancy integration is optional and has no hard dependency on @nestarc/tenancy.

A provider can restore trusted ambient context with AsyncLocalStorage. Your request/authentication layer enters the validated tenant context; local delivery uses runWithTenant() to restore the persisted tenant around each handler:

import { AsyncLocalStorage } from 'node:async_hooks';
import { Injectable } from '@nestjs/common';
import type { OutboxTenantProvider } from '@nestarc/outbox';

@Injectable()
export class TenantContext {
  readonly storage = new AsyncLocalStorage<string>();
}

@Injectable()
export class RequestTenantProvider implements OutboxTenantProvider {
  constructor(private readonly context: TenantContext) {}

  getTenantId(): string | undefined {
    return this.context.storage.getStore();
  }

  runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
    return this.context.storage.run(tenantId, fn);
  }
}

export { RequestTenantProvider as TenantContextProvider };

Export TenantContext from TenantContextModule and include that module in forRootAsync.imports. With synchronous forRoot below, both the Prisma service and injected TenantContext must instead be exported by global modules imported by the application:

OutboxModule.forRoot({
  prisma: PrismaService,
  tenancy: {
    provider: TenantContextProvider,
    policy: 'require-match',
  },
});

Choose optional, required, or require-match explicitly for your application. tenantId: null is rejected; an omitted/undefined tenant falls back to the provider. Use tenantScope: 'global' for intentional global events. See tenant policy behavior.

Custom Transport

The default delivery.mode is local: the poller looks up registered @OnOutboxEvent() handlers and invokes them through LocalTransport. In local mode, an event type with no registered handlers is marked FAILED to prevent silent data loss.

For broker-style delivery, set delivery.mode to publisher and provide a transport that implements OutboxPublisher. Publisher mode does not require local handlers:

import { OutboxPublisher, OutboxRecord } from '@nestarc/outbox';

@Injectable()
export class KafkaTransport implements OutboxPublisher {
  constructor(private readonly kafka: KafkaProducer) {}

  async publish(record: OutboxRecord): Promise<void> {
    const headers = Object.fromEntries(
      Object.entries(record.headers).filter(
        ([key]) => !key.toLowerCase().startsWith('outbox-'),
      ),
    );

    await this.kafka.send({
      topic: record.eventType,
      messages: [
        {
          key: record.partitionKey ?? record.aggregateId ?? record.id,
          value: JSON.stringify(record.payload),
          headers: {
            ...headers,
            'outbox-event-id': record.id,
            'outbox-event-type': record.eventType,
            'outbox-occurred-at': record.occurredAt.toISOString(),
            ...(record.tenantId === null
              ? {}
              : { 'outbox-tenant-id': record.tenantId }),
            ...(record.aggregateType === null
              ? {}
              : { 'outbox-aggregate-type': record.aggregateType }),
            ...(record.aggregateId === null
              ? {}
              : { 'outbox-aggregate-id': record.aggregateId }),
            ...(record.partitionKey === null
              ? {}
              : { 'outbox-partition-key': record.partitionKey }),
            ...(record.correlationId === null
              ? {}
              : { 'outbox-correlation-id': record.correlationId }),
            ...(record.causationId === null
              ? {}
              : { 'outbox-causation-id': record.causationId }),
            ...(record.idempotencyKey === null
              ? {}
              : { 'outbox-idempotency-key': record.idempotencyKey }),
          },
        },
      ],
    });
  }
}

The outbox-* header names are an application convention in this example. They preserve the durable event identity, tenant, and tracing/deduplication metadata. Custom headers in this reserved namespace are dropped regardless of case; canonical null fields stay absent. Downstream consumers still own authorization and durable deduplication.

Here KafkaProducer is an application-owned adapter. Register and export it from KafkaModule; the async registration example makes that dependency visible to KafkaTransport. The packed example test uses a recording producer double, so it verifies DI and message mapping, not a real Kafka connection or broker delivery guarantees.

For synchronous registration below, KafkaProducer and PrismaService must be exported by global modules imported by the application (transport stays top-level when using forRootAsync):

OutboxModule.forRoot({
  prisma: PrismaService,
  delivery: { mode: 'publisher' },
  transport: KafkaTransport,
});

Legacy custom transports that implement dispatch(record, handlers) can also run in publisher mode. In that case the poller calls dispatch(record, []), so broker transports should not depend on local handlers.

PostgreSQL LISTEN/NOTIFY Wakeup

Periodic polling remains required. PostgreSQL LISTEN/NOTIFY optionally reduces latency: pg_notify() runs in the event transaction, and a notification after commit triggers the same polling loop. It does not replace durable retry or lease recovery.

Install pg if your application does not already use it, then configure:

OutboxModule.forRoot({
  prisma: PrismaService,
  polling: { interval: 5000 },
  wakeup: {
    enabled: true,
    channel: 'outbox_events',
    connectionString: process.env.DATABASE_URL,
  },
});

Connection or LISTEN failures degrade to periodic polling while reconnection retries in the background. polling.enabled: false is rejected with OutboxConfigurationError (OUTBOX_INVALID_CONFIGURATION), even with wakeup enabled. See wakeup lifecycle and reconnect.

Compatibility evidence

Node 22 is the minimum supported runtime. Node 22 and 24 are required controls; Node 20 reached upstream EOL and is not supported by 0.3.0 or later. The following exact tuples are checked as packed packages and source tests. They are regression controls for the declared peer ranges, not proof of every possible version combination:

| Node | NestJS | Schedule | Prisma | Automated evidence | | ----- | ------- | -------- | ------ | ---------------------------------------------------------------------------------- | | 22 | 10.4.22 | 4.1.2 | 5.22.0 | generate, strict typecheck/build, SQL asset load, PostgreSQL emit/poll/admin smoke | | 22 | 10.4.22 | 4.1.2 | 6.19.3 | source E2E plus the same strict legacy packed PostgreSQL consumer | | 22/24 | 11.2.3 | 5.0.1 | 7.10.0 | source E2E; Node 22 also runs the strict packed PostgreSQL consumer | | 22/24 | 12.0.1 | 12.0.1 | 7.10.0 | source E2E plus strict packed PostgreSQL consumer on both required Node controls |

All three Prisma majors consume the same package root declarations and shipped src/sql assets. Node 26 is pre-LTS and runs only as an allowed-failure canary; passing that canary does not make it supported. Compatibility outside the declared peer ranges is not implied.

Upgrading to 0.3.0

0.3.0 is a pre-1.0 minor release with required schema and public API changes. Before deployment:

  1. Move to Node 22 or 24 and a supported NestJS/Schedule/Prisma tuple from the compatibility table.
  2. Stop and drain every old poller, then apply the bundled unified SQL upgrade using the 0.3.0 package. Schedule a maintenance window for index and constraint changes; repair any invalid existing rows reported by the migration before starting new pollers. Do not run 0.2.x pollers alongside 0.3.0: old workers do not honor claim tokens, leases, or persisted retry due times.
  3. Import runtime values and types from @nestarc/outbox. Replace deep imports into dist/** or individual migration files with the root or the two supported SQL paths.
  4. For forRootAsync(), move factory-returned transport, tenancy.provider, and isGlobal registrations to top-level transport, tenantProvider, and isGlobal. Import the modules that export their injected dependencies; keep runtime settings such as tenancy.policy inside the factory result.
  5. Replace tenantId: null with tenantScope: 'global' for intentional global events. An undefined tenant now falls back to the provider. Choose tenancy.policy explicitly when tenant attribution is required.
  6. Update retry() and markFailed() callers to inspect the returned OutboxAdminMutationResult.outcome instead of treating the result as a boolean. Handle applied, not_found, conflict, and lost_claim; markFailed() now accepts only PENDING and cannot cancel active delivery. Use OutboxOperatorService only in privileged code, or a fixed tenant scope from OutboxTenantAdminService for tenant-facing operations.
  7. Treat records and callback/hook contexts as readonly detached snapshots. Review producer JSON/envelope and runtime option validation: invalid input now fails before SQL or startup instead of reaching delivery callbacks.

Retry eligibility is persisted in next_attempt_at; different worker backoff settings no longer reschedule already-failed rows. stuckThreshold remains a deprecated alias for lease.duration. Delivery remains at-least-once: consumers must be idempotent, and neither FIFO nor downstream completion is implied by SENT. Hooks remain best-effort observations, including onEmit before the caller's transaction commits.

After draining old pollers, apply the shipped upgrade:

psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$(node -e "console.log(require.resolve('@nestarc/outbox/src/sql/upgrade-to-current.sql'))")"

The upgrade validates existing rows and may acquire locks for index replacement and constraints. Repair invalid rows before retrying it and plan a maintenance window for large tables. See schema diagnostics and upgrade details and the versioned SQL source.

Upgrading to 0.4.0

0.4.0 is a pre-1.0 minor release with configuration and cursor compatibility changes:

  • Keep periodic polling enabled. The previous notification-only configuration (polling.enabled: false) is now rejected at module initialization with OutboxConfigurationError. Notifications remain an optional latency improvement.
  • Discard previously saved listPage() v1 cursors and restart from the first page. Cursor v2 preserves PostgreSQL timestamp microseconds; v1 input returns OUTBOX_INVALID_CURSOR.
  • Correct invalid hook, tenant-provider, and notification settings before startup; 0.4.0 validates their object and callback shapes explicitly.

The required database schema remains 0.3.0. An existing current 0.3.0 schema needs no additional SQL migration. Applications upgrading from 0.1.x/0.2.x must also follow the 0.3.0 upgrade procedure.

Supported package paths

Import runtime values and types from @nestarc/outbox. The supported SQL subpaths are:

  • @nestarc/outbox/src/sql/create-outbox-table.sql
  • @nestarc/outbox/src/sql/upgrade-to-current.sql

Resolve the fresh SQL for a new database and the unified upgrade for a supported existing schema. Compiled dist/** internals and component/historical SQL files are not public imports. Package-local Markdown guides are documentation assets, not JavaScript import subpaths.

Executable example checks

The marked TypeScript examples in this README are extracted from the installed tarball README by npm run test:packed-examples. A strict isolated consumer compiles them with skipLibCheck: false, initializes the real Nest module graph, and exercises transactions and delivery against PostgreSQL 16 with and without optional pg.

The fixture supplies application-owned Prisma models, imports, configuration, and email/broker doubles. SQL shell fragments are checked for their package paths and the resolved SQL is executed with Prisma CLI; the literal psql commands are not executed by this fixture. Broker/email doubles do not prove external delivery guarantees.

See the fixture and run instructions and the complete quick-start application.

Ecosystem

Related packages can be integrated through your application's tenant provider and consumer logic:

Outbox does not automatically wire these packages or provide durable consumer deduplication through metadata alone.

License

MIT — see LICENSE for details.