@nestjs-transactional/outbox-typeorm
v2.0.0
Published
TypeORM persistence backend for @nestjs-transactional/outbox — event_publication table, FOR UPDATE SKIP LOCKED worker safety
Downloads
246
Maintainers
Readme
@nestjs-transactional/outbox-typeorm
TypeORM storage for
@nestjs-transactional/outbox.
outbox defines the registry and the worker but ships no production
storage. This package provides it: the event_publication table, its
archive, a repository implementation, and a migration to create both.
Publication rows are written through the ambient transaction, so they
commit with your business data or not at all — that is the guarantee the
whole pattern rests on, and it comes from the transparent repository
support in
@nestjs-transactional/typeorm.
Install
pnpm add @nestjs-transactional/outbox-typeorm \
@nestjs-transactional/outbox \
@nestjs-transactional/typeorm \
@nestjs-transactional/coreModule 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
Register the two entities on your DataSource, then wire four modules
in this order:
import { Module } from '@nestjs/common';
import { TransactionalModule } from '@nestjs-transactional/core';
import { TypeOrmTransactionalModule } from '@nestjs-transactional/typeorm';
import { OutboxModule, OutboxProcessingModule } from '@nestjs-transactional/outbox';
import {
EventPublicationArchiveEntity,
EventPublicationEntity,
OutboxTypeOrmModule,
typeOrmEventPublicationRepositoryProvider,
} from '@nestjs-transactional/outbox-typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
entities: [EventPublicationEntity, EventPublicationArchiveEntity, Order],
}),
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot({ isDefault: true }),
// Auto-create the tables in development only — production runs the
// migration instead. See Schema below.
OutboxTypeOrmModule.forRoot({
schemaInitialization: { enabled: process.env.NODE_ENV !== 'production' },
}),
OutboxModule.forRoot({
// Required. Without it `outbox` keeps its in-memory default and
// nothing is persisted — see the warning below.
repository: typeOrmEventPublicationRepositoryProvider(),
republishOnStartup: true,
}),
OutboxModule.forFeature([OrderPlacedEvent]),
OutboxProcessingModule, // worker processes only
],
})
export class AppModule {}
repositoryis not optional in practice.OutboxModule.forRoot()installsInMemoryEventPublicationRepositorywhen the option is omitted, and that provider wins. The application works — publishes, handles, completes — and every publication is lost on restart, with nothing in the database and no error to notice.typeOrmEventPublicationRepositoryProvider()aliases the outbox's token to the TypeORM implementation this module registers.
Schema
Two tables. event_publication is the hot queue; event_publication_archive
is the cold audit trail used by the ARCHIVE completion mode. Four
indexes cover the worker, operator and cleanup paths — (status,
publicationDate), (status, listenerId), (eventType) and
(completionDate). status is varchar(32) rather than an enum, so a
new lifecycle state never forces a type migration.
In production, run the shipped migration:
import { CreateEventPublication1700000000000 } from '@nestjs-transactional/outbox-typeorm';
// In your DataSource config:
migrations: [CreateEventPublication1700000000000];In development, schemaInitialization: { enabled: true } creates
both tables at bootstrap instead. It is idempotent and checks for
existing tables first, but it is still schema DDL at application
startup: keep it out of production, where a migration gives you a
reviewable, ordered, reversible change.
Concurrency
tryClaim is one conditional UPDATE
(WHERE id = :id AND status IN (PUBLISHED, RESUBMITTED)) that reports
whether the row actually transitioned. That is where the safety lives,
which is why findReadyForProcessing deliberately does not lock
rows.
SELECT ... FOR UPDATE SKIP LOCKED was tried and dropped: a pessimistic
lock has to be held by a transaction wide enough to span the listener
invocation, which is unsafe when listeners are slow. Concurrent workers
may therefore fetch the same row; only one wins the claim, and the
others move on without invoking anything. The cost is a wasted SELECT
at typical worker counts
(DD-025).
Multiple dataSources
One forRoot per dataSource, mirroring the other packages:
OutboxTypeOrmModule.forRoot(), // 'default'
OutboxTypeOrmModule.forRoot({ dataSource: 'billing' }), // 'billing'Each registers its repository under a per-dataSource token; pass the
matching name to typeOrmEventPublicationRepositoryProvider('billing')
when wiring that dataSource's OutboxModule
(ADR-019).
forRootAsync is available for config resolved at runtime.
Compatibility
| Peer | Supported range |
| --- | --- |
| Node.js | >=22.13.0 |
| typeorm | ^0.3.0 \|\| ^1.0.0 |
| @nestjs/typeorm | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 |
| @nestjs/common / @nestjs/core | ^10.0.0 \|\| ^11.0.0 \|\| ^12.0.0 |
| reflect-metadata | ^0.1.13 \|\| ^0.2.0 |
| rxjs | ^7.0.0 |
CI exercises the repository against a real Postgres via testcontainers
at three points of the TypeORM range: 0.3.31, 1.0.0 and 1.1.0.
Documentation
- Getting started and full docs
- Architecture: the outbox pattern
- Outbox architecture (ADR-007)
- Runnable examples:
basic-typeorm-outbox,multi-datasource-outbox,shared-database-modular-monolith
License
MIT
