@fluojs/cqrs 1.1.1 → 2.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.
Files changed (44) hide show
  1. package/README.ko.md +69 -7
  2. package/README.md +69 -7
  3. package/dist/buses/command-bus.d.ts +9 -2
  4. package/dist/buses/command-bus.d.ts.map +1 -1
  5. package/dist/buses/command-bus.js +54 -24
  6. package/dist/buses/event-bus.d.ts +7 -7
  7. package/dist/buses/event-bus.d.ts.map +1 -1
  8. package/dist/buses/event-bus.js +55 -68
  9. package/dist/buses/event-handler-discovery.d.ts +12 -0
  10. package/dist/buses/event-handler-discovery.d.ts.map +1 -0
  11. package/dist/buses/event-handler-discovery.js +35 -0
  12. package/dist/buses/publish-drain-tracker.d.ts +38 -0
  13. package/dist/buses/publish-drain-tracker.d.ts.map +1 -0
  14. package/dist/buses/publish-drain-tracker.js +86 -0
  15. package/dist/buses/query-bus.d.ts +9 -2
  16. package/dist/buses/query-bus.d.ts.map +1 -1
  17. package/dist/buses/query-bus.js +54 -24
  18. package/dist/buses/saga-bus.d.ts +10 -6
  19. package/dist/buses/saga-bus.d.ts.map +1 -1
  20. package/dist/buses/saga-bus.js +41 -82
  21. package/dist/buses/saga-discovery.d.ts +12 -0
  22. package/dist/buses/saga-discovery.d.ts.map +1 -0
  23. package/dist/buses/saga-discovery.js +39 -0
  24. package/dist/buses/saga-drain.d.ts +9 -0
  25. package/dist/buses/saga-drain.d.ts.map +1 -0
  26. package/dist/buses/saga-drain.js +34 -0
  27. package/dist/buses/saga-topology.d.ts +15 -0
  28. package/dist/buses/saga-topology.d.ts.map +1 -0
  29. package/dist/buses/saga-topology.js +39 -0
  30. package/dist/discovery.d.ts +16 -0
  31. package/dist/discovery.d.ts.map +1 -1
  32. package/dist/discovery.js +16 -9
  33. package/dist/dispatch-context.d.ts +33 -0
  34. package/dist/dispatch-context.d.ts.map +1 -0
  35. package/dist/dispatch-context.js +42 -0
  36. package/dist/module.d.ts +0 -8
  37. package/dist/module.d.ts.map +1 -1
  38. package/dist/module.js +9 -2
  39. package/dist/test-setup.d.ts +2 -0
  40. package/dist/test-setup.d.ts.map +1 -0
  41. package/dist/test-setup.js +4 -0
  42. package/dist/types.d.ts +4 -11
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +5 -5
@@ -5,14 +5,18 @@ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e =
5
5
  function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
6
6
  function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
7
7
  import { FluoError, Inject, InvariantError } from '@fluojs/core';
8
- import { APPLICATION_LOGGER, COMPILED_MODULES, RUNTIME_CONTAINER } from '@fluojs/runtime/internal';
8
+ import { APPLICATION_LOGGER, COMPILED_MODULES, RUNTIME_CLEANUP_REGISTRATION, RUNTIME_CONTAINER } from '@fluojs/runtime/internal';
9
9
  import { CqrsBusBase } from '../discovery.js';
10
- import { SagaExecutionError, SagaTopologyError } from '../errors.js';
10
+ import { SagaExecutionError } from '../errors.js';
11
11
  import { createIsolatedEvent } from '../event-clone.js';
12
- import { getSagaMetadata } from '../metadata.js';
13
12
  import { CQRS_MODULE_OPTIONS } from '../tokens.js';
14
- const MAX_NESTED_SAGA_DEPTH = 32;
13
+ import { discoverSagaDescriptors } from './saga-discovery.js';
14
+ import { drainPendingSagaDispatches } from './saga-drain.js';
15
+ import { enterSagaTopology } from './saga-topology.js';
15
16
  const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
