@cratis/chronicle 6.34.0 → 6.35.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 (99) hide show
  1. package/ChronicleClient.ts +8 -2
  2. package/EventStore.ts +8 -0
  3. package/dist/ChronicleClient.d.ts.map +1 -1
  4. package/dist/ChronicleClient.js +10 -2
  5. package/dist/ChronicleClient.js.map +1 -1
  6. package/dist/EventStore.d.ts.map +1 -1
  7. package/dist/EventStore.js +8 -0
  8. package/dist/EventStore.js.map +1 -1
  9. package/dist/events/handles.d.ts +12 -0
  10. package/dist/events/handles.d.ts.map +1 -0
  11. package/dist/events/handles.js +33 -0
  12. package/dist/events/handles.js.map +1 -0
  13. package/dist/events/index.d.ts +1 -0
  14. package/dist/events/index.d.ts.map +1 -1
  15. package/dist/events/index.js +1 -0
  16. package/dist/events/index.js.map +1 -1
  17. package/dist/observation/EventHandler.d.ts +7 -0
  18. package/dist/observation/EventHandler.d.ts.map +1 -0
  19. package/dist/observation/EventHandler.js +4 -0
  20. package/dist/observation/EventHandler.js.map +1 -0
  21. package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
  22. package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
  23. package/dist/observation/createHandlerInstanceValidator.js +40 -0
  24. package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
  25. package/dist/observation/getEventHandlers.d.ts +5 -0
  26. package/dist/observation/getEventHandlers.d.ts.map +1 -0
  27. package/dist/observation/getEventHandlers.js +40 -0
  28. package/dist/observation/getEventHandlers.js.map +1 -0
  29. package/dist/observation/getInstanceMethods.d.ts +4 -0
  30. package/dist/observation/getInstanceMethods.d.ts.map +1 -0
  31. package/dist/observation/getInstanceMethods.js +29 -0
  32. package/dist/observation/getInstanceMethods.js.map +1 -0
  33. package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
  34. package/dist/reactors/ReactorDispatcher.js +27 -32
  35. package/dist/reactors/ReactorDispatcher.js.map +1 -1
  36. package/dist/reactors/Reactors.d.ts +1 -0
  37. package/dist/reactors/Reactors.d.ts.map +1 -1
  38. package/dist/reactors/Reactors.js +27 -14
  39. package/dist/reactors/Reactors.js.map +1 -1
  40. package/dist/reactors/index.d.ts +1 -0
  41. package/dist/reactors/index.d.ts.map +1 -1
  42. package/dist/reactors/index.js +1 -0
  43. package/dist/reactors/index.js.map +1 -1
  44. package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
  45. package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
  46. package/dist/reducers/ReducerEventDispatcher.js +5 -17
  47. package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
  48. package/dist/reducers/ReducerEventHandler.d.ts +3 -6
  49. package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
  50. package/dist/reducers/Reducers.d.ts +1 -0
  51. package/dist/reducers/Reducers.d.ts.map +1 -1
  52. package/dist/reducers/Reducers.js +22 -10
  53. package/dist/reducers/Reducers.js.map +1 -1
  54. package/dist/reducers/index.d.ts +1 -0
  55. package/dist/reducers/index.d.ts.map +1 -1
  56. package/dist/reducers/index.js +1 -0
  57. package/dist/reducers/index.js.map +1 -1
  58. package/dist/testing/ReactorScenario.d.ts +1 -0
  59. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  60. package/dist/testing/ReactorScenario.js +9 -1
  61. package/dist/testing/ReactorScenario.js.map +1 -1
  62. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
  63. package/dist/testing/ReducerReadModelProcessor.js +1 -0
  64. package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
  65. package/dist/tsconfig.tsbuildinfo +1 -1
  66. package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
  67. package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
  68. package/events/handles.ts +37 -0
  69. package/events/index.ts +1 -0
  70. package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
  71. package/observation/EventHandler.ts +9 -0
  72. package/observation/createHandlerInstanceValidator.ts +42 -0
  73. package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
  74. package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
  75. package/observation/getEventHandlers.ts +43 -0
  76. package/observation/getInstanceMethods.ts +29 -0
  77. package/package.json +2 -2
  78. package/reactors/ReactorDispatcher.ts +26 -30
  79. package/reactors/Reactors.ts +32 -16
  80. package/reactors/decorators.standard.spec.ts +8 -0
  81. package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
  82. package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
  83. package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
  84. package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
  85. package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
  86. package/reactors/index.ts +1 -0
  87. package/reducers/ReducerEventDispatcher.ts +5 -16
  88. package/reducers/ReducerEventHandler.ts +4 -6
  89. package/reducers/Reducers.ts +24 -12
  90. package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
  91. package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
  92. package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
  93. package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
  94. package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
  95. package/reducers/index.ts +1 -0
  96. package/testing/ReactorScenario.ts +9 -1
  97. package/testing/ReducerReadModelProcessor.ts +1 -0
  98. package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
  99. package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
