Installation
1. Install the package and peers
npm install @nestarc/outbox @nestjs/schedule @prisma/clientThe tenant-aware emission example in step 4 uses the authenticated context from @nestarc/tenancy. Install and configure it first, or substitute an application-owned context service with an equivalent fail-closed getCurrentTenantOrThrow() contract:
npm install @nestarc/tenancyOutbox 0.3 requires Node.js >=22.0.0 with maintained Node 22/24 lanes. It supports NestJS 10/11/12, Schedule 4/5/12, and Prisma 5/6/7. Pair NestJS 12 with Schedule 12; when composing Jobs or Webhook, use their shared NestJS 10/11 range. Prisma 7 needs a matching driver adapter and generated client; see Prisma 7 Setup.
PostgreSQL LISTEN/NOTIFY wakeups use pg as an optional peer dependency. Install it when enabling the built-in notification client:
npm install pgThe Prisma 7 PostgreSQL adapter also needs pg, even when outbox wakeups are disabled:
npm install @prisma/adapter-pg@7 pgKeep prisma, @prisma/client, and @prisma/adapter-pg on matching versions. Follow Prisma 7 Setup to generate the client and construct it with the adapter.
2. Apply the database migration
The outbox_events table is managed with bundled SQL rather than a Prisma model. Startup validates columns, indexes, and constraints and fails with OutboxSchemaError / OUTBOX_SCHEMA_MISMATCH on an old or incomplete database; it does not migrate automatically.
New installation
Apply the complete current schema:
psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f "$(node -e "console.log(require.resolve('@nestarc/outbox/src/sql/create-outbox-table.sql'))")"Upgrade from 0.1.x or 0.2.x to 0.3
Stop and drain all old pollers before applying the 0.3 package's unified upgrade. Old/new pollers must not overlap because 0.2 workers do not honor leases, fenced claims, or stored due times.
psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f "$(node -e "console.log(require.resolve('@nestarc/outbox/src/sql/upgrade-to-current.sql'))")"The idempotent upgrade preserves rows and adds claim ownership, lease expiry, next_attempt_at, metadata, cursor/retention indexes, and CHECK constraints. It intentionally fails on corrupt rows such as invalid retry limits or non-object JSON. Repair or quarantine those rows before retrying. Index/constraint work can acquire locks, so plan a maintenance window. create-outbox-table.sql alone does not upgrade an existing table.
Also migrate callers:
- Import runtime/types from
@nestarc/outboxand resolve only the two SQL paths above;dist/**and component migration imports are blocked. - In
forRootAsync(), movetransport,tenantProvider, andisGlobalregistrations to the top level and import their dependency modules. Keep runtimetenancy.policyin the factory result; returningtenancy.provideris rejected. - Replace
tenantId: nullwith explicittenantScope: 'global'. Undefined tenant IDs fall back to the configured provider. - Inspect
retry()/markFailed()result.outcomeinstead of testing the result as a boolean. Callback records/contexts are readonly detached snapshots.
3. Register the module
The default delivery mode is local, which invokes registered @OnOutboxEvent() handlers:
// app.module.ts
import { Module } from '@nestjs/common';
import { OutboxModule } from '@nestarc/outbox';
@Module({
imports: [
OutboxModule.forRoot({
prisma: PrismaService,
polling: {
interval: 5000,
batchSize: 100,
},
retry: {
maxRetries: 5,
backoff: 'exponential',
initialDelay: 1000,
},
}),
],
})
export class AppModule {}Prisma provider visibility
When passing a class reference to prisma in forRoot(), that class must be provided by a @Global() module such as PrismaModule so NestJS can resolve it across module boundaries.
Async registration
Use forRootAsync() when the Prisma instance and options come from dependency injection:
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OutboxModule } from '@nestarc/outbox';
@Module({
imports: [
OutboxModule.forRootAsync({
imports: [PrismaModule, ConfigModule],
inject: [PrismaService, ConfigService],
useFactory: (prisma: PrismaService, config: ConfigService) => ({
prisma,
polling: {
interval: config.get('OUTBOX_POLL_INTERVAL', 5000),
batchSize: config.get('OUTBOX_BATCH_SIZE', 100),
},
retry: {
maxRetries: config.get('OUTBOX_MAX_RETRIES', 5),
backoff: 'exponential',
initialDelay: 1000,
},
}),
}),
],
})
export class AppModule {}Enable graceful shutdown
Enable Nest shutdown hooks in the application bootstrap so termination signals run the outbox shutdown lifecycle:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);
}
void bootstrap();Calling app.close() also runs shutdown hooks. The poller stops accepting new cycles and waits up to 30 seconds for active poll and delivery work, then logs a warning if work remains. This is a drain limit, not a timeout that cancels the handler or publisher. Allow additional process termination time for the application's other cleanup.
4. Emit an event with metadata
When this step is combined with @nestarc/tenancy, replace the basic forRoot() registration from step 3 with an async registration that passes the extended Prisma client and restores the persisted tenant around local handlers:
// outbox-tenant-context.provider.ts
import { TenancyContext } from '@nestarc/tenancy';
import type { OutboxTenantProvider } from '@nestarc/outbox';
export class OutboxTenantContextProvider implements OutboxTenantProvider {
private readonly context = new TenancyContext();
getTenantId(): string | null {
return TenancyContext.getCurrentTenantId();
}
runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return this.context.run(tenantId, fn);
}
}// app.module.ts
OutboxModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
tenantProvider: OutboxTenantContextProvider,
useFactory: (prisma: PrismaService) => ({
prisma: prisma.client,
tenancy: { policy: 'require-match' },
polling: { interval: 5000, batchSize: 100 },
retry: {
maxRetries: 5,
backoff: 'exponential',
initialDelay: 1000,
},
}),
})TenancyContext uses the package's shared AsyncLocalStorage, so the adapter reads the authenticated request context and can recreate it for a polled local delivery. Passing only tenantId metadata without this provider persists the identifier but does not restore the handler context.
Define an event class with a stable event type:
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();
}
}Write the event inside the same Prisma transaction as the business change. The optional third argument persists routing, idempotency, trace, and tenant metadata on the outbox record:
import { Injectable } from '@nestjs/common';
import { OutboxEmitter } from '@nestarc/outbox';
import { TenancyService, tenancyTransaction } from '@nestarc/tenancy';
@Injectable()
export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly outbox: OutboxEmitter,
private readonly tenancy: TenancyService,
) {}
async createOrder(dto: CreateOrderDto) {
const tenantId = this.tenancy.getCurrentTenantOrThrow();
return tenancyTransaction(this.prisma.base, this.tenancy, async (tx) => {
const order = await tx.order.create({
data: {
tenantId,
total: dto.total,
},
});
await this.outbox.emit(tx, new OrderCreatedEvent(order.id, dto.total), {
tenantId,
aggregateType: 'Order',
aggregateId: order.id,
partitionKey: order.id,
idempotencyKey: dto.requestId,
correlationId: dto.requestId,
headers: { source: 'orders-api' },
});
return order;
});
}
}Resolve tenantId from authenticated request context, not from CreateOrderDto or an arbitrary tenant header. With require-match, an explicit tenant must exactly match the provider. optional permits absence, required requires attribution, and tenantScope: 'global' explicitly opts out for an intentional global event. With OutboxTenantContextProvider registered above, that persisted identifier is restored around local handlers, so it must already be authoritative. Map accepted DTO fields into the Prisma write instead of passing the request object through wholesale.
emitMany() also accepts per-event metadata entries. Version 0.3 validates the full input before SQL and uses parameterized batches of up to 1,000 rows on the same caller-owned transaction:
await this.outbox.emitMany(tx, [
{
event: new OrderCreatedEvent(order.id, dto.total),
options: { aggregateType: 'Order', aggregateId: order.id },
},
new OrderAuditRequestedEvent(order.id),
]);5. Choose local handlers or broker publishing
Local handlers
In the default local mode, register a handler with @OnOutboxEvent(). The optional second argument exposes the stored record context:
import { Injectable, Module } from '@nestjs/common';
import { OnOutboxEvent, OutboxHandlerContext } from '@nestarc/outbox';
import { EmailModule, EmailService } from './email.module';
@Injectable()
export class OrderNotificationListener {
constructor(private readonly emailService: EmailService) {}
@OnOutboxEvent(OrderCreatedEvent)
async handle(
payload: { orderId: string; total: number },
context: OutboxHandlerContext,
) {
await this.emailService.sendOrderConfirmation(payload.orderId, {
idempotencyKey: context.eventId,
});
}
}
@Module({
imports: [EmailModule],
providers: [OrderNotificationListener],
})
export class OrderEventsModule {}EmailModule and EmailService are application-owned. The module must export EmailService, and the listener must be registered as a Nest provider so the outbox explorer can discover its decorator.
An event type without a registered local handler is marked FAILED with an explanatory last_error instead of being silently marked SENT.
Broker publisher
Use publisher mode for a broker transport that does not need local handlers:
import { Injectable } from '@nestjs/common';
import { OutboxPublisher, OutboxRecord } from '@nestarc/outbox';
@Injectable()
export class KafkaPublisher implements OutboxPublisher {
constructor(private readonly kafka: KafkaProducer) {}
async publish(record: OutboxRecord): Promise<void> {
await this.kafka.send({
topic: record.eventType,
messages: [
{
key: record.partitionKey ?? record.aggregateId ?? record.id,
value: JSON.stringify({
id: record.id,
eventType: record.eventType,
payload: record.payload,
tenantId: record.tenantId,
aggregateType: record.aggregateType,
aggregateId: record.aggregateId,
partitionKey: record.partitionKey,
headers: record.headers,
idempotencyKey: record.idempotencyKey,
correlationId: record.correlationId,
causationId: record.causationId,
occurredAt: record.occurredAt,
}),
headers: {
...record.headers,
'outbox-event-id': record.id,
...(record.idempotencyKey
? { 'idempotency-key': record.idempotencyKey }
: {}),
},
},
],
});
}
}Register the module asynchronously so the module that owns the broker client is visible to the outbox injector. KafkaModule must export KafkaProducer; PrismaModule must export PrismaService.
@Module({
imports: [
OutboxModule.forRootAsync({
imports: [KafkaModule, PrismaModule],
inject: [PrismaService],
transport: KafkaPublisher,
useFactory: (prisma: PrismaService) => ({
prisma,
delivery: { mode: 'publisher' },
}),
}),
],
})
export class EventsModule {}Passing KafkaPublisher to forRoot() without importing an exporting broker module does not make KafkaProducer injectable. A global broker module also works, but explicit async imports keep the dependency boundary visible.
Legacy transports implementing dispatch(record, handlers) remain supported. In publisher mode they receive an empty handler array, so broker transports must not depend on local handler registration.
At-least-once delivery
The outbox can publish a duplicate if the process stops after a broker acknowledgement but before the row is marked SENT. Preserve the outbox event id and optional idempotencyKey in the broker message as shown above, then deduplicate on one of those stable values before applying a side effect.
6. Operate failed events with the admin API
OutboxOperatorService is a privileged global provider; OutboxAdminService remains a deprecated alias. Tenant-facing code must authorize the caller and bind trusted tenant context with OutboxTenantAdminService.forTenant():
import { OutboxTenantAdminService } from '@nestarc/outbox';
// callerTenantId has already been authorized by the application's guard/policy.
const admin = app.get(OutboxTenantAdminService).forTenant(callerTenantId);
const page = await admin.listPage({ status: 'FAILED', limit: 100 });
if (page.records.length > 0) {
const result = await admin.retry(page.records[0].id);
if (result.outcome !== 'applied') {
// Handle not_found, conflict, or lost_claim in the operator UI.
}
}
const stats = await admin.getStats();listPage() uses (created_at DESC, id DESC) with an exclusive opaque nextCursor. Keep filters stable between pages. Malformed cursors produce OUTBOX_INVALID_CURSOR; date-only list() filters remain compatible but are not continuation tokens.
In published 0.3.0, cursor serialization rounds PostgreSQL timestamps to JavaScript millisecond precision. Rows sharing a sub-millisecond timestamp can be skipped across page boundaries. Do not use 0.3.0 pagination as an exhaustive export; use an application-owned database query that preserves (created_at, id) precision until a corrected version is published.
retry() moves only FAILED to PENDING, preserves retry_count, clears error/completion fields, and sets next_attempt_at to PostgreSQL's current time. markFailed() accepts only PENDING. No admin mutation overwrites a PROCESSING claim. Single-record mutations return applied, not_found, conflict, or lost_claim; cross-tenant IDs are not_found. purgeSent() only removes eligible SENT rows.
7. Enable PostgreSQL LISTEN/NOTIFY wakeups
Keep periodic polling enabled for reliable delivery in 0.3.0. A notification triggers a bounded cycle; it does not schedule future retries, exhaust a larger backlog, or replay notifications missed while the listener was disconnected. Startup backlog and expired-lease recovery also need subsequent cycles.
Wakeup mode is an optional latency optimization: emit() calls pg_notify() inside the business transaction, PostgreSQL delivers the notification after commit, and OutboxListener requests an early poll.
OutboxModule.forRoot({
prisma: PrismaService,
polling: { interval: 5000 },
wakeup: {
enabled: true,
channel: 'outbox_events',
connectionString: process.env.DATABASE_URL,
},
})When polling is enabled, listener connection/LISTEN failures or unavailable pg degrade to polling; reconnect uses capped exponential backoff. In 0.3.0, disabling polling without a usable wakeup path fails startup with OUTBOX_WAKEUP_UNAVAILABLE; startup can succeed when the listener connects, but the delivery gaps above remain. Leave polling.enabled at its true default. Concurrent timer, notification, and manual triggers coalesce into at most one queued rerun, which shutdown drops while waiting for the active poll. Advanced integrations can provide wakeup.clientFactory instead of using the built-in pg client.
Module options
| Option | Type | Default | Description |
|---|---|---|---|
prisma | class ref / instance | required | PrismaService class reference for forRoot() or resolved PrismaLike instance for forRootAsync(). |
polling.enabled | boolean | true | Keep enabled for backlog, scheduled retries, and recovery. |
polling.interval | number | 5000 | Milliseconds between fallback polling cycles. |
polling.batchSize | number | 100 | Maximum records processed per polling cycle. |
retry.maxRetries | number | 5 | Delivery attempts allowed before a record becomes FAILED. |
retry.backoff | 'fixed' | 'exponential' | 'exponential' | Backoff strategy between attempts. |
retry.initialDelay | number | 1000 | Base or fixed retry delay in milliseconds. |
delivery.mode | 'local' | 'publisher' | 'local' | Require decorated local handlers or publish records to a broker transport. |
transport | Type<OutboxTransport | OutboxPublisher> | LocalTransport | Delivery provider class. |
tenancy.provider | provider / provider class | none | Sync forRoot() only; for async registration use top-level tenantProvider. Resolves and restores tenant context. |
hooks | OutboxHooks | none | Observe emit, poll, dispatch, retry, and dead-letter lifecycle events; hook errors are isolated. |
wakeup.enabled | boolean | false | Enable PostgreSQL notification wakeups alongside polling. |
wakeup.channel | string | 'outbox_events' | PostgreSQL notification channel. |
wakeup.connectionString | string | pg default | Connection string for the built-in notification client. |
wakeup.clientFactory | function | built-in pg client | Supply a custom OutboxNotificationClient. |
isGlobal | boolean | true | Register the module globally. |
stuckThreshold | number | 300000 | Deprecated alias for lease.duration; recovery now follows lease expiry. |
See the generated API reference for complete option and method signatures.
New runtime controls
| Option | Default | Contract |
|---|---|---|
retry.maxDelay | 86400000 | Bounds the persisted retry delay; at most 2147483647 ms |
lease.duration | 300000 | Renewable claim lifetime in milliseconds |
lease.heartbeatInterval | duration / 3 | Positive and less than half the lease duration |
lease.heartbeatFailureTolerance | 1 | Heartbeat errors tolerated before abandoning completion |
tenancy.policy | optional | optional, required, or require-match |
Both sync/async paths validate polling, retry, lease, delivery, and several registration settings with OUTBOX_INVALID_CONFIGURATION. In published 0.3.0 this is not exhaustive: an invalid tenancy.policy, for example, reaches emit-time validation and throws OUTBOX_INVALID_ENVELOPE. Use the documented values and typed options. Poller/admin reads reject corrupt persisted records with OUTBOX_PERSISTED_INVARIANT_VIOLATION. Hook snapshots cannot change delivery state; onEmit observes a staged write before transaction commit and is not a durable audit fact.