17
+
18
+ /** Private capability authorizing saga dispatch from an already active publish drain. */
19
+ export const CQRS_SAGA_DRAIN_AUTHORIZATION = Symbol('fluo.cqrs.sagaDrainAuthorization');
16
20
  function isSaga(value) {
17
21
  if (typeof value !== 'object' || value === null) {
18
22
  return false;
@@ -29,13 +33,13 @@ function toErrorMessage(error) {
29
33
  /**
30
34
  * Runtime saga coordinator that discovers `@Saga()` providers and serializes execution per saga token.
31
35
  *
32
- * The service prevents re-entrant dispatch loops within the same async context and waits for
36
+ * The service prevents re-entrant dispatch loops within the same explicit dispatch context and waits for
33
37
  * in-flight saga chains during shutdown so lifecycle guarantees remain predictable.
34
38
  */
35
39
  let _CqrsSagaLifecycleSer;
36
40
  class CqrsSagaLifecycleService extends CqrsBusBase {
37
41
  static {
38
- [_CqrsSagaLifecycleSer, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, CQRS_MODULE_OPTIONS)], [], 0, void 0, CqrsBusBase).c;
42
+ [_CqrsSagaLifecycleSer, _initClass] = _applyDecs(this, [Inject(RUNTIME_CONTAINER, COMPILED_MODULES, APPLICATION_LOGGER, CQRS_MODULE_OPTIONS, RUNTIME_CLEANUP_REGISTRATION)], [], 0, void 0, CqrsBusBase).c;
39
43
  }
40
44
  descriptorsByEvent = new Map();
41
45
  discoveryPromise;
@@ -44,9 +48,13 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
44
48
  lifecycleState = 'created';
45
49
  pendingDispatches = new Set();
46
50
  shutdownDrainTimeouts = 0;
47
- constructor(runtimeContainer, compiledModules, logger, moduleOptions = {}) {
51
+ unregisterShutdownStartCleanup;
52
+ constructor(runtimeContainer, compiledModules, logger, moduleOptions = {}, registerRuntimeCleanup = () => () => undefined) {
48
53
  super(runtimeContainer, compiledModules, logger);
49
54
  this.moduleOptions = moduleOptions;
55
+ this.unregisterShutdownStartCleanup = registerRuntimeCleanup(() => {
56
+ this.markApplicationShutdownStarted();
57
+ });
50
58
  }
51
59
  async onApplicationBootstrap() {
52
60
  this.lifecycleState = 'discovering';
@@ -59,7 +67,9 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
59
67
  }
60
68
  }
61
69
  async onApplicationShutdown() {
62
- this.lifecycleState = 'stopping';
70
+ this.markApplicationShutdownStarted();
71
+ this.unregisterShutdownStartCleanup?.();
72
+ this.unregisterShutdownStartCleanup = undefined;
63
73
  await this.drainActiveSagaWork();
64
74
  this.executionChains.clear();
65
75
  this.handlerInstances.clear();
@@ -90,7 +100,8 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
90
100
  * @param event Event instance that may trigger one or more sagas.
91
101
  * @returns A promise that resolves once all matching saga chains for the event complete.
92
102
  */
93
- async dispatch(event, context) {
103
+ async dispatch(event, context, drainAuthorization) {
104
+ this.assertAcceptingNewWork(drainAuthorization);
94
105
  await this.ensureDiscovered();
95
106
  const descriptors = this.matchSagaDescriptors(event);
96
107
  if (descriptors.length === 0) {
@@ -98,6 +109,16 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
98
109
  }
99
110
  await Promise.all(descriptors.map(descriptor => this.dispatchWithOrdering(descriptor, event, context)));
100
111
  }
112
+ assertAcceptingNewWork(drainAuthorization) {
113
+ if ((this.lifecycleState === 'stopping' || this.lifecycleState === 'stopped') && drainAuthorization !== CQRS_SAGA_DRAIN_AUTHORIZATION) {
114
+ throw new InvariantError('CQRS saga bus cannot dispatch after shutdown has started.');
115
+ }
116
+ }
117
+ markApplicationShutdownStarted() {
118
+ if (this.lifecycleState !== 'stopped') {
119
+ this.lifecycleState = 'stopping';
120
+ }
121
+ }
101
122
  matchSagaDescriptors(event) {
102
123
  const descriptors = [];
103
124
  for (const [eventType, eventDescriptors] of this.descriptorsByEvent.entries()) {
@@ -108,22 +129,14 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
108
129
  return descriptors;
109
130
  }
110
131
  async dispatchWithOrdering(descriptor, event, activeContext) {
111
- const routeLabel = `${descriptor.targetType.name}(${descriptor.eventType.name})`;
112
- const isActiveRoute = activeContext?.activeRoutes.some(route => route.token === descriptor.token && route.eventType === descriptor.eventType);
113
- const isActiveToken = activeContext?.activeRoutes.some(route => route.token === descriptor.token) ?? false;
114
- if (isActiveRoute) {
115
- throw new SagaTopologyError(`Saga ${descriptor.targetType.name} re-entered an unsafe cycle while handling ${descriptor.eventType.name}. ` + `Active saga path: ${[...(activeContext?.path ?? []), routeLabel].join(' -> ')}.`);
116
- }
117
- if ((activeContext?.depth ?? 0) >= MAX_NESTED_SAGA_DEPTH) {
118
- throw new SagaTopologyError(`Saga ${descriptor.targetType.name} exceeded the maximum nested saga depth of ${MAX_NESTED_SAGA_DEPTH} while handling ${descriptor.eventType.name}. ` + 'Keep in-process saga graphs acyclic and externally bounded.');
119
- }
120
- if (isActiveToken) {
121
- await this.invokeSaga(descriptor, event, this.createDispatchContext(activeContext, descriptor, routeLabel));
132
+ const topology = enterSagaTopology(activeContext, descriptor);
133
+ if (topology.reentrantToken) {
134
+ await this.invokeSaga(descriptor, event, topology.context);
122
135
  return;
123
136
  }
124
137
  const previous = this.executionChains.get(descriptor.token) ?? Promise.resolve();
125
138
  const current = previous.then(async () => {
126
- await this.invokeSaga(descriptor, event, this.createDispatchContext(activeContext, descriptor, routeLabel));
139
+ await this.invokeSaga(descriptor, event, topology.context);
127
140
  });
128
141
  this.executionChains.set(descriptor.token, current.catch(() => undefined));
129
142
  this.pendingDispatches.add(current);
@@ -134,30 +147,16 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
134
147
  }
135
148
  }
136
149
  async drainActiveSagaWork() {
137
- const activeWork = [...this.pendingDispatches, ...this.executionChains.values()];
138
- if (activeWork.length === 0) {
139
- return;
140
- }
141
150
  const timeoutMs = this.resolveShutdownDrainTimeoutMs();
142
- const drained = await this.awaitShutdownDrain(activeWork, timeoutMs);
151
+ const activeWorkCount = this.pendingDispatches.size;
152
+ const drained = await drainPendingSagaDispatches(this.pendingDispatches, timeoutMs);
143
153
  if (!drained) {
144
- this.shutdownDrainTimeouts += 1;
145
- this.logger.warn(`CQRS saga shutdown drain exceeded ${String(timeoutMs)}ms with ${String(activeWork.length)} active saga task(s); continuing shutdown.`, 'CqrsSagaLifecycleService');
154
+ this.reportShutdownDrainTimeout(timeoutMs, activeWorkCount);
146
155
  }
147
156
  }
148
- async awaitShutdownDrain(activeWork, timeoutMs) {
149
- let timeoutId;
150
- const timeout = new Promise(resolve => {
151
- timeoutId = setTimeout(() => resolve(false), timeoutMs);
152
- });
153
- const drain = Promise.allSettled(activeWork).then(() => true);
154
- try {
155
- return await Promise.race([drain, timeout]);
156
- } finally {
157
- if (timeoutId) {
158
- clearTimeout(timeoutId);
159
- }
160
- }
157
+ reportShutdownDrainTimeout(timeoutMs, activeWorkCount) {
158
+ this.shutdownDrainTimeouts += 1;
159
+ this.logger.warn(`CQRS saga shutdown drain exceeded ${String(timeoutMs)}ms with ${String(activeWorkCount)} active saga task(s); continuing shutdown.`, 'CqrsSagaLifecycleService');
161
160
  }
162
161
  resolveShutdownDrainTimeoutMs() {
163
162
  const timeoutMs = this.moduleOptions.shutdown?.drainTimeoutMs;
@@ -166,16 +165,6 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
166
165
  }
167
166
  return Math.floor(timeoutMs);
168
167
  }
169
- createDispatchContext(activeContext, descriptor, routeLabel) {
170
- return {
171
- activeRoutes: [...(activeContext?.activeRoutes ?? []), {
172
- eventType: descriptor.eventType,
173
- token: descriptor.token
174
- }],
175
- depth: (activeContext?.depth ?? 0) + 1,
176
- path: [...(activeContext?.path ?? []), routeLabel]
177
- };
178
- }
179
168
  async invokeSaga(descriptor, event, context) {
180
169
  const instance = await this.resolveHandlerInstance(descriptor.token);
181
170
  if (!isSaga(instance)) {
@@ -216,37 +205,7 @@ class CqrsSagaLifecycleService extends CqrsBusBase {
216
205
  }
217
206
  }
218
207
  discoverSagaDescriptors() {
219
- const descriptorsByEvent = new Map();
220
- const seenByTarget = new WeakMap();
221
- for (const candidate of this.discoveryCandidates()) {
222
- const metadata = getSagaMetadata(candidate.targetType);
223
- if (!metadata) {
224
- continue;
225
- }
226
- if (candidate.scope !== 'singleton') {
227
- this.logger.warn(`${candidate.targetType.name} in module ${candidate.moduleName} declares @Saga() but is registered with ${candidate.scope} scope. Sagas are registered only for singleton providers.`, 'CqrsSagaLifecycleService');
228
- continue;
229
- }
230
- const seenEventTypes = seenByTarget.get(candidate.targetType) ?? new Set();
231
- for (const eventType of metadata.eventTypes) {
232
- if (seenEventTypes.has(eventType)) {
233
- continue;
234
- }
235
- seenEventTypes.add(eventType);
236
- const descriptors = descriptorsByEvent.get(eventType) ?? [];
237
- if (!descriptors.some(descriptor => descriptor.targetType === candidate.targetType)) {
238
- descriptors.push({
239
- eventType,
240
- moduleName: candidate.moduleName,
241
- targetType: candidate.targetType,
242
- token: candidate.token
243
- });
244
- descriptorsByEvent.set(eventType, descriptors);
245
- }
246
- }
247
- seenByTarget.set(candidate.targetType, seenEventTypes);
248
- }
249
- return descriptorsByEvent;
208
+ return discoverSagaDescriptors(this.discoveryCandidates(), this.logger);
250
209
  }
251
210
  static {
252
211
  _initClass();
@@ -0,0 +1,12 @@
1
+ import type { ApplicationLogger } from '@fluojs/runtime';
2
+ import type { DiscoveryCandidate } from '../discovery.js';
3
+ import type { CqrsEventType, SagaDescriptor } from '../types.js';
4
+ /**
5
+ * Discovers singleton saga registrations by provider-token identity.
6
+ *
7
+ * @param candidates Provider registrations compiled from the application module graph.
8
+ * @param logger Application logger used for non-singleton discovery warnings.
9
+ * @returns Saga descriptors grouped by event type in discovery order.
10
+ */
11
+ export declare function discoverSagaDescriptors(candidates: readonly DiscoveryCandidate[], logger: ApplicationLogger): Map<CqrsEventType, SagaDescriptor[]>;
12
+ //# sourceMappingURL=saga-discovery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saga-discovery.d.ts","sourceRoot":"","sources":["../../src/buses/saga-discovery.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAE1D,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEjE;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,SAAS,kBAAkB,EAAE,EACzC,MAAM,EAAE,iBAAiB,GACxB,GAAG,CAAC,aAAa,EAAE,cAAc,EAAE,CAAC,CAyCtC"}
@@ -0,0 +1,39 @@
1
+ import { getSagaMetadata } from '../metadata.js';
2
+ /**
3
+ * Discovers singleton saga registrations by provider-token identity.
4
+ *
5
+ * @param candidates Provider registrations compiled from the application module graph.
6
+ * @param logger Application logger used for non-singleton discovery warnings.
7
+ * @returns Saga descriptors grouped by event type in discovery order.
8
+ */
9
+ export function discoverSagaDescriptors(candidates, logger) {
10
+ const descriptorsByEvent = new Map();
11
+ const seenEventTypesByToken = new Map();
12
+ for (const candidate of candidates) {
13
+ const metadata = getSagaMetadata(candidate.targetType);
14
+ if (!metadata) {
15
+ continue;
16
+ }
17
+ if (candidate.scope !== 'singleton') {
18
+ logger.warn(`${candidate.targetType.name} in module ${candidate.moduleName} declares @Saga() but is registered with ${candidate.scope} scope. Sagas are registered only for singleton providers.`, 'CqrsSagaLifecycleService');
19
+ continue;
20
+ }
21
+ const seenEventTypes = seenEventTypesByToken.get(candidate.token) ?? new Set();
22
+ for (const eventType of metadata.eventTypes) {
23
+ if (seenEventTypes.has(eventType)) {
24
+ continue;
25
+ }
26
+ seenEventTypes.add(eventType);
27
+ const descriptors = descriptorsByEvent.get(eventType) ?? [];
28
+ descriptors.push({
29
+ eventType,
30
+ moduleName: candidate.moduleName,
31
+ targetType: candidate.targetType,
32
+ token: candidate.token
33
+ });
34
+ descriptorsByEvent.set(eventType, descriptors);
35
+ }
36
+ seenEventTypesByToken.set(candidate.token, seenEventTypes);
37
+ }
38
+ return descriptorsByEvent;
39
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Waits until a mutable set of saga tasks becomes quiescent within one deadline.
3
+ *
4
+ * @param pendingDispatches Live saga task set owned by the lifecycle service.
5
+ * @param timeoutMs Maximum drain duration in milliseconds.
6
+ * @returns `true` when all current and late-added tasks settle before the deadline.
7
+ */
8
+ export declare function drainPendingSagaDispatches(pendingDispatches: ReadonlySet<Promise<void>>, timeoutMs: number): Promise<boolean>;
9
+ //# sourceMappingURL=saga-drain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saga-drain.d.ts","sourceRoot":"","sources":["../../src/buses/saga-drain.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC9C,iBAAiB,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EAC7C,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC,CAgBlB"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Waits until a mutable set of saga tasks becomes quiescent within one deadline.
3
+ *
4
+ * @param pendingDispatches Live saga task set owned by the lifecycle service.
5
+ * @param timeoutMs Maximum drain duration in milliseconds.
6
+ * @returns `true` when all current and late-added tasks settle before the deadline.
7
+ */
8
+ export async function drainPendingSagaDispatches(pendingDispatches, timeoutMs) {
9
+ const deadline = Date.now() + timeoutMs;
10
+ while (pendingDispatches.size > 0) {
11
+ const remainingTimeoutMs = deadline - Date.now();
12
+ if (remainingTimeoutMs <= 0) {
13
+ return false;
14
+ }
15
+ if (!(await awaitSagaTasks([...pendingDispatches], remainingTimeoutMs))) {
16
+ return false;
17
+ }
18
+ }
19
+ return true;
20
+ }
21
+ async function awaitSagaTasks(activeWork, timeoutMs) {
22
+ let timeoutId;
23
+ const timeout = new Promise(resolve => {
24
+ timeoutId = setTimeout(() => resolve(false), timeoutMs);
25
+ });
26
+ const drain = Promise.allSettled(activeWork).then(() => true);
27
+ try {
28
+ return await Promise.race([drain, timeout]);
29
+ } finally {
30
+ if (timeoutId) {
31
+ clearTimeout(timeoutId);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,15 @@
1
+ import type { CqrsDispatchContext, SagaDescriptor } from '../types.js';
2
+ /** Result of entering one guarded saga route. */
3
+ export interface SagaTopologyEntry {
4
+ readonly context: CqrsDispatchContext;
5
+ readonly reentrantToken: boolean;
6
+ }
7
+ /**
8
+ * Validates and enters one saga route using private immutable context state.
9
+ *
10
+ * @param context Opaque context passed through the active CQRS pipeline.
11
+ * @param descriptor Saga route selected for the current event.
12
+ * @returns The next opaque context and whether the same provider token is already active.
13
+ */
14
+ export declare function enterSagaTopology(context: CqrsDispatchContext | undefined, descriptor: SagaDescriptor): SagaTopologyEntry;
15
+ //# sourceMappingURL=saga-topology.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saga-topology.d.ts","sourceRoot":"","sources":["../../src/buses/saga-topology.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAIvE,iDAAiD;AACjD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,mBAAmB,GAAG,SAAS,EACxC,UAAU,EAAE,cAAc,GACzB,iBAAiB,CAiCnB"}
@@ -0,0 +1,39 @@
1
+ import { createInternalCqrsDispatchContext, getInternalCqrsDispatchContextState } from '../dispatch-context.js';
2
+ import { SagaTopologyError } from '../errors.js';
3
+ const MAX_NESTED_SAGA_DEPTH = 32;
4
+
5
+ /** Result of entering one guarded saga route. */
6
+
7
+ /**
8
+ * Validates and enters one saga route using private immutable context state.
9
+ *
10
+ * @param context Opaque context passed through the active CQRS pipeline.
11
+ * @param descriptor Saga route selected for the current event.
12
+ * @returns The next opaque context and whether the same provider token is already active.
13
+ */
14
+ export function enterSagaTopology(context, descriptor) {
15
+ const internalState = getInternalCqrsDispatchContextState(context);
16
+ const activeTopology = internalState?.sagaTopology;
17
+ const routeLabel = `${descriptor.targetType.name}(${descriptor.eventType.name})`;
18
+ const reenteredRoute = activeTopology?.activeRoutes.some(route => route.token === descriptor.token && route.eventType === descriptor.eventType);
19
+ if (reenteredRoute) {
20
+ throw new SagaTopologyError(`Saga ${descriptor.targetType.name} re-entered an unsafe cycle while handling ${descriptor.eventType.name}. ` + `Active saga path: ${[...(activeTopology?.path ?? []), routeLabel].join(' -> ')}.`);
21
+ }
22
+ if ((activeTopology?.depth ?? 0) >= MAX_NESTED_SAGA_DEPTH) {
23
+ throw new SagaTopologyError(`Saga ${descriptor.targetType.name} exceeded the maximum nested saga depth of ${MAX_NESTED_SAGA_DEPTH} while handling ${descriptor.eventType.name}. ` + 'Keep in-process saga graphs acyclic and externally bounded.');
24
+ }
25
+ return {
26
+ context: createInternalCqrsDispatchContext({
27
+ publishDrainToken: internalState?.publishDrainToken,
28
+ sagaTopology: {
29
+ activeRoutes: [...(activeTopology?.activeRoutes ?? []), {
30
+ eventType: descriptor.eventType,
31
+ token: descriptor.token
32
+ }],
33
+ depth: (activeTopology?.depth ?? 0) + 1,
34
+ path: [...(activeTopology?.path ?? []), routeLabel]
35
+ }
36
+ }),
37
+ reentrantToken: activeTopology?.activeRoutes.some(route => route.token === descriptor.token) ?? false
38
+ };
39
+ }
@@ -22,10 +22,26 @@ export interface DiscoveryCandidate {
22
22
  export declare function createDuplicateHandlerMessage(kind: 'command' | 'query' | 'event', messageType: Function, first: {
23
23
  moduleName: string;
24
24
  targetType: Function;
25
+ token: Token;
25
26
  }, second: {
26
27
  moduleName: string;
27
28
  targetType: Function;
29
+ token: Token;
28
30
  }): string;
31
+ /**
32
+ * Checks whether two discovered handler candidates refer to the same provider registration.
33
+ *
34
+ * @param first The first handler registration.
35
+ * @param second The second handler registration.
36
+ * @returns Whether both target type and provider token match.
37
+ */
38
+ export declare function isSameHandlerRegistration(first: {
39
+ targetType: Function;
40
+ token: Token;
41
+ }, second: {
42
+ targetType: Function;
43
+ token: Token;
44
+ }): boolean;
29
45
  /**
30
46
  * Represents the cqrs bus base.
31
47
  */
@@ -1 +1 @@
1
- {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,KAAK,EAAE,SAAS,EAAY,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,CAAC;IAC7C,UAAU,EAAE,QAAQ,CAAC;IACrB,KAAK,EAAE,KAAK,CAAC;CACd;AAkBD;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,EACnC,WAAW,EAAE,QAAQ,EACrB,KAAK,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,QAAQ,CAAA;CAAE,EACnD,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,QAAQ,CAAA;CAAE,GACnD,MAAM,CAER;AAED;;GAEG;AACH,8BAAsB,WAAW;IAI7B,SAAS,CAAC,QAAQ,CAAC,gBAAgB,EAAE,SAAS;IAC9C,SAAS,CAAC,QAAQ,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE;IAC7D,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,iBAAiB;IAL9C,SAAS,CAAC,QAAQ,CAAC,gBAAgB,+BAAsC;gBAGpD,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB;IAG9C,SAAS,CAAC,mBAAmB,IAAI,kBAAkB,EAAE;cAsCrC,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;cAgBnD,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC;CAiBvE"}
1
+ {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,KAAK,EAAE,MAAM,cAAc,CAAC;AAE3D,OAAO,KAAK,EAAE,SAAS,EAAY,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,CAAC;IAC7C,UAAU,EAAE,QAAQ,CAAC;IACrB,KAAK,EAAE,KAAK,CAAC;CACd;AAkBD;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,EACnC,WAAW,EAAE,QAAQ,EACrB,KAAK,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,EACjE,MAAM,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GACjE,MAAM,CAER;AAMD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE;IAAE,UAAU,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,EAC7C,MAAM,EAAE;IAAE,UAAU,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAC7C,OAAO,CAET;AAED;;GAEG;AACH,8BAAsB,WAAW;IAI7B,SAAS,CAAC,QAAQ,CAAC,gBAAgB,EAAE,SAAS;IAC9C,SAAS,CAAC,QAAQ,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE;IAC7D,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,iBAAiB;IAL9C,SAAS,CAAC,QAAQ,CAAC,gBAAgB,+BAAsC;gBAGpD,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB;IAG9C,SAAS,CAAC,mBAAmB,IAAI,kBAAkB,EAAE;cA6BrC,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;cAgBnD,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC;CAiBvE"}
package/dist/discovery.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { formatTokenName } from '@fluojs/core';
1
2
  import { getClassDiMetadata } from '@fluojs/core/internal';
2
3
 
3
4
  /**
@@ -27,7 +28,21 @@ function isClassProvider(provider) {
27
28
  * @returns The create duplicate handler message result.
28
29
  */
29
30
  export function createDuplicateHandlerMessage(kind, messageType, first, second) {
30
- return `Duplicate ${kind} handler for ${messageType.name} was discovered in ${first.moduleName}.${first.targetType.name} and ${second.moduleName}.${second.targetType.name}.`;
31
+ return `Duplicate ${kind} handler for ${messageType.name} was discovered in ${describeHandlerRegistration(first)} and ${describeHandlerRegistration(second)}.`;
32
+ }
33
+ function describeHandlerRegistration(registration) {
34
+ return `${registration.moduleName}.${registration.targetType.name} [token: ${formatTokenName(registration.token)}]`;
35
+ }
36
+
37
+ /**
38
+ * Checks whether two discovered handler candidates refer to the same provider registration.
39
+ *
40
+ * @param first The first handler registration.
41
+ * @param second The second handler registration.
42
+ * @returns Whether both target type and provider token match.
43
+ */
44
+ export function isSameHandlerRegistration(first, second) {
45
+ return first.targetType === second.targetType && first.token === second.token;
31
46
  }
32
47
 
33
48
  /**
@@ -62,14 +77,6 @@ export class CqrsBusBase {
62
77
  });
63
78
  }
64
79
  }
65
- for (const controller of compiledModule.definition.controllers ?? []) {
66
- candidates.push({
67
- moduleName: compiledModule.type.name,
68
- scope: scopeFromProvider(controller),
69
- targetType: controller,
70
- token: controller
71
- });
72
- }
73
80
  }
74
81
  return candidates;
75
82
  }
@@ -0,0 +1,33 @@
1
+ import type { Token } from '@fluojs/core';
2
+ import type { CqrsDispatchContext, CqrsEventType } from './types.js';
3
+ /** One active saga route retained in private CQRS dispatch state. */
4
+ export interface CqrsDispatchRoute {
5
+ readonly eventType: CqrsEventType;
6
+ readonly token: Token;
7
+ }
8
+ /** Private saga topology state associated with an opaque dispatch context. */
9
+ export interface CqrsSagaTopologyState {
10
+ readonly activeRoutes: readonly CqrsDispatchRoute[];
11
+ readonly depth: number;
12
+ readonly path: readonly string[];
13
+ }
14
+ /** Private state carried by an internally created dispatch context. */
15
+ export interface InternalCqrsDispatchContextState {
16
+ readonly publishDrainToken: symbol | undefined;
17
+ readonly sagaTopology: CqrsSagaTopologyState | undefined;
18
+ }
19
+ /**
20
+ * Creates an opaque immutable dispatch context and retains its state in a private weak map.
21
+ *
22
+ * @param state Internal publish-drain and saga-topology state.
23
+ * @returns A frozen fieldless context safe to pass through application handlers.
24
+ */
25
+ export declare function createInternalCqrsDispatchContext(state: InternalCqrsDispatchContextState): CqrsDispatchContext;
26
+ /**
27
+ * Reads private state only for context values created by CQRS internals.
28
+ *
29
+ * @param context Optional public dispatch context received from a handler or saga.
30
+ * @returns The immutable internal state, or `undefined` for caller-created values.
31
+ */
32
+ export declare function getInternalCqrsDispatchContextState(context: CqrsDispatchContext | undefined): InternalCqrsDispatchContextState | undefined;
33
+ //# sourceMappingURL=dispatch-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatch-context.d.ts","sourceRoot":"","sources":["../src/dispatch-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAE1C,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAErE,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;CACvB;AAED,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,YAAY,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,uEAAuE;AACvE,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,YAAY,EAAE,qBAAqB,GAAG,SAAS,CAAC;CAC1D;AAcD;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,gCAAgC,GAAG,mBAAmB,CAU9G;AAED;;;;;GAKG;AACH,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,mBAAmB,GAAG,SAAS,GACvC,gCAAgC,GAAG,SAAS,CAE9C"}
@@ -0,0 +1,42 @@
1
+ /** One active saga route retained in private CQRS dispatch state. */
2
+
3
+ /** Private saga topology state associated with an opaque dispatch context. */
4
+
5
+ /** Private state carried by an internally created dispatch context. */
6
+
7
+ const internalContextStates = new WeakMap();
8
+ function freezeSagaTopology(state) {
9
+ return Object.freeze({
10
+ activeRoutes: Object.freeze(state.activeRoutes.map(route => Object.freeze({
11
+ eventType: route.eventType,
12
+ token: route.token
13
+ }))),
14
+ depth: state.depth,
15
+ path: Object.freeze([...state.path])
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Creates an opaque immutable dispatch context and retains its state in a private weak map.
21
+ *
22
+ * @param state Internal publish-drain and saga-topology state.
23
+ * @returns A frozen fieldless context safe to pass through application handlers.
24
+ */
25
+ export function createInternalCqrsDispatchContext(state) {
26
+ const context = Object.freeze({});
27
+ internalContextStates.set(context, Object.freeze({
28
+ publishDrainToken: state.publishDrainToken,
29
+ sagaTopology: state.sagaTopology ? freezeSagaTopology(state.sagaTopology) : undefined
30
+ }));
31
+ return context;
32
+ }
33
+
34
+ /**
35
+ * Reads private state only for context values created by CQRS internals.
36
+ *
37
+ * @param context Optional public dispatch context received from a handler or saga.
38
+ * @returns The immutable internal state, or `undefined` for caller-created values.
39
+ */
40
+ export function getInternalCqrsDispatchContextState(context) {
41
+ return context ? internalContextStates.get(context) : undefined;
42
+ }
package/dist/module.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Provider } from '@fluojs/di';
2
1
  import { type EventBusModuleOptions } from '@fluojs/event-bus';
3
2
  import { type ModuleType } from '@fluojs/runtime';
4
3
  import type { CommandHandlerClass, EventHandlerClass, QueryHandlerClass, SagaClass } from './types.js';
@@ -16,13 +15,6 @@ export interface CqrsModuleOptions {
16
15
  drainTimeoutMs?: number;
17
16
  };
18
17
  }
19
- /**
20
- * Creates the providers required for CQRS buses, compatibility aliases, and optional handler registration.
21
- *
22
- * @param options CQRS module options including eager handler classes and event-bus configuration.
23
- * @returns Providers for the command, query, event, and saga runtimes plus compatibility tokens.
24
- */
25
- export declare function createCqrsProviders(options?: CqrsModuleOptions): Provider[];
26
18
  /** Runtime module entrypoint for CQRS bus registration and handler discovery. */
27
19
  export declare class CqrsModule {
28
20
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAkB,KAAK,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC/E,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAOhE,OAAO,KAAK,EACV,mBAAmB,EAEnB,iBAAiB,EAIjB,iBAAiB,EACjB,SAAS,EACV,MAAM,YAAY,CAAC;AAEpB,4FAA4F;AAC5F,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACjD,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,iFAAiF;IACjF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,KAAK,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAC7B,8GAA8G;IAC9G,QAAQ,CAAC,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AA0CD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,iBAAsB,GAAG,QAAQ,EAAE,CA8C/E;AAED,iFAAiF;AACjF,qBAAa,UAAU;IACrB;;;;;OAKG;IACH,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU;CAiB5D"}
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AACA,OAAO,EAAkB,KAAK,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC/E,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAOhE,OAAO,KAAK,EACV,mBAAmB,EAEnB,iBAAiB,EAIjB,iBAAiB,EACjB,SAAS,EACV,MAAM,YAAY,CAAC;AAEpB,4FAA4F;AAC5F,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACjD,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,aAAa,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,iFAAiF;IACjF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,KAAK,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAC7B,8GAA8G;IAC9G,QAAQ,CAAC,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAyGD,iFAAiF;AACjF,qBAAa,UAAU;IACrB;;;;;OAKG;IACH,MAAM,CAAC,OAAO,CAAC,OAAO,GAAE,iBAAsB,GAAG,UAAU;CAiB5D"}
package/dist/module.js CHANGED
@@ -24,6 +24,13 @@ function collectOptionHandlerProviders(options) {
24
24
  }
25
25
  return providers;
26
26
  }
27
+ function resolveDelegatedEventBusOptions(options) {
28
+ const eventBusOptions = options.eventBus ?? {};
29
+ return {
30
+ ...eventBusOptions,
31
+ global: eventBusOptions.global ?? options.global ?? true
32
+ };
33
+ }
27
34
  function assertCommandBusService(service) {
28
35
  if (!(service instanceof CommandBusLifecycleService)) {
29
36
  throw new TypeError('CQRS command bus alias expected CommandBusLifecycleService.');
@@ -46,7 +53,7 @@ function assertCqrsEventBusService(service) {
46
53
  * @param options CQRS module options including eager handler classes and event-bus configuration.
47
54
  * @returns Providers for the command, query, event, and saga runtimes plus compatibility tokens.
48
55
  */
49
- export function createCqrsProviders(options = {}) {
56
+ function createCqrsProviders(options = {}) {
50
57
  return [{
51
58
  provide: CQRS_MODULE_OPTIONS,
52
59
  useValue: options
@@ -94,7 +101,7 @@ export class CqrsModule {
94
101
  return defineModule(CqrsModuleDefinition, {
95
102
  exports: [CommandBusLifecycleService, QueryBusLifecycleService, CqrsEventBusService, COMMAND_BUS, QUERY_BUS, EVENT_BUS],
96
103
  global: options.global ?? true,
97
- imports: [EventBusModule.forRoot(options.eventBus)],
104
+ imports: [EventBusModule.forRoot(resolveDelegatedEventBusOptions(options))],
98
105
  providers: createCqrsProviders(options)
99
106
  });
100
107
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=test-setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-setup.d.ts","sourceRoot":"","sources":["../src/test-setup.ts"],"names":[],"mappings":""}
@@ -0,0 +1,4 @@
1
+ import { afterEach, vi } from 'vitest';
2
+ afterEach(() => {
3
+ vi.useRealTimers();
4
+ });
package/dist/types.d.ts CHANGED
@@ -56,19 +56,12 @@ export interface ISaga<TEvent extends IEvent = IEvent> {
56
56
  /**
57
57
  * Opaque dispatch context used to preserve saga topology guards across nested CQRS calls.
58
58
  *
59
- * Pass this value unchanged from handlers and sagas into nested `execute(...)`, `publish(...)`,
60
- * or `publishAll(...)` calls. Application code should not inspect or construct it directly.
59
+ * CQRS passes this value to command handlers, query handlers, event handlers, and sagas when a
60
+ * nested dispatch chain is active. Application code should pass the value through unchanged to
61
+ * nested `execute(...)`, `publish(...)`, or `publishAll(...)` calls. The context intentionally
62
+ * exposes no public topology fields, and caller-shaped objects cannot supply trusted runtime state.
61
63
  */
62
64
  export interface CqrsDispatchContext {
63
- /** Saga routes currently active in the in-process CQRS dispatch chain. */
64
- readonly activeRoutes: readonly Readonly<{
65
- eventType: CqrsEventType;
66
- token: Token;
67
- }>[];
68
- /** Current nested saga depth for in-process topology guarding. */
69
- readonly depth: number;
70
- /** Human-readable saga route labels used when reporting topology failures. */
71
- readonly path: readonly string[];
72
65
  }
73
66
  /** Constructor type used to identify a command message class. */
74
67
  export interface CommandType<TCommand extends ICommand = ICommand> {