@cratis/chronicle 6.21.0 → 6.22.1

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 (60) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +1 -1
  3. package/dist/eventSequences/EventSequence.js.map +1 -1
  4. package/dist/reactors/ReactorDispatcher.d.ts +20 -0
  5. package/dist/reactors/ReactorDispatcher.d.ts.map +1 -0
  6. package/dist/reactors/ReactorDispatcher.js +76 -0
  7. package/dist/reactors/ReactorDispatcher.js.map +1 -0
  8. package/dist/reactors/ReactorSideEffects.d.ts +3 -0
  9. package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
  10. package/dist/reactors/ReactorSideEffects.js +16 -11
  11. package/dist/reactors/ReactorSideEffects.js.map +1 -1
  12. package/dist/reactors/Reactors.d.ts +0 -1
  13. package/dist/reactors/Reactors.d.ts.map +1 -1
  14. package/dist/reactors/Reactors.js +5 -71
  15. package/dist/reactors/Reactors.js.map +1 -1
  16. package/dist/testing/InProcessEventSequence.d.ts +8 -0
  17. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  18. package/dist/testing/InProcessEventSequence.js +14 -1
  19. package/dist/testing/InProcessEventSequence.js.map +1 -1
  20. package/dist/testing/ReactorDeliveryResult.d.ts +10 -0
  21. package/dist/testing/ReactorDeliveryResult.d.ts.map +1 -0
  22. package/dist/testing/ReactorDeliveryResult.js +4 -0
  23. package/dist/testing/ReactorDeliveryResult.js.map +1 -0
  24. package/dist/testing/ReactorScenario.d.ts +55 -0
  25. package/dist/testing/ReactorScenario.d.ts.map +1 -0
  26. package/dist/testing/ReactorScenario.js +275 -0
  27. package/dist/testing/ReactorScenario.js.map +1 -0
  28. package/dist/testing/ReactorScenarioOptions.d.ts +17 -0
  29. package/dist/testing/ReactorScenarioOptions.d.ts.map +1 -0
  30. package/dist/testing/ReactorScenarioOptions.js +4 -0
  31. package/dist/testing/ReactorScenarioOptions.js.map +1 -0
  32. package/dist/testing/RecordedReactorSideEffect.d.ts +13 -0
  33. package/dist/testing/RecordedReactorSideEffect.d.ts.map +1 -0
  34. package/dist/testing/RecordedReactorSideEffect.js +4 -0
  35. package/dist/testing/RecordedReactorSideEffect.js.map +1 -0
  36. package/dist/testing/UnsupportedReactorOperation.d.ts +7 -0
  37. package/dist/testing/UnsupportedReactorOperation.d.ts.map +1 -0
  38. package/dist/testing/UnsupportedReactorOperation.js +14 -0
  39. package/dist/testing/UnsupportedReactorOperation.js.map +1 -0
  40. package/dist/testing/index.d.ts +5 -0
  41. package/dist/testing/index.d.ts.map +1 -1
  42. package/dist/testing/index.js +2 -0
  43. package/dist/testing/index.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/eventSequences/EventSequence.spec.ts +37 -2
  46. package/eventSequences/EventSequence.ts +17 -12
  47. package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +9 -4
  48. package/package.json +1 -1
  49. package/reactors/ReactorDispatcher.ts +95 -0
  50. package/reactors/ReactorSideEffects.ts +21 -13
  51. package/reactors/Reactors.ts +11 -83
  52. package/testing/InProcessEventSequence.ts +16 -1
  53. package/testing/ReactorDeliveryResult.ts +13 -0
  54. package/testing/ReactorScenario.rejections.spec.ts +100 -0
  55. package/testing/ReactorScenario.spec.ts +611 -0
  56. package/testing/ReactorScenario.ts +279 -0
  57. package/testing/ReactorScenarioOptions.ts +20 -0
  58. package/testing/RecordedReactorSideEffect.ts +16 -0
  59. package/testing/UnsupportedReactorOperation.ts +10 -0
  60. package/testing/index.ts +5 -0
