@nestjs/cqrs 12.0.0 → 12.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.
@@ -17,7 +17,7 @@ jobs:
17
17
  build:
18
18
  working_directory: ~/nest
19
19
  docker:
20
- - image: cimg/node:24.19.0
20
+ - image: cimg/node:24.21.0
21
21
  steps:
22
22
  - checkout
23
23
  - restore_cache:
@@ -35,7 +35,7 @@ jobs:
35
35
  e2e_test:
36
36
  working_directory: ~/nest
37
37
  docker:
38
- - image: cimg/node:24.19.0
38
+ - image: cimg/node:24.21.0
39
39
  steps:
40
40
  - checkout
41
41
  - *restore-cache
@@ -46,7 +46,7 @@ jobs:
46
46
  test:
47
47
  working_directory: ~/nest
48
48
  docker:
49
- - image: cimg/node:24.19.0
49
+ - image: cimg/node:24.21.0
50
50
  steps:
51
51
  - checkout
52
52
  - *restore-cache
package/dist/event-bus.js CHANGED
@@ -122,7 +122,7 @@ let EventBus = EventBus_1 = class EventBus extends ObservableBus {
122
122
  }
123
123
  const unhandledError = this.mapToUnhandledErrorInfo(event, error);
124
124
  this.unhandledExceptionBus.publish(unhandledError);
125
- this._logger.error(`"${handler.constructor.name}" has thrown an unhandled exception.`, error);
125
+ this._logger.error(`"${handler.instance?.constructor.name ?? handler.name}" has thrown an unhandled exception.`, error);
126
126
  return of();
127
127
  }))));
128
128
  this.eventOperators.push(eventOperator);