@@ -105,6 +105,7 @@ export class Reducers implements IReducers {
105
105
 
106
106
  private readonly _logger: ReturnType<typeof createLogger>;
107
107
  private readonly _lifecycle: ConnectionLifecycle;
108
+ private readonly _unsubscribeDisconnected: () => void;
108
109
  private readonly _reducers = new Map<string, Constructor>();
109
110
  private readonly _queues = new Map<string, AsyncQueue<ReducerMessage>>();
110
111
  private readonly _observations = new Map<string, AbortController>();
@@ -136,7 +137,7 @@ export class Reducers implements IReducers {
136
137
  ) {
137
138
  this._logger = createLogger('@cratis/chronicle/reducers', _connection.logger);
138
139
  this._lifecycle = lifecycle;
139
- lifecycle.onDisconnected(async () => {
140
+ this._unsubscribeDisconnected = lifecycle.onDisconnected(async () => {
140
141
  this._logger.info('Disconnected — stopping all reducer observations');
141
142
  this._registered = false;
142
143
  this.disconnectAll();
@@ -145,6 +146,7 @@ export class Reducers implements IReducers {
145
146
 
146
147
  /** Stops observations permanently when the owning client is disposed. */
147
148
  dispose(): void {
149
+ this._unsubscribeDisconnected();
148
150
  this._disposed = true;
149
151
  this._registered = false;
150
152
  this.disconnectAll();
@@ -172,13 +174,16 @@ export class Reducers implements IReducers {
172
174
  await this.discover();
173
175
  }
174
176
 
177
+ const registrations = [...this._reducers].map(([id, type]) => ({
178
+ id, type, dispatcher: new ReducerEventDispatcher(type, this._clientArtifacts.eventTypes)
179
+ }));
175
180
  assertUniqueReadModelIds(this._clientArtifacts.readModels);
176
181
  await this.registerReadModels();
177
182
 
178
183
  if (this._disposed) return;
179
184
  this._logger.info('Registering reducers', { count: this._reducers.size });
180
- for (const [id, reducerType] of this._reducers) {
181
- this.startObservation(id, reducerType);
185
+ for (const { id, type, dispatcher } of registrations) {
186
+ this.startObservation(id, type, dispatcher);
182
187
  }
183
188
 
184
189
  this._registered = true;
@@ -236,12 +241,12 @@ export class Reducers implements IReducers {
236
241
  return JSON.stringify(minimalSchema);
237
242
  }
238
243
 
239
- private startObservation(id: string, reducerType: Constructor): void {
244
+ private startObservation(id: string, reducerType: Constructor,
245
+ dispatcher: ReducerEventDispatcher): void {
240
246
  runInBackgroundContext(() => {
241
247
  if (this._disposed) return;
242
248
  const metadata = getReducerMetadata(reducerType)!;
243
249
  const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
244
- const dispatcher = new ReducerEventDispatcher(reducerType, this._clientArtifacts.eventTypes);
245
250
  const readModelName = this.getReducerReadModelIdentifier(reducerType);
246
251
 
247
252
  this._logger.info('Starting reducer observation', {
@@ -269,7 +274,7 @@ export class Reducers implements IReducers {
269
274
  this._logger.error('Reducer observation loop exited with error', { reducerId: id, error });
270
275
  }
271
276
 
272
- this.scheduleReobserve(id, reducerType);
277
+ this.scheduleReobserve(id, reducerType, dispatcher);
273
278
  }
274
279
 
275
280
  /**
@@ -282,7 +287,7 @@ export class Reducers implements IReducers {
282
287
  * retried, and the delay keeps a stream that keeps ending from becoming a hot
283
288
  * loop.
284
289
  */
285
- private scheduleReobserve(id: string, reducerType: Constructor): void {
290
+ private scheduleReobserve(id: string, reducerType: Constructor, dispatcher: ReducerEventDispatcher): void {
286
291
  // A disconnect clears the registration; the reconnect re-registers every
287
292
  // reducer from scratch, so retrying here as well would double up.
288
293
  if (!this._registered || this._disposed) {
@@ -295,7 +300,7 @@ export class Reducers implements IReducers {
295
300
  }
296
301
 
297
302
  this._logger.info('Re-establishing reducer observation', { reducerId: id });
298
- this.startObservation(id, reducerType);
303
+ this.startObservation(id, reducerType, dispatcher);
299
304
  }, Reducers._reobserveDelayMs));
300
305
 
301
306
  handle.unref?.();
@@ -353,6 +358,7 @@ export class Reducers implements IReducers {
353
358
 
354
359
  try {
355
360
  const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
361
+ if (reducerInstance) dispatcher.validateInstance(reducerInstance);
356
362
 
357
363
  for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
358
364
  let lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
@@ -376,9 +382,12 @@ export class Reducers implements IReducers {
376
382
  readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
377
383
  signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
378
384
  replayState: operation.ReplayState
379
- }, this._artifactActivator, artifact => runActivated(artifact, () =>
380
- notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
381
- { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState }), this._connection.logger);
385
+ }, this._artifactActivator, artifact => {
386
+ dispatcher.validateInstance(artifact.instance);
387
+ return runActivated(artifact, () =>
388
+ notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
389
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
390
+ }, this._connection.logger);
382
391
  } else if (reducerInstance) {
383
392
  await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
384
393
  }
@@ -448,7 +457,10 @@ export class Reducers implements IReducers {
448
457
  readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
449
458
  signal: controller.signal, delivery: ArtifactDelivery.Events,
450
459
  eventContext: toClientEventContext(firstInvocableEvent.Context!)
451
- }, this._artifactActivator, processEvents, this._connection.logger);
460
+ }, this._artifactActivator, artifact => {
461
+ dispatcher.validateInstance(artifact.instance);
462
+ return processEvents(artifact);
463
+ }, this._connection.logger);
452
464
  } catch (err) {
453
465
  this._logger.error('Error activating reducer', { reducerId: id, error: err });
454
466
  exceptionMessages.push(String(err));
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { EventType } from '../../events/EventType.js';
7
+ import type { EventContext } from '../../events/EventContext.js';
8
+ import { handles } from '../index.js';
9
+ import { ReducerEventDispatcher } from '../ReducerEventDispatcher.js';
10
+
11
+ chai.should();
12
+
13
+ @eventType('explicit-reducer-delivery')
14
+ class AuthorRegistered { constructor(readonly name: string) {} }
15
+
16
+ class AuthorReducer {
17
+ @handles(AuthorRegistered)
18
+ update(event: AuthorRegistered, state: { names: string[] } | undefined, context: EventContext) {
19
+ return { names: [...(state?.names ?? []), event.name], id: context.eventSourceId };
20
+ }
21
+ }
22
+
23
+ class FieldReducer extends AuthorReducer {
24
+ override update = () => ({ names: [], id: 'hidden' });
25
+ }
26
+
27
+ describe('when an instance field hides an inherited reducer handler', () => {
28
+ it('should reject the field against the preflight method', () => {
29
+ const dispatcher = new ReducerEventDispatcher(FieldReducer, [AuthorRegistered]);
30
+ (() => dispatcher.validateInstance(new FieldReducer())).should.throw(
31
+ "Override 'update' on 'FieldReducer' hides @handles(AuthorRegistered) declared on 'AuthorReducer'; use a method with @handles instead of an instance field.");
32
+ });
33
+ });
34
+
35
+ describe('when invoking an explicit reducer handler', () => {
36
+ let result: unknown;
37
+ beforeEach(async () => {
38
+ const dispatcher = new ReducerEventDispatcher(AuthorReducer, [AuthorRegistered]);
39
+ const instance = new AuthorReducer() as unknown as Record<string, Function>;
40
+ const context: EventContext = {
41
+ sequenceNumber: 1n, eventSourceId: 'author', eventType: EventType.parse('explicit-reducer-delivery'),
42
+ occurred: new Date(), correlationId: '', causation: [], tags: []
43
+ };
44
+ const handler = dispatcher.handlerFor('explicit-reducer-delivery')!;
45
+ const initial = await dispatcher.invoke(instance, handler, new AuthorRegistered('Ada'), undefined, context);
46
+ result = await dispatcher.invoke(instance, handler, new AuthorRegistered('Grace'), initial, context);
47
+ });
48
+ it('should pass the event state and context to the named method', () => {
49
+ (result as object).should.deep.equal({ names: ['Ada', 'Grace'], id: 'author' });
50
+ });
51
+ });
@@ -0,0 +1,69 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { EventType } from '../../events/EventType.js';
7
+ import type { EventContext } from '../../events/EventContext.js';
8
+ import { handles } from '../index.js';
9
+ import { ReducerEventDispatcher } from '../ReducerEventDispatcher.js';
10
+
11
+ chai.should();
12
+
13
+ @eventType('instance-reducer-event')
14
+ class AuthorRegistered { constructor(readonly name: string) {} }
15
+
16
+ class BoundConventional {
17
+ readonly prefix = 'conventional';
18
+ constructor() { this.authorRegistered = this.authorRegistered.bind(this); }
19
+ authorRegistered(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
20
+ return [...(state ?? []), `${this.prefix}:${event.name}:${context.eventSourceId}`];
21
+ }
22
+ }
23
+
24
+ class BoundExplicit {
25
+ readonly prefix = 'explicit';
26
+ constructor() { this.update = this.update.bind(this); }
27
+ @handles(AuthorRegistered)
28
+ update(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
29
+ return [...(state ?? []), `${this.prefix}:${event.name}:${context.eventSourceId}`];
30
+ }
31
+ }
32
+
33
+ class Conventional {
34
+ authorRegistered(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
35
+ return [...(state ?? []), `base:${event.name}:${context.eventSourceId}`];
36
+ }
37
+ }
38
+
39
+ class FieldConventional extends Conventional {
40
+ override authorRegistered = (event: AuthorRegistered, state: string[] | undefined, context: EventContext) =>
41
+ [...(state ?? []), `field:${event.name}:${context.eventSourceId}`];
42
+ }
43
+
44
+ for (const { type, prefix } of [
45
+ { type: BoundConventional, prefix: 'conventional' },
46
+ { type: BoundExplicit, prefix: 'explicit' },
47
+ { type: FieldConventional, prefix: 'field' }
48
+ ]) {
49
+ describe(`when invoking the ${prefix} reducer instance handler`, () => {
50
+ let result: unknown;
51
+ beforeEach(async () => {
52
+ const dispatcher = new ReducerEventDispatcher(type, [AuthorRegistered]);
53
+ const instance = new type();
54
+ dispatcher.validateInstance(instance);
55
+ const context: EventContext = {
56
+ sequenceNumber: 1n, eventSourceId: 'author', eventType: EventType.parse('instance-reducer-event'),
57
+ occurred: new Date(), correlationId: '', causation: [], tags: []
58
+ };
59
+ const handler = dispatcher.handlerFor('instance-reducer-event')!;
60
+ const initial = await dispatcher.invoke(instance as unknown as Record<string, Function>, handler,
61
+ new AuthorRegistered('Ada'), undefined, context);
62
+ result = await dispatcher.invoke(instance as unknown as Record<string, Function>, handler,
63
+ new AuthorRegistered('Grace'), initial, context);
64
+ });
65
+ it('should invoke the instance handler with the event state and context', () => {
66
+ (result as string[]).should.deep.equal([`${prefix}:Ada:author`, `${prefix}:Grace:author`]);
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,63 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { EventContext, EventType, ObservationState, ReplayState, type ReducerMessage } from '@cratis/chronicle.contracts';
5
+ import { beforeEach, chai, describe, it } from 'vitest';
6
+ import type { IClientArtifactsProvider } from '../../artifacts/index.js';
7
+ import type { ChronicleConnection } from '../../connection/index.js';
8
+ import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
9
+ import type { IEventStore } from '../../IEventStore.js';
10
+ import { eventType } from '../../events/eventTypeDecorator.js';
11
+ import { handles, reducer, Reducers } from '../index.js';
12
+
13
+ chai.should();
14
+
15
+ @eventType('field-reducer-event')
16
+ class AuthorRegistered {}
17
+ const calls: string[] = [];
18
+ class Base {
19
+ @handles(AuthorRegistered) update() { calls.push('base'); return {}; }
20
+ beginReplay() { calls.push('replay'); }
21
+ endReplay() {}
22
+ }
23
+ @reducer('field-reducer')
24
+ class Derived extends Base {
25
+ override update = () => { calls.push('field'); return {}; };
26
+ }
27
+
28
+ for (const replayState of [ReplayState.REPLAY_STATE_None, ReplayState.BeginReplay]) {
29
+ describe(`when activating a reducer with a field override for replay state ${replayState}`, () => {
30
+ let result: ReducerMessage;
31
+ let disposed: boolean;
32
+ beforeEach(async () => {
33
+ calls.length = 0;
34
+ disposed = false;
35
+ const lifecycle = new ConnectionLifecycle();
36
+ let finish!: () => void;
37
+ const finished = new Promise<void>(resolve => { finish = resolve; });
38
+ async function* stream(queue: AsyncIterable<ReducerMessage>) {
39
+ const messages = queue[Symbol.asyncIterator]();
40
+ await messages.next();
41
+ yield { Events: [{ Content: '{}', Context: EventContext.fromPartial({
42
+ EventType: EventType.fromPartial({ Id: 'field-reducer-event', Generation: 1 }), SequenceNumber: 1n
43
+ }) }], Partition: 'author', ReplayState: replayState, InitialState: '' };
44
+ result = (await messages.next()).value!;
45
+ await lifecycle.disconnected(error => { throw error; });
46
+ finish();
47
+ }
48
+ const reducers = new Reducers({ reducers: [Derived], eventTypes: [AuthorRegistered], readModels: [] } as unknown as IClientArtifactsProvider,
49
+ { reducers: { observe: stream }, readModels: { registerMany: async () => ({}) } } as unknown as ChronicleConnection,
50
+ 'store', 'tenant', lifecycle, 'sink', { readModels: {} } as IEventStore,
51
+ type => ({ instance: new type(), dispose: () => { disposed = true; } }));
52
+ try { await reducers.register(); await finished; } finally { reducers.dispose(); }
53
+ });
54
+ it('should fail delivery without invoking the field or replay notification', () => {
55
+ calls.should.have.lengthOf(0);
56
+ result.Content!.Value1!.State.should.equal(ObservationState.Failed);
57
+ result.Content!.Value1!.ReadModelState.should.equal('');
58
+ result.Content!.Value1!.ExceptionMessages.join(' ').should.contain(
59
+ "Override 'update' on 'Derived' hides @handles(AuthorRegistered) declared on 'Base'; use a method with @handles instead of an instance field.");
60
+ });
61
+ it('should release the rejected activation lease', () => { disposed.should.be.true; });
62
+ });
63
+ }
@@ -0,0 +1,73 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { EventContext, EventType, ObservationState, type ReducerMessage } from '@cratis/chronicle.contracts';
5
+ import { beforeEach, chai, describe, it } from 'vitest';
6
+ import type { IClientArtifactsProvider } from '../../artifacts/index.js';
7
+ import type { ChronicleConnection } from '../../connection/index.js';
8
+ import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
9
+ import { eventType } from '../../events/eventTypeDecorator.js';
10
+ import type { EventContext as ClientEventContext } from '../../events/EventContext.js';
11
+ import { handles, reducer, Reducers } from '../index.js';
12
+
13
+ chai.should();
14
+
15
+ @eventType('legacy-reduced-handler')
16
+ class AuthorRegistered { constructor(readonly name = '') {} }
17
+
18
+ @reducer('legacy-reduction')
19
+ class AuthorReducer {
20
+ @handles(AuthorRegistered)
21
+ update(event: AuthorRegistered, state: { names: string[] } | undefined, context: ClientEventContext) {
22
+ return { names: [...(state?.names ?? []), event.name], id: context.eventSourceId };
23
+ }
24
+ }
25
+
26
+ describe('when observing explicit legacy reducer handlers', () => {
27
+ let registration: ReducerMessage;
28
+ let result: ReducerMessage;
29
+ beforeEach(async () => {
30
+ const lifecycle = new ConnectionLifecycle();
31
+ let finish!: () => void;
32
+ const finished = new Promise<void>(resolve => { finish = resolve; });
33
+ async function* stream(queue: AsyncIterable<ReducerMessage>) {
34
+ const messages = queue[Symbol.asyncIterator]();
35
+ registration = (await messages.next()).value!;
36
+ yield {
37
+ Events: ['Ada', 'Grace'].map((name, index) => ({
38
+ Context: EventContext.fromPartial({
39
+ EventType: EventType.fromPartial({ Id: 'legacy-reduced-handler', Generation: 1 }),
40
+ EventSourceId: 'author', SequenceNumber: BigInt(index + 1)
41
+ }),
42
+ Content: JSON.stringify({ name })
43
+ })),
44
+ Partition: 'author', ReplayState: 0, InitialState: ''
45
+ };
46
+ result = (await messages.next()).value!;
47
+ await lifecycle.disconnected(error => { throw error; });
48
+ finish();
49
+ }
50
+ const connection = {
51
+ reducers: { observe: stream }, readModels: { registerMany: async () => ({}) }
52
+ } as unknown as ChronicleConnection;
53
+ const artifacts = {
54
+ reducers: [AuthorReducer], eventTypes: [AuthorRegistered], readModels: []
55
+ } as unknown as IClientArtifactsProvider;
56
+ const reducers = new Reducers(artifacts, connection, 'store', 'tenant', lifecycle, 'sink');
57
+ try {
58
+ await reducers.register();
59
+ await finished;
60
+ } finally { reducers.dispose(); }
61
+ });
62
+ it('should subscribe to the explicitly named event type', () => {
63
+ registration.Content!.Value0!.Reducer!.EventTypes.map(entry => entry.EventType!.Id)
64
+ .should.deep.equal(['legacy-reduced-handler']);
65
+ });
66
+ it('should pass the event state and context through the named method', () => {
67
+ JSON.parse(result.Content!.Value1!.ReadModelState).should.deep.equal({ names: ['Ada', 'Grace'], id: 'author' });
68
+ });
69
+ it('should acknowledge both deliveries successfully', () => {
70
+ result.Content!.Value1!.State.should.equal(ObservationState.Success);
71
+ result.Content!.Value1!.LastSuccessfulObservation.should.equal(2n);
72
+ });
73
+ });
@@ -0,0 +1,57 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it, vi } from 'vitest';
5
+ import type { IClientArtifactsProvider } from '../../artifacts/index.js';
6
+ import type { ChronicleConnection } from '../../connection/index.js';
7
+ import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
8
+ import { eventType } from '../../events/eventTypeDecorator.js';
9
+ import { handles, reducer, Reducers } from '../index.js';
10
+
11
+ chai.should();
12
+
13
+ @eventType('reducer-handled-event')
14
+ class AuthorRegistered {}
15
+ @eventType('unselected-reducer-event')
16
+ class Unregistered {}
17
+
18
+ @reducer('valid-handlers')
19
+ class Valid { @handles(AuthorRegistered) update() {} }
20
+ @reducer('missing-handler-event')
21
+ class Missing { @handles(Unregistered) update() {} }
22
+ @reducer('duplicate-handlers')
23
+ class Duplicate {
24
+ @handles(AuthorRegistered) update() {}
25
+ authorRegistered() {}
26
+ }
27
+
28
+ for (const [type, message] of [
29
+ [Missing, /update.*Missing.*Unregistered.*no registered event type/],
30
+ [Duplicate, /multiple handlers.*reducer-handled-event.*update.*authorRegistered/]
31
+ ] as const) {
32
+ describe(`when registering a reducer with invalid explicit handlers on ${type.name}`, () => {
33
+ const observe = vi.fn();
34
+ const registerMany = vi.fn();
35
+ let error: unknown;
36
+ beforeEach(async () => {
37
+ observe.mockReset();
38
+ registerMany.mockReset();
39
+ error = undefined;
40
+ const artifacts = { reducers: [Valid, type], eventTypes: [AuthorRegistered], readModels: [] } as unknown as IClientArtifactsProvider;
41
+ const connection = { reducers: { observe }, readModels: { registerMany } } as unknown as ChronicleConnection;
42
+ const reducers = new Reducers(artifacts, connection, 'store', 'tenant', new ConnectionLifecycle(), 'sink');
43
+ try { await reducers.register(); } catch (caught) { error = caught; }
44
+ finally { reducers.dispose(); }
45
+ });
46
+ it('should reject registration with an actionable error', () => {
47
+ (error instanceof Error).should.be.true;
48
+ (error as Error).message.should.match(message);
49
+ });
50
+ it('should not register read models before validating all handlers', () => {
51
+ registerMany.mock.calls.should.have.lengthOf(0);
52
+ });
53
+ it('should not start an observation before validating all reducers', () => {
54
+ observe.mock.calls.should.have.lengthOf(0);
55
+ });
56
+ });
57
+ }
package/reducers/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ export { handles } from '../events/handles.js';
4
5
  export { ReducerId } from './ReducerId.js';
5
6
  export { reducer, getReducerMetadata, isReducer } from './reducer.js';
6
7
  export type { ReducerMetadata } from './reducer.js';
@@ -21,6 +21,7 @@ import { EventStoreNamespaceName } from '../EventStoreNamespaceName.js';
21
21
  import type { IEventStore } from '../IEventStore.js';
22
22
  import type { IReadModels } from '../readModels/IReadModels.js';
23
23
  import { getReactorEventTypes, invokeReactorHandler, selectReactorHandler } from '../reactors/ReactorDispatcher.js';
24
+ import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
24
25
  import { getReactorMetadata } from '../reactors/reactor.js';
25
26
  import { normalizeReactorSideEffects } from '../reactors/ReactorSideEffects.js';
26
27
  import type { ReactorServices } from '../reactors/ReactorServices.js';
@@ -40,6 +41,7 @@ export class ReactorScenario {
40
41
  private readonly _entries: ReturnType<typeof getReactorEventTypes>;
41
42
  private readonly _eventTypes: readonly Constructor[];
42
43
  private readonly _instance?: Record<string, Function>;
44
+ private readonly _validateInstance: (instance: object) => void;
43
45
  private readonly _store: IEventStore;
44
46
  private readonly _controller = new AbortController();
45
47
  private readonly _effects: RecordedReactorSideEffect[] = [];
@@ -76,9 +78,12 @@ export class ReactorScenario {
76
78
  eventTypeMigrations: eventTypeMigrations === undefined ? undefined : [...eventTypeMigrations]
77
79
  } });
78
80
  this._entries = getReactorEventTypes(_reactor, this._eventTypes);
81
+ this._validateInstance = createHandlerInstanceValidator(_reactor,
82
+ this._entries.flatMap(entry => [entry.methodName, entry.replayMethodName].filter((name): name is string => !!name)));
79
83
  // Every unsupported event-sequence operation is latched at its source, whichever result or stream carries it.
80
84
  observeUnsupportedOperations(this._events.eventSequence, error => { this.latch(error); });
81
85
  this._instance = _options.artifactActivator ? undefined : new (_reactor as new () => Record<string, Function>)();
86
+ if (this._instance) this._validateInstance(this._instance);
82
87
  this._store = _options.servicesEventStore ?? this.scenarioStore();
83
88
  this.given = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, true) }) };
84
89
  this.when = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, false) }) };
@@ -246,7 +251,10 @@ export class ReactorScenario {
246
251
  artifactId: getReactorMetadata(this._reactor)!.id.value, eventStore: this._store,
247
252
  readModels: services.readModels, eventSequenceId: 'event-log', partition: sourceId,
248
253
  signal: services.signal, delivery: ArtifactDelivery.Events, eventContext: first.context },
249
- this._options.artifactActivator, process);
254
+ this._options.artifactActivator, artifact => {
255
+ this._validateInstance(artifact.instance);
256
+ return process(artifact);
257
+ });
250
258
  } else {
251
259
  await process({ instance: this._instance ?? {} });
252
260
  }
@@ -18,6 +18,7 @@ export class ReducerReadModelProcessor<TReadModel extends object> implements IRe
18
18
  async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
19
19
  const results = new Map<string, ReadModelState<TReadModel>>();
20
20
  const reducer = new (this._reducerType as new () => Record<string, Function>)();
21
+ this._dispatcher.validateInstance(reducer);
21
22
  for (const event of events) {
22
23
  const handler = this._dispatcher.handlerFor(event.context.eventType.id.value);
23
24
  if (!handler) continue;
@@ -0,0 +1,100 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { field } from '@cratis/fundamentals';
5
+ import { beforeEach, chai, describe, it } from 'vitest';
6
+ import { eventType, handles, onceOnly, reactor, type EventContext, type ReactorServices } from '../../index.js';
7
+ import { ReactorScenario } from '../index.js';
8
+
9
+ chai.should();
10
+
11
+ @eventType('scenario-explicit-author')
12
+ class AuthorRegistered {
13
+ @field(String) name: string;
14
+ constructor(name: string) { this.name = name; }
15
+ }
16
+ @eventType('scenario-explicit-notification')
17
+ class AuthorNotified {
18
+ @field(String) name: string;
19
+ constructor(name: string) { this.name = name; }
20
+ }
21
+
22
+ const calls: { name: string; source: string; method: string }[] = [];
23
+ @reactor('explicit-scenario-handler')
24
+ class AuthorReactor {
25
+ @onceOnly()
26
+ @handles(AuthorRegistered)
27
+ notify(event: AuthorRegistered, context: EventContext, services: ReactorServices) {
28
+ (services.readModels === services.eventStore.readModels).should.be.true;
29
+ calls.push({ name: event.name, source: context.eventSourceId, method: 'notify' });
30
+ return new AuthorNotified(event.name);
31
+ }
32
+ }
33
+
34
+ const options = { artifacts: { eventTypes: [AuthorRegistered, AuthorNotified] }, constraints: 'disabled' as const };
35
+
36
+ describe('when delivering a live event to an explicitly named onceOnly handler in ReactorScenario', () => {
37
+ let scenario: ReactorScenario;
38
+ beforeEach(async () => {
39
+ calls.length = 0;
40
+ scenario = new ReactorScenario(AuthorReactor, options);
41
+ await scenario.when.forEventSource('author-1').events(new AuthorRegistered('Ada'));
42
+ });
43
+ it('should invoke the method with the event and context', () => {
44
+ calls.should.deep.equal([{ name: 'Ada', source: 'author-1', method: 'notify' }]);
45
+ });
46
+ it('should process returned side effects', () => {
47
+ scenario.shouldHaveProduced(AuthorNotified, event => event.name === 'Ada');
48
+ });
49
+ it('should complete delivery', () => {
50
+ scenario.results[0].completed.should.be.true;
51
+ });
52
+ });
53
+
54
+ describe('when ReactorScenario has an unregistered explicit handler', () => {
55
+ it('should reject it before constructing the reactor', () => {
56
+ let constructed = false;
57
+ @reactor('invalid-explicit-scenario')
58
+ class Invalid {
59
+ constructor() { constructed = true; }
60
+ @handles(AuthorRegistered) notify() {}
61
+ }
62
+ (() => new ReactorScenario(Invalid, { ...options, artifacts: { eventTypes: [AuthorNotified] } }))
63
+ .should.throw(/notify.*AuthorRegistered.*no registered event type/);
64
+ constructed.should.be.false;
65
+ });
66
+ });
67
+
68
+ @reactor('field-shadowing-scenario')
69
+ class FieldReactor extends AuthorReactor {
70
+ override notify = () => new AuthorNotified('hidden');
71
+ }
72
+
73
+ describe('when an instance field hides an inherited ReactorScenario handler', () => {
74
+ it('should reject the field after constructing the reactor', () => {
75
+ (() => new ReactorScenario(FieldReactor, options)).should.throw(
76
+ "Override 'notify' on 'FieldReactor' hides @handles(AuthorRegistered) declared on 'AuthorReactor'; use a method with @handles instead of an instance field.");
77
+ });
78
+ it('should reject an activator-created field before delivery and dispose its lease', async () => {
79
+ let disposed = false;
80
+ const scenario = new ReactorScenario(FieldReactor, { ...options, artifactActivator: type => ({
81
+ instance: new type(), dispose: () => { disposed = true; }
82
+ }) });
83
+ const [result] = await Promise.allSettled([scenario.when.forEventSource('author').events(new AuthorRegistered('Ada'))]);
84
+ result.status.should.equal('rejected');
85
+ if (result.status === 'rejected') (result.reason as Error).message.should.match(/Override 'notify'.*use a method/);
86
+ scenario.produced.should.have.lengthOf(0);
87
+ disposed.should.be.true;
88
+ });
89
+ });
90
+
91
+ describe('when ReactorScenario has duplicate explicit and conventional handlers', () => {
92
+ @reactor('duplicate-explicit-scenario')
93
+ class Invalid {
94
+ @handles(AuthorRegistered) notify() {}
95
+ authorRegistered() {}
96
+ }
97
+ it('should reject the ambiguous event delivery', () => {
98
+ (() => new ReactorScenario(Invalid, options)).should.throw(/multiple handlers.*scenario-explicit-author/);
99
+ });
100
+ });
@@ -0,0 +1,29 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { chai, describe, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { handles } from '../../events/handles.js';
7
+ import { ReducerReadModelProcessor } from '../ReducerReadModelProcessor.js';
8
+
9
+ chai.should();
10
+
11
+ @eventType('scenario-field-reducer')
12
+ class AuthorRegistered {}
13
+ class Model {}
14
+ class Base {
15
+ @handles(AuthorRegistered) update() { return new Model(); }
16
+ }
17
+ class Derived extends Base {
18
+ override update = () => new Model();
19
+ }
20
+
21
+ describe('when a reducer scenario instance field hides an inherited handler', () => {
22
+ it('should reject the instance before processing events', async () => {
23
+ const processor = new ReducerReadModelProcessor(Model, Derived, [AuthorRegistered]);
24
+ const [result] = await Promise.allSettled([processor.process([])]);
25
+ result.status.should.equal('rejected');
26
+ if (result.status === 'rejected') (result.reason as Error).message.should.equal(
27
+ "Override 'update' on 'Derived' hides @handles(AuthorRegistered) declared on 'Base'; use a method with @handles instead of an instance field.");
28
+ });
29
+ });