@@ -4,11 +4,10 @@
4
4
  import 'reflect-metadata';
5
5
  import { diag } from '@opentelemetry/api';
6
6
  import { Constructor } from '@cratis/fundamentals';
7
- import { EventObservationState, ObservationState, ReactorMessage, ReplayState } from '@cratis/chronicle.contracts';
7
+ import { ObservationState, ReactorMessage, ReplayState } from '@cratis/chronicle.contracts';
8
8
  import { IClientArtifactsProvider } from '../artifacts/index.js';
9
9
  import { ChronicleConnection } from '../connection/index.js';
10
10
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
11
- import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
12
11
  import { toClientEventContext } from '../events/toClientEventContext.js';
13
12
  import { getTagsFor } from '../events/tagDecorator.js';
14
13
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
@@ -19,7 +18,8 @@ import { IReactors } from './IReactors.js';
19
18
  import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
20
19
  import { getReactorMetadata } from './reactor.js';
21
20
  import { isOnceOnly } from './onceOnly.js';
22
- import { getReplayEventType } from './replay.js';
21
+ import { getReactorEventTypes, selectReactorHandler, invokeReactorHandler } from './ReactorDispatcher.js';
22
+ import type { ReactorEventTypeEntry } from './ReactorDispatcher.js';
23
23
  import type { ReactorResultHandler } from './ReactorResultHandler.js';
24
24
  import type { ReactorServices } from './ReactorServices.js';
25
25
  import type { IEventStore } from '../IEventStore.js';
@@ -36,13 +36,6 @@ const EVENT_SOURCE_ID_KEY = '$eventSourceId';
36
36
  /** Sentinel sequence number sent back when no event was successfully processed. */
37
37
  const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
38
38
 
