@nestjs-transactional/outbox 1.0.0-alpha.5 → 1.1.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 +174 -335
- 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/describe-thrown.d.ts +20 -0
- package/dist/externalization/describe-thrown.js +49 -0
- package/dist/externalization/describe-thrown.js.map +1 -0
- 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 +6 -0
- package/dist/index.js +11 -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 +33 -7
- package/dist/module/outbox-processing.module.js.map +1 -1
- package/dist/module/outbox.module.d.ts +15 -0
- package/dist/module/outbox.module.js +99 -8
- package/dist/module/outbox.module.js.map +1 -1
- package/dist/recovery/outbox-cleanup-scheduler.d.ts +56 -0
- package/dist/recovery/outbox-cleanup-scheduler.js +139 -0
- package/dist/recovery/outbox-cleanup-scheduler.js.map +1 -0
- 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 +37 -6
- 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 +11 -2
- package/dist/testing/in-memory-repository.js.map +1 -1
- package/dist/testing/published-events.js +6 -2
- package/dist/testing/published-events.js.map +1 -1
- package/dist/tokens/token-utils.js.map +1 -1
- package/dist/types/cleanup-config.d.ts +59 -0
- package/dist/types/cleanup-config.js +17 -0
- package/dist/types/cleanup-config.js.map +1 -0
- 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 +25 -13
package/README.md
CHANGED
|
@@ -1,395 +1,234 @@
|
|
|
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
|
-
|
|
116
|
-
|
|
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([])`).
|
|
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.
|
|
125
75
|
|
|
126
|
-
|
|
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
|
-
```
|
|
90
|
+
Publish from inside a transaction:
|
|
161
91
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
import { Transactional } from '@nestjs-transactional/core';
|
|
169
|
-
import { OutboxEventPublisher } from '@nestjs-transactional/outbox';
|
|
170
|
-
|
|
171
|
-
@Injectable()
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
}
|
|
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
|
+
// Retention for COMPLETED publications, also off unless asked for.
|
|
139
|
+
// 0 = off, which leaves purging to you.
|
|
140
|
+
cleanup: { interval: 3_600_000, retention: 7 * 24 * 3_600_000, batchSize: 500 },
|
|
141
|
+
});
|
|
251
142
|
```
|
|
252
143
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
144
|
+
Shutdown is drained, not cut off: `OutboxProcessingModule` awaits the
|
|
145
|
+
batch already running before NestJS tears down the DataSource, so a
|
|
146
|
+
publication is not stranded mid-transition.
|
|
147
|
+
|
|
148
|
+
Retry is opt-in because Spring Modulith has none either — recovery is
|
|
149
|
+
otherwise an operator action. A publication that exhausts its attempts
|
|
150
|
+
stays `FAILED`; there is no separate dead-letter state, and it remains
|
|
151
|
+
visible and resubmittable
|
|
152
|
+
([DD-026](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/026-automatic-retry-policy.md)).
|
|
153
|
+
|
|
154
|
+
Under the default `UPDATE` completion mode a delivered publication
|
|
155
|
+
stays as an audit row and nothing removes it, so the table grows until
|
|
156
|
+
someone purges it. `cleanup` puts that purge on a timer: each pass takes
|
|
157
|
+
up to `batchSize` publications whose `completionDate` is older than
|
|
158
|
+
`retention` and deletes them. The bound matters, because the first pass
|
|
159
|
+
after enabling this on an existing deployment meets the table at its
|
|
160
|
+
largest, and one unbounded `DELETE` there is a long transaction holding
|
|
161
|
+
locks your writers need. Passes drain from the oldest end, so a job that
|
|
162
|
+
cannot keep up still shrinks the backlog rather than stranding its tail.
|
|
163
|
+
|
|
164
|
+
`completed.purge(olderThan)` is unchanged and remains the right tool for
|
|
165
|
+
a deliberate one-shot bulk purge.
|
|
166
|
+
|
|
167
|
+
Three injectable APIs for operators, matching Spring Modulith's:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
await failed.findAll({ minAge: 60_000 }); // triage
|
|
171
|
+
await failed.resubmit({ maxAttempts: 5 }); // retry by hand
|
|
172
|
+
await incomplete.count(); // anything not COMPLETED
|
|
173
|
+
await completed.purge(olderThan); // retention
|
|
269
174
|
```
|
|
270
175
|
|
|
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';
|
|
176
|
+
## Listener ids
|
|
280
177
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
178
|
+
Each publication row is keyed by `${baseId}#${EventName}`, where
|
|
179
|
+
`baseId` defaults to the handler's class name. Renaming the class
|
|
180
|
+
therefore orphans any stored publication for it. Pass an explicit id
|
|
181
|
+
when that is a risk:
|
|
284
182
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
}
|
|
183
|
+
```ts
|
|
184
|
+
@OutboxEventsHandler({ events: [OrderPlacedEvent], id: 'Billing.charge' })
|
|
289
185
|
```
|
|
290
186
|
|
|
291
|
-
|
|
292
|
-
(carrying `eventType`, `target`, and the underlying cause) and surface
|
|
293
|
-
on the publication's `failureReason` for operator visibility.
|
|
187
|
+
## Testing
|
|
294
188
|
|
|
295
|
-
|
|
189
|
+
The `/testing` subpath ships an in-memory repository and fluent
|
|
190
|
+
assertions, so outbox behaviour is testable without a database:
|
|
296
191
|
|
|
297
|
-
|
|
298
|
-
the code under test published. Mirrors Spring Modulith's
|
|
299
|
-
`PublishedEvents` / `AssertablePublishedEvents`.
|
|
300
|
-
|
|
301
|
-
```typescript
|
|
192
|
+
```ts
|
|
302
193
|
import {
|
|
303
|
-
PublishedEvents,
|
|
304
194
|
AssertablePublishedEvents,
|
|
195
|
+
InMemoryEventPublicationRepository,
|
|
305
196
|
} from '@nestjs-transactional/outbox/testing';
|
|
306
197
|
|
|
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
|
-
});
|
|
198
|
+
(await assertable.contains(OrderPlacedEvent))
|
|
199
|
+
.matching((e) => e.orderId, 'o-1')
|
|
200
|
+
.hasSize(1);
|
|
201
|
+
|
|
202
|
+
// The negative path matters just as much: after a rollback, the
|
|
203
|
+
// in-memory repository has removed the publication.
|
|
204
|
+
await assertable.doesNotContain(OrderCancelledEvent);
|
|
343
205
|
```
|
|
344
206
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
- [
|
|
363
|
-
- [
|
|
364
|
-
- [`
|
|
365
|
-
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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).
|
|
207
|
+
## Sending events to a broker
|
|
208
|
+
|
|
209
|
+
`@Externalized` marks an event for forwarding after its local handlers
|
|
210
|
+
complete. The `EVENT_EXTERNALIZER` SPI is transport-agnostic;
|
|
211
|
+
`outbox-microservices` implements it over `@nestjs/microservices`.
|
|
212
|
+
|
|
213
|
+
A publication is marked `COMPLETED` when the externalizer resolves,
|
|
214
|
+
and what that proves depends on the transport. Kafka and RabbitMQ wait
|
|
215
|
+
for a broker acknowledgement, so a broker that is down produces a
|
|
216
|
+
`FAILED` row the retry and resubmit machinery can act on. NATS core and
|
|
217
|
+
TCP acknowledge nothing, and gRPC cannot be used for externalization at
|
|
218
|
+
all. The per-transport table is in that package's README and in
|
|
219
|
+
[ADR-021](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/021-externalization-acknowledgement-per-transport.md).
|
|
220
|
+
|
|
221
|
+
## Documentation
|
|
222
|
+
|
|
223
|
+
- [Getting started and full docs](https://github.com/igorgolovanov/nestjs-transactional#readme)
|
|
224
|
+
- [Architecture: the outbox pattern](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/architecture/outbox-pattern.md)
|
|
225
|
+
- [Migrating to the outbox](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/guides/migrating-to-outbox.md)
|
|
226
|
+
- [One `forRoot` per dataSource (ADR-019)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/019-outbox-multi-forroot-pattern.md)
|
|
227
|
+
- Runnable examples:
|
|
228
|
+
[`basic-outbox`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/basic-outbox),
|
|
229
|
+
[`saga-pattern`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/saga-pattern),
|
|
230
|
+
[`audit-logging`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/audit-logging),
|
|
231
|
+
[`graceful-shutdown`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/graceful-shutdown)
|
|
393
232
|
|
|
394
233
|
## License
|
|
395
234
|
|
|
@@ -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;
|