@nestjs-transactional/outbox 1.0.0-alpha.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -332
- package/dist/api/failed-event-publications.js.map +1 -1
- package/dist/api/incomplete-event-publications.js +1 -2
- package/dist/api/incomplete-event-publications.js.map +1 -1
- package/dist/decorators/outbox-events-handler.decorator.d.ts +1 -1
- package/dist/decorators/outbox-events-handler.decorator.js.map +1 -1
- package/dist/dispatcher/data-source-outbox-publisher.d.ts +2 -3
- package/dist/dispatcher/data-source-outbox-publisher.js +2 -3
- package/dist/dispatcher/data-source-outbox-publisher.js.map +1 -1
- package/dist/dispatcher/event-publication-processor.d.ts +19 -2
- package/dist/dispatcher/event-publication-processor.js +40 -3
- package/dist/dispatcher/event-publication-processor.js.map +1 -1
- package/dist/dispatcher/outbox-event-publisher.d.ts +1 -1
- package/dist/dispatcher/outbox-event-publisher.js +2 -2
- package/dist/dispatcher/outbox-event-publisher.js.map +1 -1
- package/dist/dispatcher/processor-options.d.ts +13 -1
- package/dist/dispatcher/processor-options.js +4 -1
- package/dist/dispatcher/processor-options.js.map +1 -1
- package/dist/externalization/event-externalizer.d.ts +2 -2
- package/dist/externalization/externalization-registry.js.map +1 -1
- package/dist/externalization/externalized.decorator.d.ts +3 -4
- package/dist/externalization/externalized.decorator.js +2 -3
- package/dist/externalization/externalized.decorator.js.map +1 -1
- package/dist/externalization/types.d.ts +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/module/outbox-processing.module.d.ts +18 -2
- package/dist/module/outbox-processing.module.js +28 -8
- package/dist/module/outbox-processing.module.js.map +1 -1
- package/dist/module/outbox.module.d.ts +8 -0
- package/dist/module/outbox.module.js +61 -7
- package/dist/module/outbox.module.js.map +1 -1
- package/dist/recovery/outbox-retry-scheduler.d.ts +68 -0
- package/dist/recovery/outbox-retry-scheduler.js +163 -0
- package/dist/recovery/outbox-retry-scheduler.js.map +1 -0
- package/dist/recovery/staleness-monitor.d.ts +10 -2
- package/dist/recovery/staleness-monitor.js +28 -3
- package/dist/recovery/staleness-monitor.js.map +1 -1
- package/dist/registry/event-publication-registry.d.ts +1 -1
- package/dist/registry/event-publication-registry.js +1 -1
- package/dist/registry/event-publication-registry.js.map +1 -1
- package/dist/registry/listener-registry.d.ts +2 -2
- package/dist/registry/listener-registry.js +2 -2
- package/dist/registry/multi-ds-listener-registrar.d.ts +10 -4
- package/dist/registry/multi-ds-listener-registrar.js +1 -1
- package/dist/registry/multi-ds-listener-registrar.js.map +1 -1
- package/dist/registry/outbox-listener-scanner.d.ts +1 -1
- package/dist/registry/outbox-listener-scanner.js +1 -1
- package/dist/registry/outbox-listener-scanner.js.map +1 -1
- package/dist/repository/event-publication-repository.d.ts +25 -5
- package/dist/repository/event-publication-repository.js.map +1 -1
- package/dist/serialization/event-type-resolver.d.ts +1 -1
- package/dist/serialization/event-type-resolver.js +1 -1
- package/dist/serialization/json-event-serializer.js.map +1 -1
- package/dist/shutdown/drain.d.ts +32 -0
- package/dist/shutdown/drain.js +56 -0
- package/dist/shutdown/drain.js.map +1 -0
- package/dist/testing/in-memory-repository.js +1 -2
- package/dist/testing/in-memory-repository.js.map +1 -1
- package/dist/testing/published-events.js +4 -1
- package/dist/testing/published-events.js.map +1 -1
- package/dist/tokens/token-utils.js.map +1 -1
- package/dist/types/resubmission-options.d.ts +0 -4
- package/dist/types/resubmission-options.js +0 -9
- package/dist/types/resubmission-options.js.map +1 -1
- package/dist/types/retry-config.d.ts +51 -0
- package/dist/types/retry-config.js +16 -0
- package/dist/types/retry-config.js.map +1 -0
- package/dist/types/staleness-config.d.ts +8 -0
- package/dist/types/staleness-config.js +2 -0
- package/dist/types/staleness-config.js.map +1 -1
- package/package.json +17 -6
package/README.md
CHANGED
|
@@ -1,395 +1,215 @@
|
|
|
1
1
|
# @nestjs-transactional/outbox
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@nestjs-transactional/outbox)
|
|
4
4
|
[](https://github.com/igorgolovanov/nestjs-transactional/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`@nestjs-transactional` family of packages.
|
|
6
|
+
The transactional outbox for NestJS — a persistent Event Publication
|
|
7
|
+
Registry, modelled on Spring Modulith's.
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
In-process event handlers have one failure mode you cannot design
|
|
10
|
+
around: if the process dies between the commit and the handler, the
|
|
11
|
+
event is gone. This package writes one row per handler per event, in the
|
|
12
|
+
same transaction as your business data. A worker picks the rows up
|
|
13
|
+
afterwards, retries what fails, and resumes what a restart interrupted.
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
phase-based dispatching (like Spring Framework core): handlers fire
|
|
14
|
-
`AFTER_COMMIT`, `BEFORE_COMMIT`, `AFTER_ROLLBACK`, or `AFTER_COMPLETION`.
|
|
15
|
-
That covers a lot, but it is purely in-memory — if the process dies
|
|
16
|
-
between commit and handler invocation, the event is lost.
|
|
15
|
+
Either both happen or neither does. That is the whole point.
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
This package is storage-agnostic — it ships the SPI, the worker, the
|
|
18
|
+
operator APIs and an in-memory implementation for tests, but no
|
|
19
|
+
production backend. Add
|
|
20
|
+
[`@nestjs-transactional/outbox-typeorm`](https://www.npmjs.com/package/@nestjs-transactional/outbox-typeorm)
|
|
21
|
+
for that. It builds on
|
|
22
|
+
[`@nestjs-transactional/core`](https://www.npmjs.com/package/@nestjs-transactional/core),
|
|
23
|
+
integrates with
|
|
24
|
+
[`@nestjs-transactional/cqrs`](https://www.npmjs.com/package/@nestjs-transactional/cqrs),
|
|
25
|
+
and can forward events to a broker through
|
|
26
|
+
[`@nestjs-transactional/outbox-microservices`](https://www.npmjs.com/package/@nestjs-transactional/outbox-microservices).
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
invocation is logged atomically with the business transaction.
|
|
22
|
-
- **Retry on process restart** — publications that were not acknowledged
|
|
23
|
-
before shutdown are replayed on next startup.
|
|
24
|
-
- **Lifecycle states**: `PUBLISHED`, `PROCESSING`, `COMPLETED`, `FAILED`,
|
|
25
|
-
`RESUBMITTED`.
|
|
26
|
-
- **Staleness monitor** — detects publications stuck in `PROCESSING`.
|
|
27
|
-
- **Failed / Incomplete / Completed** query APIs for operators.
|
|
28
|
-
- **Completion modes**: `UPDATE`, `DELETE`, `ARCHIVE`.
|
|
29
|
-
|
|
30
|
-
This package only defines types, the repository SPI, the in-memory
|
|
31
|
-
reference implementation (for tests), and the Nest module wiring. It
|
|
32
|
-
does **not** ship a production persistence backend — that lives in a
|
|
33
|
-
sibling package such as `@nestjs-transactional/outbox-typeorm`.
|
|
34
|
-
|
|
35
|
-
## Installation
|
|
28
|
+
## Install
|
|
36
29
|
|
|
37
30
|
```bash
|
|
38
|
-
pnpm add @nestjs-transactional/
|
|
39
|
-
#
|
|
40
|
-
pnpm add @nestjs-transactional/outbox-typeorm
|
|
31
|
+
pnpm add @nestjs-transactional/outbox @nestjs-transactional/core
|
|
32
|
+
pnpm add @nestjs-transactional/outbox-typeorm # a persistence backend
|
|
41
33
|
```
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
`rxjs`.
|
|
45
|
-
|
|
46
|
-
## Usage
|
|
47
|
-
|
|
48
|
-
### 1. Module wiring
|
|
35
|
+
## Quick start
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
serializer, processor, recovery). `OutboxModule.forFeature([...])`
|
|
52
|
-
declares the event classes a feature module owns — matches
|
|
53
|
-
`TypeOrmModule.forFeature(...)` ergonomics.
|
|
54
|
-
|
|
55
|
-
```typescript
|
|
37
|
+
```ts
|
|
56
38
|
import { Module } from '@nestjs/common';
|
|
57
39
|
import { TransactionalModule } from '@nestjs-transactional/core';
|
|
58
40
|
import { OutboxModule, OutboxProcessingModule } from '@nestjs-transactional/outbox';
|
|
41
|
+
import { typeOrmEventPublicationRepositoryProvider } from '@nestjs-transactional/outbox-typeorm';
|
|
59
42
|
|
|
60
43
|
@Module({
|
|
61
44
|
imports: [
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
TransactionalModule.forRoot({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
45
|
+
// Must be global: outbox providers resolve TransactionManager
|
|
46
|
+
// across module boundaries.
|
|
47
|
+
TransactionalModule.forRoot({ isGlobal: true }),
|
|
48
|
+
TypeOrmTransactionalModule.forRoot(),
|
|
49
|
+
OutboxTypeOrmModule.forRoot(),
|
|
50
|
+
|
|
68
51
|
OutboxModule.forRoot({
|
|
52
|
+
// Without this, the in-memory repository stays installed and
|
|
53
|
+
// nothing is ever persisted. See the note below.
|
|
54
|
+
repository: typeOrmEventPublicationRepositoryProvider(),
|
|
69
55
|
republishOnStartup: true,
|
|
70
|
-
processor: { pollingInterval: 1000, batchSize: 100 },
|
|
71
|
-
staleness: { processing: 60_000, monitorInterval: 30_000 },
|
|
72
|
-
// repository: { provide: EVENT_PUBLICATION_REPOSITORY, useClass: TypeOrmEventPublicationRepository },
|
|
73
56
|
}),
|
|
74
|
-
// Import OutboxProcessingModule ONLY in worker processes — not in
|
|
75
|
-
// API-only apps that merely publish events.
|
|
76
|
-
OutboxProcessingModule,
|
|
77
|
-
// Register the event classes this module emits / consumes.
|
|
78
|
-
OutboxModule.forFeature([OrderPlacedEvent, OrderShippedEvent]),
|
|
79
|
-
],
|
|
80
|
-
})
|
|
81
|
-
export class AppModule {}
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
#### Modular pattern (multiple feature modules)
|
|
85
|
-
|
|
86
|
-
Distribute event registrations to the modules that own them:
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
@Module({
|
|
90
|
-
imports: [OutboxModule.forFeature([OrderPlacedEvent, OrderCancelledEvent])],
|
|
91
|
-
providers: [OrderService],
|
|
92
|
-
})
|
|
93
|
-
export class OrderModule {}
|
|
94
57
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
})
|
|
99
|
-
export class InventoryModule {}
|
|
58
|
+
// Event classes this module owns. Feature modules normally call
|
|
59
|
+
// forFeature for their own events.
|
|
60
|
+
OutboxModule.forFeature([OrderPlacedEvent]),
|
|
100
61
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
OutboxModule.forRoot({ republishOnStartup: true /* ... */ }),
|
|
105
|
-
OrderModule,
|
|
106
|
-
InventoryModule,
|
|
62
|
+
// ONLY in worker processes. An API that merely publishes events
|
|
63
|
+
// must not import this.
|
|
64
|
+
OutboxProcessingModule,
|
|
107
65
|
],
|
|
108
66
|
})
|
|
109
67
|
export class AppModule {}
|
|
110
68
|
```
|
|
111
69
|
|
|
112
|
-
|
|
113
|
-
`
|
|
114
|
-
|
|
115
|
-
|
|
70
|
+
> **Pass `repository`.** `OutboxModule.forRoot()` falls back to
|
|
71
|
+
> `InMemoryEventPublicationRepository` when the option is missing. Your
|
|
72
|
+
> application starts, publishes, and handles events perfectly — and
|
|
73
|
+
> loses every one of them on restart, with nothing in the database and
|
|
74
|
+
> no error anywhere.
|
|
116
75
|
|
|
117
|
-
|
|
118
|
-
Error: Event type 'OrderPlacedEvent' already registered.
|
|
119
|
-
Each event type can only be registered once — check for duplicate
|
|
120
|
-
entries across OutboxModule.forFeature() calls.
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
`OutboxModule.forFeature([])` is accepted as a no-op (matches
|
|
124
|
-
`TypeOrmModule.forFeature([])`).
|
|
125
|
-
|
|
126
|
-
### 2. Declaring a handler
|
|
127
|
-
|
|
128
|
-
```typescript
|
|
129
|
-
import { Injectable } from '@nestjs/common';
|
|
130
|
-
import {
|
|
131
|
-
type IOutboxEventHandler,
|
|
132
|
-
OutboxEventsHandler,
|
|
133
|
-
} from '@nestjs-transactional/outbox';
|
|
76
|
+
Declare a handler:
|
|
134
77
|
|
|
78
|
+
```ts
|
|
135
79
|
@Injectable()
|
|
136
80
|
@OutboxEventsHandler(OrderPlacedEvent)
|
|
137
|
-
export class
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// publishing transaction has committed. Retried on exception,
|
|
143
|
-
// resumable across restarts.
|
|
81
|
+
export class ChargeCustomer implements IOutboxEventHandler<OrderPlacedEvent> {
|
|
82
|
+
async handle(event: OrderPlacedEvent) {
|
|
83
|
+
// Invoked by the worker, in its own transaction. Throwing marks the
|
|
84
|
+
// publication FAILED, leaving it for retry or an operator.
|
|
85
|
+
await this.payments.charge(event.orderId);
|
|
144
86
|
}
|
|
145
87
|
}
|
|
146
88
|
```
|
|
147
89
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
// Short form — defaults (newTransaction: true).
|
|
152
|
-
@OutboxEventsHandler(OrderPlacedEvent, OrderCancelledEvent)
|
|
153
|
-
|
|
154
|
-
// Long form — explicit options.
|
|
155
|
-
@OutboxEventsHandler({
|
|
156
|
-
events: [OrderPlacedEvent],
|
|
157
|
-
id: 'inventory.reservation', // stable base id, see "Listener ids" below
|
|
158
|
-
newTransaction: false, // skip the REQUIRES_NEW wrapper
|
|
159
|
-
})
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
See ADR-014 for the rationale behind the class-level shape.
|
|
163
|
-
|
|
164
|
-
### 3. Publishing events
|
|
90
|
+
Publish from inside a transaction:
|
|
165
91
|
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
export class PlaceOrderHandler {
|
|
173
|
-
constructor(private readonly outbox: OutboxEventPublisher) {}
|
|
174
|
-
|
|
175
|
-
@Transactional()
|
|
176
|
-
async handle(command: PlaceOrder): Promise<void> {
|
|
177
|
-
// ...persist business data in the same transaction...
|
|
178
|
-
await this.outbox.publish(new OrderPlacedEvent(command.orderId));
|
|
179
|
-
}
|
|
92
|
+
```ts
|
|
93
|
+
@Transactional()
|
|
94
|
+
async placeOrder(dto: PlaceOrderDto) {
|
|
95
|
+
const order = await this.orders.save(dto);
|
|
96
|
+
await this.publisher.publish(new OrderPlacedEvent(order.id));
|
|
97
|
+
return order; // publication rows commit with the order, or not at all
|
|
180
98
|
}
|
|
181
99
|
```
|
|
182
100
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
101
|
+
## Lifecycle
|
|
102
|
+
|
|
103
|
+
A publication moves through five states:
|
|
104
|
+
|
|
105
|
+
`PUBLISHED` → `PROCESSING` → `COMPLETED`, or `FAILED` → `RESUBMITTED`
|
|
106
|
+
back to `PROCESSING`.
|
|
107
|
+
|
|
108
|
+
The worker polls for ready rows and then **claims** each one with a
|
|
109
|
+
single conditional `UPDATE`. That claim, not the poll, is what makes
|
|
110
|
+
concurrent workers safe: two workers may fetch the same row, but only
|
|
111
|
+
one wins the claim and the loser moves on without invoking the handler.
|
|
112
|
+
So scaling out costs a wasted read, never a duplicate dispatch
|
|
113
|
+
([DD-025](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/025-claim-atomicity-obligation.md)).
|
|
114
|
+
|
|
115
|
+
Completed rows can be kept (`UPDATE`, the default), deleted, or moved to
|
|
116
|
+
an archive table — `completionMode`. Kept rows need purging eventually;
|
|
117
|
+
`CompletedEventPublications.purge(olderThan)` does it.
|
|
118
|
+
|
|
119
|
+
## Operating it
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
OutboxModule.forRoot({
|
|
123
|
+
repository: typeOrmEventPublicationRepositoryProvider(),
|
|
124
|
+
republishOnStartup: true,
|
|
125
|
+
processor: {
|
|
126
|
+
pollingInterval: 1000, // end-to-end latency is dominated by this
|
|
127
|
+
batchSize: 100,
|
|
128
|
+
maxConcurrent: 10,
|
|
129
|
+
// How long shutdown waits for an in-flight batch. Keep it under
|
|
130
|
+
// your platform's grace period; 0 disables the wait.
|
|
131
|
+
shutdownTimeout: 10_000,
|
|
132
|
+
},
|
|
133
|
+
// Flip publications stuck in a non-terminal state to FAILED. 0 = off.
|
|
134
|
+
staleness: { processing: 60_000, monitorInterval: 30_000 },
|
|
135
|
+
// Automatic retry, off unless you ask for it. maxAttempts counts the
|
|
136
|
+
// first delivery, so 3 means the original plus two retries.
|
|
137
|
+
retry: { maxAttempts: 3, baseDelay: 1_000, factor: 2, maxDelay: 300_000 },
|
|
138
|
+
});
|
|
200
139
|
```
|
|
201
140
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
`inventory.reservation#OrderPlacedEvent`.
|
|
206
|
-
|
|
207
|
-
A class that handles multiple event types produces one id per event.
|
|
208
|
-
Renaming the class without supplying an explicit `id` breaks delivery
|
|
209
|
-
for every stored publication under the old id — set `options.id` when
|
|
210
|
-
you want stability across renames.
|
|
211
|
-
|
|
212
|
-
## Externalization (advanced)
|
|
213
|
-
|
|
214
|
-
> The `EventExternalizer` SPI lives in this package. The
|
|
215
|
-
> `@nestjs/microservices` `ClientProxy`-backed implementation ships
|
|
216
|
-
> as a separate package —
|
|
217
|
-
> [`@nestjs-transactional/outbox-microservices`](../outbox-microservices).
|
|
218
|
-
> Without an externalizer bound, `@Externalized` mappings are
|
|
219
|
-
> recorded but never delivered to a broker — local outbox listeners
|
|
220
|
-
> still run.
|
|
221
|
-
>
|
|
222
|
-
> Architecture and design rationale:
|
|
223
|
-
> [ADR-015](../../docs/adr/015-event-externalization-architecture.md),
|
|
224
|
-
> [`docs/architecture/event-externalization.md`](../../docs/architecture/event-externalization.md).
|
|
225
|
-
> Reliability caveat for the `@nestjs/microservices`-backed
|
|
226
|
-
> implementation:
|
|
227
|
-
> [ADR-016](../../docs/adr/016-externalization-reliability-semantics.md).
|
|
228
|
-
|
|
229
|
-
`@Externalized` marks an event class for delivery to an external
|
|
230
|
-
message broker (Kafka topic, RabbitMQ exchange, NATS subject, ...) in
|
|
231
|
-
addition to local outbox listeners. The processor invokes the bound
|
|
232
|
-
`EventExternalizer` AFTER the local listener has succeeded — single
|
|
233
|
-
unit atomicity (DD-019): if either step fails, the publication is
|
|
234
|
-
recorded as `FAILED` and can be resubmitted via `FailedEventPublications`.
|
|
235
|
-
|
|
236
|
-
```typescript
|
|
237
|
-
import { Externalized } from '@nestjs-transactional/outbox';
|
|
238
|
-
|
|
239
|
-
@Externalized<OrderPlacedEvent>({
|
|
240
|
-
target: 'orders.placed', // broker-side destination
|
|
241
|
-
routingKey: (e) => e.tenantId, // optional, brokers that support it
|
|
242
|
-
headers: (e) => ({ 'x-tenant': e.tenantId }), // static record OR callback
|
|
243
|
-
client: 'KAFKA_CLIENT', // optional ClientProxy token
|
|
244
|
-
})
|
|
245
|
-
export class OrderPlacedEvent {
|
|
246
|
-
constructor(
|
|
247
|
-
readonly orderId: string,
|
|
248
|
-
readonly tenantId: string,
|
|
249
|
-
) {}
|
|
250
|
-
}
|
|
251
|
-
```
|
|
141
|
+
Shutdown is drained, not cut off: `OutboxProcessingModule` awaits the
|
|
142
|
+
batch already running before NestJS tears down the DataSource, so a
|
|
143
|
+
publication is not stranded mid-transition.
|
|
252
144
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
145
|
+
Retry is opt-in because Spring Modulith has none either — recovery is
|
|
146
|
+
otherwise an operator action. A publication that exhausts its attempts
|
|
147
|
+
stays `FAILED`; there is no separate dead-letter state, and it remains
|
|
148
|
+
visible and resubmittable
|
|
149
|
+
([DD-026](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/026-automatic-retry-policy.md)).
|
|
256
150
|
|
|
257
|
-
|
|
258
|
-
import { EVENT_EXTERNALIZER, type EventExternalizer } from '@nestjs-transactional/outbox';
|
|
151
|
+
Three injectable APIs for operators, matching Spring Modulith's:
|
|
259
152
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
},
|
|
266
|
-
],
|
|
267
|
-
})
|
|
268
|
-
export class MyAppModule {}
|
|
153
|
+
```ts
|
|
154
|
+
await failed.findAll({ minAge: 60_000 }); // triage
|
|
155
|
+
await failed.resubmit({ maxAttempts: 5 }); // retry by hand
|
|
156
|
+
await incomplete.count(); // anything not COMPLETED
|
|
157
|
+
await completed.purge(olderThan); // retention
|
|
269
158
|
```
|
|
270
159
|
|
|
271
|
-
|
|
272
|
-
`EVENT_EXTERNALIZER` exists AND the event class carries an
|
|
273
|
-
`@Externalized` mapping — keep one without the other if you only need
|
|
274
|
-
half the contract (e.g. metadata-only mappings during a rollout, or a
|
|
275
|
-
generic externalizer that resolves targets some other way). Inspect
|
|
276
|
-
the resolved mapping at runtime via `ExternalizationRegistry`:
|
|
277
|
-
|
|
278
|
-
```typescript
|
|
279
|
-
import { ExternalizationRegistry } from '@nestjs-transactional/outbox';
|
|
160
|
+
## Listener ids
|
|
280
161
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
162
|
+
Each publication row is keyed by `${baseId}#${EventName}`, where
|
|
163
|
+
`baseId` defaults to the handler's class name. Renaming the class
|
|
164
|
+
therefore orphans any stored publication for it. Pass an explicit id
|
|
165
|
+
when that is a risk:
|
|
284
166
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
}
|
|
167
|
+
```ts
|
|
168
|
+
@OutboxEventsHandler({ events: [OrderPlacedEvent], id: 'Billing.charge' })
|
|
289
169
|
```
|
|
290
170
|
|
|
291
|
-
|
|
292
|
-
(carrying `eventType`, `target`, and the underlying cause) and surface
|
|
293
|
-
on the publication's `failureReason` for operator visibility.
|
|
171
|
+
## Testing
|
|
294
172
|
|
|
295
|
-
|
|
173
|
+
The `/testing` subpath ships an in-memory repository and fluent
|
|
174
|
+
assertions, so outbox behaviour is testable without a database:
|
|
296
175
|
|
|
297
|
-
|
|
298
|
-
the code under test published. Mirrors Spring Modulith's
|
|
299
|
-
`PublishedEvents` / `AssertablePublishedEvents`.
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
176
|
+
```ts
|
|
302
177
|
import {
|
|
303
|
-
PublishedEvents,
|
|
304
178
|
AssertablePublishedEvents,
|
|
179
|
+
InMemoryEventPublicationRepository,
|
|
305
180
|
} from '@nestjs-transactional/outbox/testing';
|
|
306
181
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
TransactionalModule.forRoot({ isGlobal: true /* ... */ }),
|
|
315
|
-
OutboxModule.forRoot({}),
|
|
316
|
-
OutboxModule.forFeature([OrderPlacedEvent]),
|
|
317
|
-
],
|
|
318
|
-
providers: [
|
|
319
|
-
PlaceOrderService,
|
|
320
|
-
PublishedEvents,
|
|
321
|
-
AssertablePublishedEvents,
|
|
322
|
-
],
|
|
323
|
-
}).compile();
|
|
324
|
-
await app.init();
|
|
325
|
-
assertablePublishedEvents = app.get(AssertablePublishedEvents);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('publishes OrderPlacedEvent for the placed order', async () => {
|
|
329
|
-
const service = app.get(PlaceOrderService);
|
|
330
|
-
await service.place('order-123');
|
|
331
|
-
|
|
332
|
-
const view = await assertablePublishedEvents.contains(OrderPlacedEvent);
|
|
333
|
-
view.matching((e) => e.orderId, 'order-123').hasSize(1);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it('publishes nothing when validation rejects the command', async () => {
|
|
337
|
-
const service = app.get(PlaceOrderService);
|
|
338
|
-
await service.placeInvalid().catch(() => undefined);
|
|
339
|
-
|
|
340
|
-
await assertablePublishedEvents.doesNotContain(OrderPlacedEvent);
|
|
341
|
-
});
|
|
342
|
-
});
|
|
182
|
+
(await assertable.contains(OrderPlacedEvent))
|
|
183
|
+
.matching((e) => e.orderId, 'o-1')
|
|
184
|
+
.hasSize(1);
|
|
185
|
+
|
|
186
|
+
// The negative path matters just as much: after a rollback, the
|
|
187
|
+
// in-memory repository has removed the publication.
|
|
188
|
+
await assertable.doesNotContain(OrderCancelledEvent);
|
|
343
189
|
```
|
|
344
190
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
- [`audit-logging`](../../examples/audit-logging) — cross-DS audit trail with idempotency on the consumer.
|
|
368
|
-
- [`e-commerce-orders`](../../examples/e-commerce-orders) — three-bounded-context flagship wired through outbox integration events.
|
|
369
|
-
|
|
370
|
-
Full catalogue: [examples/README.md](../../examples/README.md).
|
|
371
|
-
|
|
372
|
-
## Status
|
|
373
|
-
|
|
374
|
-
Alpha. Public API may change between 0.x releases. Current
|
|
375
|
-
functionality is exercised end-to-end through the
|
|
376
|
-
[Tier 1–5 example library](../../examples/README.md).
|
|
377
|
-
|
|
378
|
-
Design notes:
|
|
379
|
-
[`docs/roadmap/README.md`](../../docs/roadmap/README.md),
|
|
380
|
-
[`docs/adr/006-outbox-pattern.md`](../../docs/adr/006-outbox-pattern.md).
|
|
381
|
-
|
|
382
|
-
## Inspired by Spring Modulith
|
|
383
|
-
|
|
384
|
-
The design follows
|
|
385
|
-
[Spring Modulith's Event Publication Registry](https://docs.spring.io/spring-modulith/reference/events.html)
|
|
386
|
-
closely — lifecycle states, `@ApplicationModuleListener` semantics,
|
|
387
|
-
completion modes, and staleness monitoring all map one-to-one. The
|
|
388
|
-
deviations from Spring Modulith are limited to what is needed to fit
|
|
389
|
-
the Node.js / NestJS runtime (async workers instead of thread pools,
|
|
390
|
-
AsyncLocalStorage for transaction context, NestJS DI conventions,
|
|
391
|
-
class-level handler decorators aligned with `@nestjs/cqrs`
|
|
392
|
-
conventions — see ADR-014).
|
|
191
|
+
## Sending events to a broker
|
|
192
|
+
|
|
193
|
+
`@Externalized` marks an event for forwarding after its local handlers
|
|
194
|
+
complete. The `EVENT_EXTERNALIZER` SPI is transport-agnostic;
|
|
195
|
+
`outbox-microservices` implements it over `@nestjs/microservices`.
|
|
196
|
+
**Read that package's reliability note before relying on it in
|
|
197
|
+
production** — `ClientProxy.emit()` cannot report broker-side failure,
|
|
198
|
+
so a publication can be marked `COMPLETED` when nothing reached the
|
|
199
|
+
broker
|
|
200
|
+
([ADR-016](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/016-externalization-reliability-semantics.md)).
|
|
201
|
+
|
|
202
|
+
## Documentation
|
|
203
|
+
|
|
204
|
+
- [Getting started and full docs](https://github.com/igorgolovanov/nestjs-transactional#readme)
|
|
205
|
+
- [Architecture: the outbox pattern](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/architecture/outbox-pattern.md)
|
|
206
|
+
- [Migrating to the outbox](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/guides/migrating-to-outbox.md)
|
|
207
|
+
- [One `forRoot` per dataSource (ADR-019)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/019-outbox-multi-forroot-pattern.md)
|
|
208
|
+
- Runnable examples:
|
|
209
|
+
[`basic-outbox`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/basic-outbox),
|
|
210
|
+
[`saga-pattern`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/saga-pattern),
|
|
211
|
+
[`audit-logging`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/audit-logging),
|
|
212
|
+
[`graceful-shutdown`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/graceful-shutdown)
|
|
393
213
|
|
|
394
214
|
## License
|
|
395
215
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"failed-event-publications.js","sourceRoot":"","sources":["../../src/api/failed-event-publications.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAAoD;AAEpD,6FAGoD;AAEpD,oEAAgE;AAChE,wEAAoE;
|
|
1
|
+
{"version":3,"file":"failed-event-publications.js","sourceRoot":"","sources":["../../src/api/failed-event-publications.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAAoD;AAEpD,6FAGoD;AAEpD,oEAAgE;AAChE,wEAAoE;AAEpE;;;;GAIG;AAEI,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAGf;IAFnB,YAEmB,UAAsC;QAAtC,eAAU,GAAV,UAAU,CAA4B;IACtD,CAAC;IAEJ;;;;OAIG;IACH,KAAK,CAAC,OAAO,CAAC,OAGb;QACC,OAAO,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IAC7C,CAAC;IAED,uDAAuD;IACvD,KAAK,CAAC,KAAK;QACT,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;QAClD,OAAO,MAAM,CAAC,MAAM,CAAC;IACvB,CAAC;IAED;;;;;;;;;;OAUG;IACH,KAAK,CAAC,QAAQ,CAAC,UAA+B,0CAAmB,CAAC,QAAQ,EAAE;QAC1E,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC;YAC9C,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,WAAW,EAAE,OAAO,CAAC,qBAAqB,IAAI,SAAS;SACxD,CAAC,CAAC;QAEH,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;QAClF,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QAEvD,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;YAC5B,MAAM,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,EAAE,sCAAiB,CAAC,WAAW,EAAE;gBACxE,oBAAoB,EAAE,IAAI,IAAI,EAAE;aACjC,CAAC,CAAC;QACL,CAAC;QAED,OAAO,SAAS,CAAC,MAAM,CAAC;IAC1B,CAAC;CACF,CAAA;AApDY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,2DAA4B,CAAC,CAAA;;GAF5B,uBAAuB,CAoDnC"}
|
|
@@ -52,8 +52,7 @@ let IncompleteEventPublications = class IncompleteEventPublications {
|
|
|
52
52
|
const toProcess = filtered.slice(0, options.batchSize);
|
|
53
53
|
let resubmitted = 0;
|
|
54
54
|
for (const pub of toProcess) {
|
|
55
|
-
if (pub.status === publication_status_1.PublicationStatus.FAILED ||
|
|
56
|
-
pub.status === publication_status_1.PublicationStatus.PUBLISHED) {
|
|
55
|
+
if (pub.status === publication_status_1.PublicationStatus.FAILED || pub.status === publication_status_1.PublicationStatus.PUBLISHED) {
|
|
57
56
|
await this.repository.updateStatus(pub.id, publication_status_1.PublicationStatus.RESUBMITTED, {
|
|
58
57
|
lastResubmissionDate: new Date(),
|
|
59
58
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"incomplete-event-publications.js","sourceRoot":"","sources":["../../src/api/incomplete-event-publications.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAAoD;AAEpD,6FAGoD;AAEpD,oEAAgE;AAChE,wEAAoE;
|
|
1
|
+
{"version":3,"file":"incomplete-event-publications.js","sourceRoot":"","sources":["../../src/api/incomplete-event-publications.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAAoD;AAEpD,6FAGoD;AAEpD,oEAAgE;AAChE,wEAAoE;AAEpE;;;;;;;GAOG;AAEI,IAAM,2BAA2B,GAAjC,MAAM,2BAA2B;IAGnB;IAFnB,YAEmB,UAAsC;QAAtC,eAAU,GAAV,UAAU,CAA4B;IACtD,CAAC;IAEJ,gEAAgE;IAChE,KAAK,CAAC,OAAO;QACX,OAAO,IAAI,CAAC,UAAU,CAAC,cAAc,EAAE,CAAC;IAC1C,CAAC;IAED,2DAA2D;IAC3D,KAAK,CAAC,KAAK;QACT,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,cAAc,EAAE,CAAC;QAC1D,OAAO,UAAU,CAAC,MAAM,CAAC;IAC3B,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,8BAA8B,CAClC,UAA+B,0CAAmB,CAAC,QAAQ,EAAE;QAE7D,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,cAAc,EAAE,CAAC;QAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;QAC1F,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QAEvD,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;YAC5B,IAAI,GAAG,CAAC,MAAM,KAAK,sCAAiB,CAAC,MAAM,IAAI,GAAG,CAAC,MAAM,KAAK,sCAAiB,CAAC,SAAS,EAAE,CAAC;gBAC1F,MAAM,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,EAAE,sCAAiB,CAAC,WAAW,EAAE;oBACxE,oBAAoB,EAAE,IAAI,IAAI,EAAE;iBACjC,CAAC,CAAC;gBACH,WAAW,EAAE,CAAC;YAChB,CAAC;QACH,CAAC;QAED,OAAO,WAAW,CAAC;IACrB,CAAC;CACF,CAAA;AA5CY,kEAA2B;sCAA3B,2BAA2B;IADvC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,2DAA4B,CAAC,CAAA;;GAF5B,2BAA2B,CA4CvC"}
|
|
@@ -76,7 +76,7 @@ export interface OutboxEventsHandlerMetadata {
|
|
|
76
76
|
* `Reflect.defineMetadata`. The actual registration happens at
|
|
77
77
|
* application bootstrap via `OutboxListenerScanner`.
|
|
78
78
|
*
|
|
79
|
-
* **Multi-dataSource
|
|
79
|
+
* **Multi-dataSource.** The scanner walks every
|
|
80
80
|
* per-dataSource `EventTypeRegistry` and routes the handler to the
|
|
81
81
|
* `OutboxListenerRegistry` whose dataSource owns the decorated
|
|
82
82
|
* events — automatic, no decorator option required. A handler
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outbox-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/outbox-events-handler.decorator.ts"],"names":[],"mappings":";;;AA+FA,kDAeC;
|
|
1
|
+
{"version":3,"file":"outbox-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/outbox-events-handler.decorator.ts"],"names":[],"mappings":";;;AA+FA,kDAeC;AAiCD,wEAKC;AApJD,4BAA0B;AAI1B;;;;;;;;;GASG;AACU,QAAA,8BAA8B,GAAG,MAAM,CAAC,gCAAgC,CAAC,CAAC;AAiFvF,SAAgB,mBAAmB,CACjC,GAAG,IAA2C;IAE9C,MAAM,QAAQ,GAAgC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEpE,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,yDAAyD;YACvD,uEAAuE,CAC1E,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,MAAc,EAAQ,EAAE;QAC9B,OAAO,CAAC,cAAc,CAAC,sCAA8B,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC3E,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,IAA2C;IAClE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClD,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO;YACL,UAAU,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;YAC/B,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,cAAc,EAAE,OAAO,CAAC,cAAc,IAAI,IAAI;SAC/C,CAAC;IACJ,CAAC;IAED,OAAO;QACL,UAAU,EAAE,IAAc;QAC1B,cAAc,EAAE,IAAI;KACrB,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,SAAkB;IACzC,OAAO,CACL,SAAS,KAAK,IAAI;QAClB,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC;QACzB,OAAO,SAAS,KAAK,UAAU;QAC/B,QAAQ,IAAI,SAAS,CACtB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAgB,8BAA8B,CAC5C,MAAc;IAEd,MAAM,KAAK,GAAY,OAAO,CAAC,WAAW,CAAC,sCAA8B,EAAE,MAAM,CAAC,CAAC;IACnF,OAAO,KAAgD,CAAC;AAC1D,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { EventPublicationRegistry } from '../registry/event-publication-registry';
|
|
2
2
|
import type { OutboxListenerRegistry } from '../registry/listener-registry';
|
|
3
3
|
/**
|
|
4
|
-
* Per-dataSource publisher
|
|
4
|
+
* Per-dataSource publisher. Owns the per-transaction
|
|
5
5
|
* buffer, lifecycle hook registration, and delegate calls into the
|
|
6
6
|
* `EventPublicationRegistry` bound to a single dataSource.
|
|
7
7
|
*
|
|
@@ -60,8 +60,7 @@ export declare class DataSourceOutboxPublisher {
|
|
|
60
60
|
* — `transactionManager.registerBeforeCommit` is NOT used because it
|
|
61
61
|
* always targets "the first active transaction on the context",
|
|
62
62
|
* which is the wrong target when multiple dataSources have live
|
|
63
|
-
* transactions in the same async stack
|
|
64
|
-
* simultaneous scenario).
|
|
63
|
+
* transactions in the same async stack.
|
|
65
64
|
*/
|
|
66
65
|
scheduleForPublication(event: unknown): void;
|
|
67
66
|
private ensureActiveTransaction;
|
|
@@ -4,7 +4,7 @@ exports.DataSourceOutboxPublisher = void 0;
|
|
|
4
4
|
const common_1 = require("@nestjs/common");
|
|
5
5
|
const core_1 = require("@nestjs-transactional/core");
|
|
6
6
|
/**
|
|
7
|
-
* Per-dataSource publisher
|
|
7
|
+
* Per-dataSource publisher. Owns the per-transaction
|
|
8
8
|
* buffer, lifecycle hook registration, and delegate calls into the
|
|
9
9
|
* `EventPublicationRegistry` bound to a single dataSource.
|
|
10
10
|
*
|
|
@@ -85,8 +85,7 @@ class DataSourceOutboxPublisher {
|
|
|
85
85
|
* — `transactionManager.registerBeforeCommit` is NOT used because it
|
|
86
86
|
* always targets "the first active transaction on the context",
|
|
87
87
|
* which is the wrong target when multiple dataSources have live
|
|
88
|
-
* transactions in the same async stack
|
|
89
|
-
* simultaneous scenario).
|
|
88
|
+
* transactions in the same async stack.
|
|
90
89
|
*/
|
|
91
90
|
scheduleForPublication(event) {
|
|
92
91
|
const tx = core_1.TransactionContext.getActiveTransactionByDataSource(this.dataSource);
|