39
- interface EventTypeEntry {
40
- readonly id: string;
41
- readonly generation: number;
42
- readonly methodName?: string;
43
- readonly replayMethodName?: string;
44
- }
45
-
46
39
  /**
47
40
  * A push-based async queue that implements {@link AsyncIterable} for use with nice-grpc
48
41
  * bidirectional streaming. Values pushed via {@link send} are yielded in order to
@@ -187,7 +180,7 @@ export class Reactors implements IReactors {
187
180
  if (this._disposed) return;
188
181
  const metadata = getReactorMetadata(reactorType)!;
189
182
  const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
190
- const eventTypes = this.getEventTypesFor(reactorType);
183
+ const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
191
184
 
192
185
  this._logger.info('Starting reactor observation', {
193
186
  reactorId: id,
@@ -203,7 +196,7 @@ export class Reactors implements IReactors {
203
196
  id: string,
204
197
  reactorType: Constructor,
205
198
  eventSequenceId: string,
206
- eventTypes: EventTypeEntry[]
199
+ eventTypes: ReactorEventTypeEntry[]
207
200
  ): Promise<void> {
208
201
  try {
209
202
  await this.observeReactor(id, reactorType, eventSequenceId, eventTypes);
@@ -246,7 +239,7 @@ export class Reactors implements IReactors {
246
239
  id: string,
247
240
  reactorType: Constructor,
248
241
  eventSequenceId: string,
249
- eventTypes: EventTypeEntry[]
242
+ eventTypes: ReactorEventTypeEntry[]
250
243
  ): Promise<void> {
251
244
  const queue = new AsyncQueue<ReactorMessage>();
252
245
  const controller = new AbortController();
@@ -323,12 +316,8 @@ export class Reactors implements IReactors {
323
316
  }
324
317
 
325
318
  const selectHandler = (event: typeof eventsToObserve.Events[number]) => {
326
- const entry = eventTypes.find(candidate => candidate.id === event.Context?.EventType?.Id);
327
- if (!entry) return undefined;
328
- const isReplay = (event.Context!.ObservationState & EventObservationState.Replay) !== 0;
329
- const methodName = isReplay ? (entry.replayMethodName ?? entry.methodName) : entry.methodName;
330
- const method = methodName ? (reactorInstance?.[methodName] ?? (reactorType.prototype as Record<string, Function>)[methodName]) : undefined;
331
- return { methodName, isReplay, skipReplay: isReplay && method !== undefined && isOnceOnly(method) };
319
+ return selectReactorHandler(eventTypes, reactorType, reactorInstance,
320
+ event.Context?.EventType?.Id, event.Context?.ObservationState ?? 0);
332
321
  };
333
322
 
334
323
  const processEvents = async (artifact: ActivatedArtifact<Record<string, Function>>) => {
@@ -369,11 +358,9 @@ export class Reactors implements IReactors {
369
358
  eventTypeId
370
359
  });
371
360
 
372
- await runActivated(artifact, async () => {
373
- const handlerResult = await artifact.instance[methodName](content, context, services);
374
- await dispatchReactorSideEffects(this._eventLog, handlerResult, context, reactorType as Function,
375
- this._eventStoreName, this._namespace, this._resultHandler);
376
- }, { delivery: ArtifactDelivery.Events, eventContext: context, methodName });
361
+ await invokeReactorHandler(artifact, methodName, content, context, services, result =>
362
+ dispatchReactorSideEffects(this._eventLog, result, context, reactorType as Function,
363
+ this._eventStoreName, this._namespace, this._resultHandler));
377
364
 
378
365
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
379
366
  } catch (err) {
@@ -446,65 +433,6 @@ export class Reactors implements IReactors {
446
433
  }
447
434
  }
448
435
 
449
- private getEventTypesFor(reactorType: Constructor): EventTypeEntry[] {
450
- const proto = reactorType.prototype as Record<string, unknown>;
451
- const entries: EventTypeEntry[] = [];
452
- const replayHandlers = new Map<string, string>();
453
- const eventTypes = new Map<string, { eventTypeClass: Function; id: string; generation: number }>();
454
-
455
- for (const eventTypeClass of this._clientArtifacts.eventTypes) {
456
- const metadata = getEventTypeMetadata(eventTypeClass);
457
- if (metadata) {
458
- eventTypes.set((eventTypeClass as Function).name, {
459
- eventTypeClass: eventTypeClass as Function,
460
- id: metadata.eventType.id.value,
461
- generation: metadata.eventType.generation.value
462
- });
463
- }
464
- }
465
-
466
- // A derived method shadows a base method of the same name, even if it is not marked for replay.
467
- const seenMethods = new Set<string>();
468
- for (let current = proto; current && current !== Object.prototype; current = Object.getPrototypeOf(current) as Record<string, unknown>) {
469
- for (const name of Object.getOwnPropertyNames(current)) {
470
- if (seenMethods.has(name)) continue;
471
- seenMethods.add(name);
472
- const method = current[name];
473
- if (typeof method !== 'function') continue;
474
- const replayEventType = getReplayEventType(method);
475
- if (replayEventType === undefined) continue;
476
-
477
- const eventType = replayEventType === true
478
- ? eventTypes.get(name.startsWith('replay') ? name.slice('replay'.length) : '')
479
- : [...eventTypes.values()].find(candidate => candidate.eventTypeClass === replayEventType);
480
- if (!eventType) {
481
- throw new Error(`Replay handler '${name}' on reactor '${(reactorType as Function).name}' has no registered event type.`);
482
- }
483
- if (replayHandlers.has(eventType.id)) {
484
- throw new Error(`Reactor '${(reactorType as Function).name}' has multiple replay handlers for event type '${eventType.id}': '${replayHandlers.get(eventType.id)}' and '${name}'.`);
485
- }
486
- replayHandlers.set(eventType.id, name);
487
- }
488
- }
489
-
490
- for (const [className, eventType] of eventTypes) {
491
- const methodName = className.charAt(0).toLowerCase() + className.slice(1);
492
- const liveMethod = proto[methodName];
493
- const liveMethodName = typeof liveMethod === 'function' && getReplayEventType(liveMethod) === undefined ? methodName : undefined;
494
- const replayMethodName = replayHandlers.get(eventType.id);
495
- if (liveMethodName || replayMethodName) {
496
- entries.push({
497
- id: eventType.id,
498
- generation: eventType.generation,
499
- methodName: liveMethodName,
500
- replayMethodName
501
- });
502
- }
503
- }
504
-
505
- return entries;
506
- }
507
-
508
436
  private disconnectAll(): void {
509
437
  for (const controller of this._observations.values()) controller.abort();
510
438
  for (const queue of this._queues.values()) queue.complete();
@@ -34,6 +34,19 @@ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOpe
34
34
  // JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls.
35
35
  const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u;
36
36
 
37
+ // Observers ReactorScenario registers to latch unsupported operations, keyed by sequence.
38
+ const unsupportedObservers = new WeakMap<object, (error: UnsupportedEventSequenceOperation) => void>();
39
+
40
+ /**
41
+ * Observe every unsupported operation a scenario sequence reports, however the caller receives it.
42
+ * Internal to the testing harness; ReactorScenario uses it to fail deliveries that swallow such errors.
43
+ * @param sequence - The in-process sequence to observe.
44
+ * @param observer - Called with each unsupported-operation error the sequence creates.
45
+ */
46
+ export function observeUnsupportedOperations(sequence: object, observer: (error: UnsupportedEventSequenceOperation) => void): void {
47
+ unsupportedObservers.set(sequence, observer);
48
+ }
49
+
37
50
  /** Fixture-backed, scenario-local append sequence; no kernel or observer scheduler is started. */