@@ -13,6 +13,7 @@ export declare class EventPublisher<EventBase extends IEvent = IEvent> {
13
13
  /**
14
14
  * Merge the event publisher into the provided class.
15
15
  * This is required to make `publish` and `publishAll` available on the `AggregateRoot` class.
16
+ * The aggregate is the dispatcher context, unless `commit()`, `publish()` or `publishAll()` is given one.
16
17
  * @param metatype The class to merge into.
17
18
  * @param asyncContext The async context (if scoped).
18
19
  */
@@ -20,6 +21,7 @@ export declare class EventPublisher<EventBase extends IEvent = IEvent> {
20
21
  /**
21
22
  * Merge the event publisher into the provided object.
22
23
  * This is required to make `publish` and `publishAll` available on the `IAggregateRoot` class instance.
24
+ * The aggregate is the dispatcher context, unless `commit()`, `publish()` or `publishAll()` is given one.
23
25
  * @param object The object to merge into.
24
26
  * @param asyncContext The async context (if scoped).
25
27
  */
@@ -19,33 +19,35 @@ let EventPublisher = class EventPublisher {
19
19
  /**
20
20
  * Merge the event publisher into the provided class.
21
21
  * This is required to make `publish` and `publishAll` available on the `AggregateRoot` class.
22
+ * The aggregate is the dispatcher context, unless `commit()`, `publish()` or `publishAll()` is given one.
22
23
  * @param metatype The class to merge into.
23
24
  * @param asyncContext The async context (if scoped).
24
25
  */
25
26
  mergeClassContext(metatype, asyncContext) {
26
27
  const eventBus = this.eventBus;
27
28
  return class extends metatype {
28
- publish(event) {
29
- eventBus.publish(event, this, asyncContext);
29
+ publish(event, dispatcherContext) {
30
+ return eventBus.publish(event, dispatcherContext === undefined ? this : dispatcherContext, asyncContext);
30
31
  }
31
- publishAll(events) {
32
- eventBus.publishAll(events, this, asyncContext);
32
+ publishAll(events, dispatcherContext) {
33
+ return eventBus.publishAll(events, dispatcherContext === undefined ? this : dispatcherContext, asyncContext);
33
34
  }
34
35
  };
35
36
  }
36
37
  /**
37
38
  * Merge the event publisher into the provided object.
38
39
  * This is required to make `publish` and `publishAll` available on the `IAggregateRoot` class instance.
40
+ * The aggregate is the dispatcher context, unless `commit()`, `publish()` or `publishAll()` is given one.
39
41
  * @param object The object to merge into.
40
42
  * @param asyncContext The async context (if scoped).
41
43
  */
42
44
  mergeObjectContext(object, asyncContext) {
43
45
  const eventBus = this.eventBus;
44
- object.publish = (event) => {
45
- eventBus.publish(event, object, asyncContext);
46
+ object.publish = (event, dispatcherContext) => {
47
+ return eventBus.publish(event, dispatcherContext === undefined ? object : dispatcherContext, asyncContext);
46
48
  };
47
- object.publishAll = (events) => {
48
- eventBus.publishAll(events, object, asyncContext);
49
+ object.publishAll = (events, dispatcherContext) => {
50
+ return eventBus.publishAll(events, dispatcherContext === undefined ? object : dispatcherContext, asyncContext);
49
51
  };
50
52
  return object;
51
53
  }
@@ -10,17 +10,27 @@ export interface IAggregateRoot<EventBase extends IEvent = IEvent> {
10
10
  /**
11
11
  * Publishes a single event.
12
12
  * @param event The event to publish.
13
+ * @param dispatcherContext Dispatcher context passed to the event publisher, such as
14
+ * `{ transaction }`. Defaults to the aggregate (merged with `EventPublisher`) or none (`@Publishable()`).
15
+ * @returns What the event bus returns (the event publisher's result).
13
16
  */
14
- publish<T extends EventBase = EventBase>(event: T): void;
17
+ publish<T extends EventBase = EventBase>(event: T, dispatcherContext?: unknown): any;
15
18
  /**
16
19
  * Publishes multiple events.
17
20
  * @param events The events to publish.
21
+ * @param dispatcherContext Dispatcher context passed to the event publisher, such as
22
+ * `{ transaction }`. Defaults to the aggregate (merged with `EventPublisher`) or none (`@Publishable()`).
23
+ * @returns What the event bus returns (the event publisher's result).
18
24
  */
19
- publishAll<T extends EventBase = EventBase>(events: T[]): void;
25
+ publishAll<T extends EventBase = EventBase>(events: T[], dispatcherContext?: unknown): any;
20
26
  /**
21
27
  * Commits all uncommitted events.
28
+ * The events are cleared once they are handed to the event bus, before an asynchronous publisher settles.
29
+ * @param dispatcherContext Dispatcher context passed to the event publisher, such as
30
+ * `{ transaction }`. Defaults to the aggregate (merged with `EventPublisher`) or none (`@Publishable()`).
31
+ * @returns What `publishAll()` returns: await it to wait for (and catch the errors of) an asynchronous publisher.
22
32
  */
23
- commit(): void;
33
+ commit(dispatcherContext?: unknown): any;
24
34
  /**
25
35
  * Uncommits all events.
26
36
  */
@@ -22,11 +22,19 @@ export function WithAggregateRoot(Base) {
22
22
  get autoCommit() {
23
23
  return this[IS_AUTO_COMMIT_ENABLED];
24
24
  }
25
- publish(event) { }
26
- publishAll(events) { }
27
- commit() {
28
- this.publishAll(this[INTERNAL_EVENTS]);
29
- this[INTERNAL_EVENTS].length = 0;
25
+ publish(event, dispatcherContext) { }
26
+ publishAll(events, dispatcherContext) { }
27
+ commit(dispatcherContext) {
28
+ const events = this[INTERNAL_EVENTS];
29
+ // With a dispatcher context, the caller awaits the publisher, which may read
30
+ // the events after it returns: hand it a copy, as the events are cleared below.
31
+ const result = dispatcherContext === undefined
32
+ ? this.publishAll(events)
33
+ : this.publishAll([...events], dispatcherContext);
34
+ // Cleared once the publisher has been called, not once it settles (as before):
35
+ // a caller that doesn't await must not publish the same events twice.
36
+ events.length = 0;
37
+ return result;
30
38
  }
31
39
  uncommit() {
32
40
  this[INTERNAL_EVENTS].length = 0;
@@ -4,11 +4,15 @@ export class AggregateRootStorage {
4
4
  }
5
5
  static mergeContext(eventBus) {
6
6
  for (const item of this.storage) {
7
- item.prototype.publish = function (event) {
8
- eventBus.publish(event);
7
+ item.prototype.publish = function (event, dispatcherContext) {
8
+ return dispatcherContext === undefined
9
+ ? eventBus.publish(event)
10
+ : eventBus.publish(event, dispatcherContext);
9
11
  };
10
- item.prototype.publishAll = function (events) {
11
- eventBus.publishAll(events);
12
+ item.prototype.publishAll = function (events, dispatcherContext) {
13
+ return dispatcherContext === undefined
14
+ ? eventBus.publishAll(events)
15
+ : eventBus.publishAll(events, dispatcherContext);
12
16
  };
13
17
  }
14
18
  this.storage = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/cqrs",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "A lightweight CQRS module for Nest framework (node.js)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,21 +34,21 @@
34
34
  "test:e2e": "vitest run --config vitest.config.e2e.ts"
35
35
  },
36
36
  "devDependencies": {
37
- "@commitlint/cli": "21.2.2",
38
- "@commitlint/config-angular": "21.2.2",
39
- "@nestjs/common": "12.0.1",
40
- "@nestjs/core": "12.0.1",
41
- "@nestjs/testing": "12.0.1",
42
- "@types/node": "24.13.3",
37
+ "@commitlint/cli": "21.2.3",
38
+ "@commitlint/config-angular": "21.2.3",
39
+ "@nestjs/common": "12.1.1",
40
+ "@nestjs/core": "12.1.1",
41
+ "@nestjs/testing": "12.1.1",
42
+ "@types/node": "24.19.0",
43
43
  "husky": "9.1.7",
44
- "lint-staged": "17.3.0",
45
- "oxlint": "1.80.0",
46
- "prettier": "3.9.6",
44
+ "lint-staged": "17.6.0",
45
+ "oxlint": "1.86.0",
46
+ "prettier": "3.9.9",
47
47
  "reflect-metadata": "0.2.2",
48
- "release-it": "21.0.2",
48
+ "release-it": "21.1.0",
49
49
  "rxjs": "7.8.2",
50
50
  "typescript": "7.0.2",
51
- "vite": "8.2.2",
51
+ "vite": "8.3.1",
52
52
  "vitest": "4.1.11"
53
53
  },
54
54
  "peerDependencies": {
@@ -0,0 +1,243 @@
1
+ import { Inject, Injectable, Module, Scope } from '@nestjs/common';
2
+ import { REQUEST } from '@nestjs/core';
3
+ import { Test, TestingModule } from '@nestjs/testing';
4
+ import { setImmediate } from 'timers/promises';
5
+ import {
6
+ AggregateRoot,
7
+ AsyncContext,
8
+ CommandBus,
9
+ CommandHandler,
10
+ CqrsModule,
11
+ EventBus,
12
+ EventPublisher,
13
+ EventsHandler,
14
+ ICommandHandler,
15
+ IEvent,
16
+ IEventHandler,
17
+ IEventPublisher,
18
+ Publishable,
19
+ } from '../../src/index.js';
20
+ import { waitImmediate } from '../utils/wait-immediate.js';
21
+
22
+ interface Transaction {
23
+ id: string;
24
+ fail?: Error;
25
+ }
26
+
27
+ class OrderPlacedEvent implements IEvent {
28
+ constructor(public readonly orderId: string) {}
29
+ }
30
+
31
+ class Order extends AggregateRoot {
32
+ constructor(public readonly id: string) {
33
+ super();
34
+ }
35
+
36
+ place() {
37
+ this.apply(new OrderPlacedEvent(this.id));
38
+ }
39
+ }
40
+
41
+ @Publishable()
42
+ class PublishableOrder extends Order {}
43
+
44
+ class PlaceOrderCommand {
45
+ constructor(
46
+ public readonly orderId: string,
47
+ public readonly transaction?: Transaction,
48
+ public readonly publishable = false,
49
+ ) {}
50
+ }
51
+
52
+ class ScopedPlaceOrderCommand {
53
+ constructor(
54
+ public readonly orderId: string,
55
+ public readonly transaction: Transaction,
56
+ ) {}
57
+ }
58
+
59
+ /**
60
+ * Stands in for a publisher that writes events through the dispatcher context's transaction
61
+ * (an outbox, or durable workflows) before it hands them to the in-memory handlers.
62
+ */
63
+ @Injectable()
64
+ class TransactionalEventPublisher implements IEventPublisher {
65
+ readonly written: Array<{
66
+ orderIds: string[];
67
+ context: unknown;
68
+ asyncContext?: AsyncContext;
69
+ }> = [];
70
+ private readonly inner: IEventPublisher;
71
+
72
+ constructor(eventBus: EventBus) {
73
+ this.inner = eventBus.publisher;
74
+ eventBus.publisher = this;
75
+ }
76
+
77
+ publish(event: IEvent, context?: unknown, asyncContext?: AsyncContext) {
78
+ return this.publishAll([event], context, asyncContext);
79
+ }
80
+
81
+ async publishAll(
82
+ events: IEvent[],
83
+ context?: unknown,
84
+ asyncContext?: AsyncContext,
85
+ ) {
86
+ await setImmediate();
87
+ const transaction = (context as { transaction?: Transaction } | undefined)
88
+ ?.transaction;
89
+ if (transaction?.fail) {
90
+ throw transaction.fail;
91
+ }
92
+ this.written.push({
93
+ orderIds: events.map((event) => (event as OrderPlacedEvent).orderId),
94
+ context,
95
+ asyncContext,
96
+ });
97
+ events.forEach((event) => this.inner.publish(event, context, asyncContext));
98
+ return `written in ${transaction?.id ?? 'no transaction'}`;
99
+ }
100
+ }
101
+
102
+ @CommandHandler(PlaceOrderCommand)
103
+ class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
104
+ constructor(private readonly publisher: EventPublisher) {}
105
+
106
+ async execute({ orderId, transaction, publishable }: PlaceOrderCommand) {
107
+ const order = publishable
108
+ ? new PublishableOrder(orderId)
109
+ : this.publisher.mergeObjectContext(new Order(orderId));
110
+ order.place();
111
+ return transaction ? order.commit({ transaction }) : order.commit();
112
+ }
113
+ }
114
+
115
+ @CommandHandler(ScopedPlaceOrderCommand, { scope: Scope.REQUEST })
116
+ class ScopedPlaceOrderHandler implements ICommandHandler<ScopedPlaceOrderCommand> {
117
+ constructor(
118
+ private readonly publisher: EventPublisher,
119
+ @Inject(REQUEST) private readonly context: AsyncContext,
120
+ ) {}
121
+
122
+ async execute({ orderId, transaction }: ScopedPlaceOrderCommand) {
123
+ const MergedOrder = this.publisher.mergeClassContext(Order, this.context);
124
+ const order = new MergedOrder(orderId);
125
+ order.place();
126
+ return order.commit({ transaction });
127
+ }
128
+ }
129
+
130
+ @EventsHandler(OrderPlacedEvent)
131
+ class OrderPlacedHandler implements IEventHandler<OrderPlacedEvent> {
132
+ static readonly handled: string[] = [];
133
+
134
+ handle(event: OrderPlacedEvent) {
135
+ OrderPlacedHandler.handled.push(event.orderId);
136
+ }
137
+ }
138
+
139
+ @Module({
140
+ imports: [CqrsModule.forRoot()],
141
+ providers: [
142
+ TransactionalEventPublisher,
143
+ PlaceOrderHandler,
144
+ ScopedPlaceOrderHandler,
145
+ OrderPlacedHandler,
146
+ ],
147
+ })
148
+ class OrdersModule {}
149
+
150
+ describe('Aggregate commit with a dispatcher context', () => {
151
+ let moduleRef: TestingModule;
152
+ let commandBus: CommandBus;
153
+ let publisher: TransactionalEventPublisher;
154
+
155
+ beforeAll(async () => {
156
+ moduleRef = await Test.createTestingModule({
157
+ imports: [OrdersModule],
158
+ }).compile();
159
+ await moduleRef.init();
160
+
161
+ commandBus = moduleRef.get(CommandBus);
162
+ publisher = moduleRef.get(TransactionalEventPublisher);
163
+ });
164
+
165
+ beforeEach(() => {
166
+ publisher.written.length = 0;
167
+ OrderPlacedHandler.handled.length = 0;
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await moduleRef.close();
172
+ });
173
+
174
+ it('awaits the publisher, which receives the transaction and the events', async () => {
175
+ const transaction = { id: 'tx-1' };
176
+
177
+ const result = await commandBus.execute(
178
+ new PlaceOrderCommand('order-1', transaction),
179
+ );
180
+
181
+ expect(result).toBe('written in tx-1');
182
+ expect(publisher.written).toEqual([
183
+ {
184
+ orderIds: ['order-1'],
185
+ context: { transaction },
186
+ asyncContext: undefined,
187
+ },
188
+ ]);
189
+ await waitImmediate();
190
+ expect(OrderPlacedHandler.handled).toEqual(['order-1']);
191
+ });
192
+
193
+ it("rejects with the publisher's error", async () => {
194
+ const fail = new Error('serialization failure');
195
+
196
+ await expect(
197
+ commandBus.execute(
198
+ new PlaceOrderCommand('order-2', { id: 'tx-2', fail }),
199
+ ),
200
+ ).rejects.toBe(fail);
201
+
202
+ await waitImmediate();
203
+ expect(publisher.written).toEqual([]);
204
+ expect(OrderPlacedHandler.handled).toEqual([]);
205
+ });
206
+
207
+ it('passes the transaction from a @Publishable() aggregate', async () => {
208
+ const transaction = { id: 'tx-3' };
209
+
210
+ await commandBus.execute(
211
+ new PlaceOrderCommand('order-3', transaction, true),
212
+ );
213
+
214
+ expect(publisher.written).toEqual([
215
+ {
216
+ orderIds: ['order-3'],
217
+ context: { transaction },
218
+ asyncContext: undefined,
219
+ },
220
+ ]);
221
+ });
222
+
223
+ it('keeps the async context of a request-scoped handler', async () => {
224
+ const asyncContext = new AsyncContext();
225
+ const transaction = { id: 'tx-4' };
226
+
227
+ await commandBus.execute(
228
+ new ScopedPlaceOrderCommand('order-4', transaction),
229
+ asyncContext,
230
+ );
231
+
232
+ expect(publisher.written).toEqual([
233
+ { orderIds: ['order-4'], context: { transaction }, asyncContext },
234
+ ]);
235
+ });
236
+
237
+ it('keeps passing the aggregate as the dispatcher context without one', async () => {
238
+ await commandBus.execute(new PlaceOrderCommand('order-5'));
239
+
240
+ expect(publisher.written).toHaveLength(1);
241
+ expect(publisher.written[0].context).toBeInstanceOf(Order);
242
+ });
243
+ });
@@ -1,3 +1,4 @@
1
+ import { Logger } from '@nestjs/common';
1
2
  import { Test, TestingModule } from '@nestjs/testing';
2
3
  import { take } from 'rxjs';
3
4
  import { CommandBus, UnhandledExceptionBus } from '../../src/index.js';
@@ -57,6 +58,24 @@ describe('Unhandled exceptions', () => {
57
58
 
58
59
  await exceptionPromise;
59
60
  });
61
+
62
+ it('should log the event handler class name', async () => {
63
+ const loggerSpy = vi.spyOn(Logger.prototype, 'error');
64
+ const command = new UnhandledExceptionCommand('event');
65
+ const commandBus = moduleRef.get(CommandBus);
66
+
67
+ const exceptionPromise = new Promise<void>((resolve) => {
68
+ unhandledExceptionBus.pipe(take(1)).subscribe(() => resolve());
69
+ });
70
+ await commandBus.execute(command);
71
+ await exceptionPromise;
72
+
73
+ expect(loggerSpy).toHaveBeenCalledWith(
74
+ '"UnhandledExceptionEventHandler" has thrown an unhandled exception.',
75
+ new Error(`Unhandled exception in ${command.failAt}`),
76
+ );
77
+ loggerSpy.mockRestore();
78
+ });
60
79
  });
61
80
 
62
81
  describe('when exception is thrown from saga', () => {