38
51
  export class InProcessEventSequence implements IEventSequence {
39
52
  readonly id: EventSequenceId;
@@ -455,6 +468,8 @@ export class InProcessEventSequence implements IEventSequence {
455
468
  }
456
469
 
457
470
  private unsupported(operation: string, artifact: string, reason: string): UnsupportedEventSequenceOperation {
458
- return new UnsupportedEventSequenceOperation(operation, artifact, reason);
471
+ const error = new UnsupportedEventSequenceOperation(operation, artifact, reason);
472
+ unsupportedObservers.get(this)?.(error);
473
+ return error;
459
474
  }
460
475
  }
@@ -0,0 +1,13 @@
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 type { EventContext } from '../events/EventContext.js';
5
+
6
+ /** Observable outcome of one ordered source-partition delivery (not a kernel checkpoint). */
7
+ export interface ReactorDeliveryResult {
8
+ readonly sourceId: string;
9
+ readonly handled: readonly EventContext[];
10
+ readonly skipped: readonly EventContext[];
11
+ readonly completed: boolean;
12
+ readonly error?: unknown;
13
+ }
@@ -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 { chai, describe, it } from 'vitest';
6
+ import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
7
+ import { eventType } from '../events/eventTypeDecorator.js';
8
+ import { reactor } from '../reactors/reactor.js';
9
+ import { ReactorScenario } from './ReactorScenario.js';
10
+ import { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
11
+
12
+ chai.should();
13
+ @eventType('rejection-event')
14
+ class Registered {
15
+ @field(String) name: string;
16
+ constructor(name: string) { this.name = name; }
17
+ }
18
+ class Unregistered { constructor(readonly name: string) {} }
19
+ const options = { artifacts: { eventTypes: [Registered] }, constraints: 'disabled' as const };
20
+
21
+ async function rejected(operation: string, call: () => Promise<unknown>): Promise<void> {
22
+ await call().then(() => { throw new Error('expected rejection'); }, error => {
23
+ (error instanceof UnsupportedReactorOperation).should.be.true;
24
+ error.operation.should.equal(operation);
25
+ error.message.should.contain('Use a kernel-backed test.');
26
+ });
27
+ }
28
+
29
+ describe('unsupported reactor operations', () => {
30
+ it('rejects missing registration, custom sequence and commands during configuration', () => {
31
+ class Plain {}
32
+ (() => new ReactorScenario(Plain, options)).should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
33
+ @reactor('custom-sequence', 'custom') class Custom { registered() {} }
34
+ (() => new ReactorScenario(Custom, options)).should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
35
+ @reactor('commands') class Commands { registered() {} }
36
+ (() => new ReactorScenario(Commands, { ...options, commandTypes: [Unregistered] }))
37
+ .should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
38
+ });
39
+
40
+ it('rejects replay and redelivery asynchronously without mutation', async () => {
41
+ @reactor('replay-rejection') class R { registered() {} }
42
+ const scenario = new ReactorScenario(R, options);
43
+ await rejected('replay', () => scenario.replay());
44
+ await rejected('redeliver', () => scenario.redeliver());
45
+ await rejected('when.events', () => scenario.when.forEventSource('A').events());
46
+ scenario.results.length.should.equal(0);
47
+ scenario.sideEffects.length.should.equal(0);
48
+ });
49
+
50
+ it('rejects unsupported returned values instead of silently dropping them', async () => {
51
+ for (const value of [{ command: true }, [new Registered('ok'), { command: true }], [undefined]]) {
52
+ @reactor('unknown-result') class R { registered() { return value; } }
53
+ const scenario = new ReactorScenario(R, options);
54
+ await rejected('handler.return', () => scenario.when.forEventSource('A').events(new Registered('one')));
55
+ scenario.sideEffects.length.should.equal(0);
56
+ scenario.results[0].completed.should.be.false;
57
+ }
58
+ });
59
+
60
+ it('rejects unsupported setup before invocation and without delivery results', async () => {
61
+ let called = 0;
62
+ @reactor('unsupported-input') class R { registered() { called++; } }
63
+ const scenario = new ReactorScenario(R, options);
64
+ await scenario.given.forEventSource('A').events(new Registered('ok'), new Unregistered('bad')).then(() => {
65
+ throw new Error('expected rejection');
66
+ }, error => { error.message.should.contain('Use a kernel-backed test.'); });
67
+ called.should.equal(0);
68
+ scenario.results.length.should.equal(0);
69
+ await scenario.when.forEventSource('A').events(new Registered('after'));
70
+ scenario.results[0].handled[0].sequenceNumber.should.equal(0n);
71
+ });
72
+
73
+ it('rejects missing read-model services instead of supplying fake data', async () => {
74
+ @reactor('read-model-rejection') class R {
75
+ registered(_event: Registered, _context: unknown, services: { readModels: { findInstanceById(): unknown } }) {
76
+ return services.readModels.findInstanceById();
77
+ }
78
+ }
79
+ const scenario = new ReactorScenario(R, options);
80
+ await rejected('services.readModels.findInstanceById', () => scenario.when.forEventSource('A').events(new Registered('one')));
81
+ });
82
+
83
+ it('preserves processing-only, completion-only and activation failures', async () => {
84
+ @reactor('failure-rejection') class R { registered() { throw new Error('handler'); } }
85
+ const processing = new ReactorScenario(R, { ...options, artifactActivator: type => ({ instance: new type() }) });
86
+ await processing.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
87
+ error => { String(error).should.contain('handler'); });
88
+ processing.results[0].completed.should.be.false;
89
+ @reactor('completion-rejection') class Complete { registered() {} }
90
+ const completion = new ReactorScenario(Complete, { ...options, artifactActivator: type => ({ instance: new type(),
91
+ complete: () => { throw new Error('completion'); } }) });
92
+ await completion.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
93
+ error => { (error instanceof ArtifactCompletionFailed).should.be.true; chai.expect(error.processingError).to.be.undefined; });
94
+ completion.results[0].completed.should.be.false;
95
+ const activation = new ReactorScenario(Complete, { ...options, artifactActivator: () => { throw new Error('activation'); } });
96
+ await activation.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
97
+ error => { String(error).should.contain('activation'); });
98
+ activation.results[0].handled.length.should.equal(0);
99
+ });
100
+ });