@cratis/chronicle 6.12.0 → 6.14.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 (93) hide show
  1. package/EventStore.ts +4 -0
  2. package/IEventStore.ts +7 -0
  3. package/dist/EventStore.d.ts +2 -0
  4. package/dist/EventStore.d.ts.map +1 -1
  5. package/dist/EventStore.js +3 -0
  6. package/dist/EventStore.js.map +1 -1
  7. package/dist/IEventStore.d.ts +6 -0
  8. package/dist/IEventStore.d.ts.map +1 -1
  9. package/dist/observation/IObservers.d.ts +28 -0
  10. package/dist/observation/IObservers.d.ts.map +1 -0
  11. package/dist/observation/IObservers.js +4 -0
  12. package/dist/observation/IObservers.js.map +1 -0
  13. package/dist/observation/ObserverInformation.d.ts +24 -0
  14. package/dist/observation/ObserverInformation.d.ts.map +1 -0
  15. package/dist/observation/ObserverInformation.js +4 -0
  16. package/dist/observation/ObserverInformation.js.map +1 -0
  17. package/dist/observation/ObserverRemovalOutcome.d.ts +14 -0
  18. package/dist/observation/ObserverRemovalOutcome.d.ts.map +1 -0
  19. package/dist/observation/ObserverRemovalOutcome.js +17 -0
  20. package/dist/observation/ObserverRemovalOutcome.js.map +1 -0
  21. package/dist/observation/ObserverRemovalResult.d.ts +17 -0
  22. package/dist/observation/ObserverRemovalResult.d.ts.map +1 -0
  23. package/dist/observation/ObserverRemovalResult.js +4 -0
  24. package/dist/observation/ObserverRemovalResult.js.map +1 -0
  25. package/dist/observation/ObserverType.d.ts +16 -0
  26. package/dist/observation/ObserverType.d.ts.map +1 -0
  27. package/dist/observation/ObserverType.js +19 -0
  28. package/dist/observation/ObserverType.js.map +1 -0
  29. package/dist/observation/Observers.d.ts +24 -0
  30. package/dist/observation/Observers.d.ts.map +1 -0
  31. package/dist/observation/Observers.js +43 -0
  32. package/dist/observation/Observers.js.map +1 -0
  33. package/dist/observation/index.d.ts +8 -0
  34. package/dist/observation/index.d.ts.map +1 -1
  35. package/dist/observation/index.js +5 -0
  36. package/dist/observation/index.js.map +1 -1
  37. package/dist/observation/toObserverInformation.d.ts +16 -0
  38. package/dist/observation/toObserverInformation.d.ts.map +1 -0
  39. package/dist/observation/toObserverInformation.js +43 -0
  40. package/dist/observation/toObserverInformation.js.map +1 -0
  41. package/dist/observation/toObserverRemovalResult.d.ts +16 -0
  42. package/dist/observation/toObserverRemovalResult.d.ts.map +1 -0
  43. package/dist/observation/toObserverRemovalResult.js +37 -0
  44. package/dist/observation/toObserverRemovalResult.js.map +1 -0
  45. package/dist/reducers/ReducerEventDispatcher.d.ts +14 -0
  46. package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -0
  47. package/dist/reducers/ReducerEventDispatcher.js +35 -0
  48. package/dist/reducers/ReducerEventDispatcher.js.map +1 -0
  49. package/dist/reducers/ReducerEventHandler.d.ts +7 -0
  50. package/dist/reducers/ReducerEventHandler.d.ts.map +1 -0
  51. package/dist/reducers/ReducerEventHandler.js +4 -0
  52. package/dist/reducers/ReducerEventHandler.js.map +1 -0
  53. package/dist/reducers/Reducers.d.ts +0 -1
  54. package/dist/reducers/Reducers.d.ts.map +1 -1
  55. package/dist/reducers/Reducers.js +11 -30
  56. package/dist/reducers/Reducers.js.map +1 -1
  57. package/dist/testing/ReadModelScenario.d.ts +33 -0
  58. package/dist/testing/ReadModelScenario.d.ts.map +1 -0
  59. package/dist/testing/ReadModelScenario.js +137 -0
  60. package/dist/testing/ReadModelScenario.js.map +1 -0
  61. package/dist/testing/ReadModelScenarioGiven.d.ts +11 -0
  62. package/dist/testing/ReadModelScenarioGiven.d.ts.map +1 -0
  63. package/dist/testing/ReadModelScenarioGiven.js +18 -0
  64. package/dist/testing/ReadModelScenarioGiven.js.map +1 -0
  65. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts +10 -0
  66. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts.map +1 -0
  67. package/dist/testing/ReadModelScenarioGivenBuilder.js +15 -0
  68. package/dist/testing/ReadModelScenarioGivenBuilder.js.map +1 -0
  69. package/dist/testing/index.d.ts +4 -0
  70. package/dist/testing/index.d.ts.map +1 -0
  71. package/dist/testing/index.js +6 -0
  72. package/dist/testing/index.js.map +1 -0
  73. package/dist/tsconfig.tsbuildinfo +1 -1
  74. package/observation/IObservers.ts +32 -0
  75. package/observation/ObserverInformation.ts +33 -0
  76. package/observation/ObserverRemovalOutcome.ts +19 -0
  77. package/observation/ObserverRemovalResult.ts +22 -0
  78. package/observation/ObserverType.ts +22 -0
  79. package/observation/Observers.spec.ts +115 -0
  80. package/observation/Observers.ts +49 -0
  81. package/observation/index.ts +8 -0
  82. package/observation/toObserverInformation.ts +46 -0
  83. package/observation/toObserverRemovalResult.ts +40 -0
  84. package/package.json +7 -2
  85. package/reducers/ReducerEventDispatcher.ts +47 -0
  86. package/reducers/ReducerEventHandler.ts +9 -0
  87. package/reducers/Reducers.ts +13 -42
  88. package/testing/ReadModelScenario.example.ts +29 -0
  89. package/testing/ReadModelScenario.spec.ts +191 -0
  90. package/testing/ReadModelScenario.ts +151 -0
  91. package/testing/ReadModelScenarioGiven.ts +16 -0
  92. package/testing/ReadModelScenarioGivenBuilder.ts +15 -0
  93. package/testing/index.ts +6 -0
@@ -0,0 +1,47 @@
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 { Constructor } from '@cratis/fundamentals';
5
+ import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
6
+ import type { EventContext } from '../events/EventContext.js';
7
+ import type { ReducerEventHandler } from './ReducerEventHandler.js';
8
+
9
+ /** Selects and invokes the same reducer handlers for kernel deliveries and in-process scenarios. */
10
+ export class ReducerEventDispatcher {
11
+ readonly handlers: ReducerEventHandler[];
12
+
13
+ /** Discovers event methods from the artifact event types. */
14
+ constructor(reducerType: Constructor, eventTypes: readonly Constructor[]) {
15
+ const proto = reducerType.prototype as Record<string, unknown>;
16
+ this.handlers = [];
17
+ for (const eventTypeClass of eventTypes) {
18
+ const metadata = getEventTypeMetadata(eventTypeClass);
19
+ if (!metadata) continue;
20
+ const className = (eventTypeClass as Function).name;
21
+ const methodName = className.charAt(0).toLowerCase() + className.slice(1);
22
+ if (typeof proto[methodName] === 'function') {
23
+ this.handlers.push({
24
+ id: metadata.eventType.id.value,
25
+ generation: metadata.eventType.generation.value,
26
+ methodName
27
+ });
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Resolves a registered event type, or returns undefined for an unsubscribed event. */
33
+ handlerFor(eventTypeId: string): ReducerEventHandler | undefined {
34
+ return this.handlers.find(handler => handler.id === eventTypeId);
35
+ }
36
+
37
+ /** Calls the selected handler with the event, current state, and context. */
38
+ async invoke(
39
+ instance: Record<string, Function>,
40
+ handler: ReducerEventHandler,
41
+ event: unknown,
42
+ currentState: unknown,
43
+ context: EventContext
44
+ ): Promise<unknown> {
45
+ return await instance[handler.methodName](event, currentState, context) as unknown;
46
+ }
47
+ }
@@ -0,0 +1,9 @@
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
+ /** Event type and camel-case method selected for a reducer observation. */
5
+ export interface ReducerEventHandler {
6
+ readonly id: string;
7
+ readonly generation: number;
8
+ readonly methodName: string;
9
+ }
@@ -8,7 +8,6 @@ import { ObservationState, ReadModelObserverType, ReducerMessage } from '@cratis
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';
@@ -16,6 +15,7 @@ import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
16
15
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
17
16
  import { IReducers } from './IReducers.js';
18
17
  import { getReducerMetadata } from './reducer.js';
18
+ import { ReducerEventDispatcher } from './ReducerEventDispatcher.js';
19
19
  import { getReadModelMetadata } from '../readModels/index.js';
20
20
  import { getReadModelId } from '../readModels/readModel.js';
21
21
  import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
@@ -28,12 +28,6 @@ const EVENT_SOURCE_ID_KEY = '$eventSourceId';
28
28
  /** Sentinel sequence number sent back when no event was successfully processed. */
29
29
  const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
30
30
 
31
- interface EventTypeEntry {
32
- readonly id: string;
33
- readonly generation: number;
34
- readonly methodName: string;
35
- }
36
-
37
31
  /**
38
32
  * A push-based async queue that implements {@link AsyncIterable} for use with nice-grpc
39
33
  * bidirectional streaming. Values pushed via {@link send} are yielded in order to
@@ -229,29 +223,29 @@ export class Reducers implements IReducers {
229
223
  if (this._disposed) return;
230
224
  const metadata = getReducerMetadata(reducerType)!;
231
225
  const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
232
- const eventTypes = this.getEventTypesFor(reducerType);
226
+ const dispatcher = new ReducerEventDispatcher(reducerType, this._clientArtifacts.eventTypes);
233
227
  const readModelName = this.getReducerReadModelIdentifier(reducerType);
234
228
 
235
229
  this._logger.info('Starting reducer observation', {
236
230
  reducerId: id,
237
231
  eventSequenceId,
238
232
  readModel: readModelName,
239
- handlerCount: eventTypes.length,
240
- handlers: eventTypes.map(e => e.methodName)
233
+ handlerCount: dispatcher.handlers.length,
234
+ handlers: dispatcher.handlers.map(e => e.methodName)
241
235
  });
242
236
 
243
- void this.runObservation(id, reducerType, eventSequenceId, eventTypes, readModelName);
237
+ void this.runObservation(id, reducerType, eventSequenceId, readModelName, dispatcher);
244
238
  }
245
239
 
246
240
  private async runObservation(
247
241
  id: string,
248
242
  reducerType: Constructor,
249
243
  eventSequenceId: string,
250
- eventTypes: EventTypeEntry[],
251
- readModelName: string
244
+ readModelName: string,
245
+ dispatcher: ReducerEventDispatcher
252
246
  ): Promise<void> {
253
247
  try {
254
- await this.observeReducer(id, reducerType, eventSequenceId, eventTypes, readModelName);
248
+ await this.observeReducer(id, reducerType, eventSequenceId, readModelName, dispatcher);
255
249
  } catch (error) {
256
250
  this._logger.error('Reducer observation loop exited with error', { reducerId: id, error: String(error) });
257
251
  }
@@ -301,8 +295,8 @@ export class Reducers implements IReducers {
301
295
  id: string,
302
296
  reducerType: Constructor,
303
297
  eventSequenceId: string,
304
- eventTypes: EventTypeEntry[],
305
- readModelName: string
298
+ readModelName: string,
299
+ dispatcher: ReducerEventDispatcher
306
300
  ): Promise<void> {
307
301
  const queue = new AsyncQueue<ReducerMessage>();
308
302
  const controller = new AbortController();
@@ -319,7 +313,7 @@ export class Reducers implements IReducers {
319
313
  Reducer: {
320
314
  ReducerId: id,
321
315
  EventSequenceId: eventSequenceId,
322
- EventTypes: eventTypes.map(et => ({
316
+ EventTypes: dispatcher.handlers.map(et => ({
323
317
  EventType: { Id: et.id, Generation: et.generation, Tombstone: false },
324
318
  Key: EVENT_SOURCE_ID_KEY
325
319
  })),
@@ -377,7 +371,7 @@ export class Reducers implements IReducers {
377
371
  continue;
378
372
  }
379
373
 
380
- const entry = eventTypes.find(et => et.id === eventTypeId);
374
+ const entry = dispatcher.handlerFor(eventTypeId);
381
375
  if (!entry) {
382
376
  this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
383
377
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
@@ -395,7 +389,7 @@ export class Reducers implements IReducers {
395
389
  hasState: currentState !== undefined
396
390
  });
397
391
 
398
- currentState = await reducerInstance[entry.methodName](content, currentState, context);
392
+ currentState = await dispatcher.invoke(reducerInstance, entry, content, currentState, context);
399
393
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
400
394
  } catch (err) {
401
395
  this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });
@@ -442,29 +436,6 @@ export class Reducers implements IReducers {
442
436
  }
443
437
  }
444
438
 
445
- private getEventTypesFor(reducerType: Constructor): EventTypeEntry[] {
446
- const proto = reducerType.prototype as Record<string, unknown>;
447
- const entries: EventTypeEntry[] = [];
448
-
449
- for (const eventTypeClass of this._clientArtifacts.eventTypes) {
450
- const eventTypeMeta = getEventTypeMetadata(eventTypeClass);
451
- if (!eventTypeMeta) continue;
452
-
453
- const className = (eventTypeClass as Function).name;
454
- const methodName = className.charAt(0).toLowerCase() + className.slice(1);
455
-
456
- if (typeof proto[methodName] === 'function') {
457
- entries.push({
458
- id: eventTypeMeta.eventType.id.value,
459
- generation: eventTypeMeta.eventType.generation.value,
460
- methodName
461
- });
462
- }
463
- }
464
-
465
- return entries;
466
- }
467
-
468
439
  private disconnectAll(): void {
469
440
  for (const controller of this._observations.values()) controller.abort();
470
441
  for (const queue of this._queues.values()) queue.complete();
@@ -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 { eventType } from '../events/eventTypeDecorator.js';
5
+ import { reducer } from '../reducers/reducer.js';
6
+ import { ReadModelScenario } from './index.js';
7
+
8
+ class BookBorrowed {
9
+ constructor(readonly title: string) {}
10
+ }
11
+ eventType('book-borrowed')(BookBorrowed);
12
+
13
+ class BookStatus {
14
+ title = '';
15
+ }
16
+
17
+ class BookStatusReducer {
18
+ bookBorrowed(event: BookBorrowed): BookStatus {
19
+ return { title: event.title };
20
+ }
21
+ }
22
+ reducer('book-status-reducer', undefined, BookStatus)(BookStatusReducer);
23
+
24
+ /** An in-process read model example compiled with the SDK. */
25
+ export async function readModelScenarioExample(): Promise<BookStatus | null> {
26
+ const scenario = new ReadModelScenario(BookStatus);
27
+ scenario.given.forEventSource('book-42').events(new BookBorrowed('Dune'));
28
+ return await scenario.instanceForEventSourceId('book-42');
29
+ }
@@ -0,0 +1,191 @@
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 { describe, expect, it } from 'vitest';
5
+ import { ConceptAs, field } from '@cratis/fundamentals';
6
+ import type { Constructor } from '@cratis/fundamentals';
7
+ import { eventType } from '../events/eventTypeDecorator.js';
8
+ import type { EventContext } from '../events/EventContext.js';
9
+ import { fromEvent } from '../projections/modelBound/fromEvent.js';
10
+ import { projection } from '../projections/declarative/projection.js';
11
+ import { reducer } from '../reducers/reducer.js';
12
+ import { ReadModelScenario, ReadModelScenarioGivenBuilder } from './index.js';
13
+ import { readModelScenarioExample } from './ReadModelScenario.example.js';
14
+
15
+ class ItemAdded {
16
+ constructor(readonly amount: number) {}
17
+ }
18
+ eventType('scenario-item-added')(ItemAdded);
19
+ class ItemRemoved {}
20
+ eventType('scenario-item-removed')(ItemRemoved);
21
+ class UnrelatedEvent {}
22
+ eventType('scenario-unrelated')(UnrelatedEvent);
23
+ class BookTitle extends ConceptAs<string> {
24
+ static readonly valueType = String;
25
+ }
26
+ class BookNamed {
27
+ @field(BookTitle) title: BookTitle;
28
+ constructor(title: BookTitle) { this.title = title; }
29
+ }
30
+ eventType('scenario-book-named')(BookNamed);
31
+ class BookState { title = ''; }
32
+ class BookReducer {
33
+ bookNamed(event: { title: string }): BookState { return { title: event.title }; }
34
+ }
35
+ reducer('scenario-book-reducer', undefined, BookState)(BookReducer);
36
+
37
+ const nullableStates: unknown[] = [];
38
+ class NullableState { count = 0; }
39
+ class NullableReducer {
40
+ itemAdded(_event: ItemAdded, current: NullableState | null | undefined): null {
41
+ nullableStates.push(current);
42
+ return null;
43
+ }
44
+ }
45
+ reducer('scenario-nullable-reducer', undefined, NullableState)(NullableReducer);
46
+
47
+ const reducerFailure = new Error('broken handler');
48
+ class FailingState {}
49
+ class FailingReducer {
50
+ itemAdded(): never { throw reducerFailure; }
51
+ }
52
+ reducer('scenario-failing-reducer', undefined, FailingState)(FailingReducer);
53
+
54
+ class ItemState {
55
+ count = 0;
56
+ }
57
+ const contexts: EventContext[] = [];
58
+ const incomingStates: Array<ItemState | undefined> = [];
59
+ class ItemReducer {
60
+ async itemAdded(event: ItemAdded, current: ItemState | undefined, context: EventContext): Promise<ItemState> {
61
+ contexts.push(context);
62
+ incomingStates.push(current);
63
+ await Promise.resolve();
64
+ return { count: (current?.count ?? 0) + event.amount };
65
+ }
66
+ itemRemoved(): undefined { return undefined; }
67
+ }
68
+ reducer('scenario-item-reducer', undefined, ItemState)(ItemReducer);
69
+
70
+ const artifacts = {
71
+ reducers: [ItemReducer],
72
+ eventTypes: [ItemAdded, ItemRemoved, UnrelatedEvent],
73
+ projections: [] as Constructor[]
74
+ };
75
+
76
+ describe('ReadModelScenario', () => {
77
+ it('runs the compiled documentation example', async () => {
78
+ expect(await readModelScenarioExample()).toEqual({ title: 'Dune' });
79
+ });
80
+
81
+ it('exposes a named given builder for selecting an event source', () => {
82
+ const scenario = new ReadModelScenario(ItemState, artifacts);
83
+ expect(scenario.given).toBeInstanceOf(ReadModelScenarioGivenBuilder);
84
+ });
85
+
86
+ it('returns null without matching events and distinguishes that from deletion', async () => {
87
+ const scenario = new ReadModelScenario(ItemState, artifacts);
88
+ scenario.given.forEventSource('A').events(new UnrelatedEvent());
89
+ expect(await scenario.instance).toBeNull();
90
+ expect(await scenario.instanceForEventSourceId('A')).toBeNull();
91
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(false);
92
+ });
93
+
94
+ it('passes each event, current state, and realistic ordered contexts to an async reducer', async () => {
95
+ contexts.length = 0;
96
+ incomingStates.length = 0;
97
+ const scenario = new ReadModelScenario(ItemState, artifacts);
98
+ scenario.given.forEventSource('A').events(new ItemAdded(2), new ItemAdded(3));
99
+ expect(await scenario.instance).toEqual({ count: 5 });
100
+ expect(incomingStates).toEqual([undefined, { count: 2 }]);
101
+ expect(contexts.map(context => context.sequenceNumber)).toEqual([0n, 1n]);
102
+ expect(contexts.map(context => context.eventSourceId)).toEqual(['A', 'A']);
103
+ expect(contexts.map(context => context.eventSourceType)).toEqual(['Default', 'Default']);
104
+ expect(contexts.map(context => context.eventStreamType)).toEqual(['All', 'All']);
105
+ expect(contexts.map(context => context.eventStreamId)).toEqual(['Default', 'Default']);
106
+ expect(contexts.map(context => context.eventStore)).toEqual(['[NotSet]', '[NotSet]']);
107
+ expect(contexts.map(context => context.namespace)).toEqual(['[NotSet]', '[NotSet]']);
108
+ expect(contexts.map(context => context.eventType.id.value)).toEqual(['scenario-item-added', 'scenario-item-added']);
109
+ expect(contexts.map(context => context.eventType.generation.value)).toEqual([1, 1]);
110
+ expect(contexts.every(context => context.occurred instanceof Date && !Number.isNaN(context.occurred.getTime()))).toBe(true);
111
+ expect(contexts.map(context => context.correlationId)).toEqual([
112
+ '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000'
113
+ ]);
114
+ });
115
+
116
+ it('keeps interleaved event sources separate and rejects an ambiguous single instance', async () => {
117
+ contexts.length = 0;
118
+ const scenario = new ReadModelScenario(ItemState, artifacts);
119
+ scenario.given.forEventSource('A').events(new ItemAdded(2));
120
+ scenario.given.forEventSource('B').events(new ItemAdded(7));
121
+ scenario.given.forEventSource('A').events(new ItemAdded(3));
122
+ expect(await scenario.instanceForEventSourceId('A')).toEqual({ count: 5 });
123
+ expect(await scenario.instanceForEventSourceId('B')).toEqual({ count: 7 });
124
+ expect(contexts.map(context => context.sequenceNumber)).toEqual([0n, 1n, 2n]);
125
+ expect(contexts.map(context => context.eventSourceId)).toEqual(['A', 'B', 'A']);
126
+ await expect(scenario.instance).rejects.toThrow('Multiple read model instances');
127
+ });
128
+
129
+ it('replays added events after an earlier result was read', async () => {
130
+ const scenario = new ReadModelScenario(ItemState, artifacts);
131
+ scenario.given.forEventSource('A').events(new ItemAdded(2));
132
+ expect(await scenario.instance).toEqual({ count: 2 });
133
+ scenario.given.forEventSource('A').events(new ItemAdded(3));
134
+ expect(await scenario.instance).toEqual({ count: 5 });
135
+ });
136
+
137
+ it('treats an undefined reducer result as deletion, not missing history', async () => {
138
+ incomingStates.length = 0;
139
+ const scenario = new ReadModelScenario(ItemState, artifacts);
140
+ scenario.given.forEventSource('A').events(new ItemAdded(2), new ItemRemoved());
141
+ expect(await scenario.instanceForEventSourceId('A')).toBeNull();
142
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(true);
143
+ scenario.given.forEventSource('A').events(new ItemAdded(4));
144
+ expect(await scenario.instance).toEqual({ count: 4 });
145
+ expect(incomingStates.at(-1)).toBeUndefined();
146
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(false);
147
+ });
148
+
149
+ it('serializes concept properties as primitives, like production append', async () => {
150
+ const scenario = new ReadModelScenario(BookState, {
151
+ reducers: [BookReducer], eventTypes: [BookNamed], projections: []
152
+ });
153
+ scenario.given.forEventSource('book-42').events(new BookNamed(new BookTitle('Dune')));
154
+ expect(await scenario.instance).toEqual({ title: 'Dune' });
155
+ });
156
+
157
+ it('preserves a null reducer result as null state rather than a deletion', async () => {
158
+ nullableStates.length = 0;
159
+ const scenario = new ReadModelScenario(NullableState, {
160
+ reducers: [NullableReducer], eventTypes: [ItemAdded], projections: []
161
+ });
162
+ scenario.given.forEventSource('A').events(new ItemAdded(1), new ItemAdded(2));
163
+ expect(await scenario.instanceForEventSourceId('A')).toBeNull();
164
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(false);
165
+ expect(nullableStates).toEqual([undefined, null]);
166
+ });
167
+
168
+ it('wraps reducer failures with the read model and reducer names and preserves the cause', async () => {
169
+ const scenario = new ReadModelScenario(FailingState, {
170
+ reducers: [FailingReducer], eventTypes: [ItemAdded], projections: []
171
+ });
172
+ scenario.given.forEventSource('A').events(new ItemAdded(1));
173
+ await expect(scenario.instance).rejects.toMatchObject({
174
+ message: expect.stringContaining('FailingState'),
175
+ cause: reducerFailure
176
+ });
177
+ await expect(scenario.instance).rejects.toThrow('FailingReducer');
178
+ });
179
+
180
+ it('rejects model-bound and declarative projections rather than silently returning null', () => {
181
+ class ProjectedState {}
182
+ fromEvent(ItemAdded)(ProjectedState);
183
+ expect(() => new ReadModelScenario(ProjectedState, artifacts)).toThrow('not supported yet; use a kernel-backed test');
184
+
185
+ class FluentState {}
186
+ class FluentProjection {}
187
+ projection('scenario-fluent-projection', FluentState)(FluentProjection);
188
+ expect(() => new ReadModelScenario(FluentState, { ...artifacts, projections: [FluentProjection] }))
189
+ .toThrow('not supported yet; use a kernel-backed test');
190
+ });
191
+ });
@@ -0,0 +1,151 @@
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 'reflect-metadata';
5
+ import { JsonSerializer } from '@cratis/fundamentals';
6
+ import type { Constructor } from '@cratis/fundamentals';
7
+ import type { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
8
+ import type { EventContext } from '../events/EventContext.js';
9
+ import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
10
+ import { getFromEventMetadata } from '../projections/modelBound/fromEvent.js';
11
+ import { getRemovedWithClassMetadata } from '../projections/modelBound/removedWith.js';
12
+ import { getRemovedWithJoinClassMetadata } from '../projections/modelBound/removedWithJoin.js';
13
+ import { getClearWithClassMetadata } from '../projections/modelBound/clearWith.js';
14
+ import { getVariantOfMetadata } from '../projections/modelBound/variantOf.js';
15
+ import { getEntersOnMetadata } from '../projections/modelBound/entersOn.js';
16
+ import { getProjectionMetadata } from '../projections/declarative/projection.js';
17
+ import { getReducerMetadata } from '../reducers/reducer.js';
18
+ import { ReducerEventDispatcher } from '../reducers/ReducerEventDispatcher.js';
19
+ import { DecoratorType } from '../types/DecoratorType.js';
20
+ import { hasModelBoundProperties, TypeDiscoverer } from '../types/TypeDiscoverer.js';
21
+ import { ReadModelScenarioGivenBuilder } from './ReadModelScenarioGivenBuilder.js';
22
+
23
+ type ScenarioArtifacts = Pick<IClientArtifactsProvider, 'reducers' | 'eventTypes' | 'projections'>;
24
+
25
+ type SeededEvent = { sourceId: string; content: unknown; context: EventContext };
26
+ type ReducedState<T> = { instance: T | null; deleted: boolean };
27
+
28
+ /**
29
+ * Folds seeded events through a reducer in-process, without a Chronicle kernel.
30
+ * Projections, observer scheduling, storage, migrations, and compliance are not simulated.
31
+ * Supply an artifact catalog to isolate a scenario from process-wide decorator discovery.
32
+ */
33
+ export class ReadModelScenario<TReadModel extends object> {
34
+ private readonly _readModelType: Constructor<TReadModel>;
35
+ private readonly _reducerType: Constructor;
36
+ private readonly _dispatcher: ReducerEventDispatcher;
37
+ private readonly _events: SeededEvent[] = [];
38
+ private _results: Promise<Map<string, ReducedState<TReadModel>>> | undefined;
39
+
40
+ /** Selects the reducer associated with the read model type. */
41
+ constructor(readModelType: Constructor<TReadModel>, artifacts?: ScenarioArtifacts) {
42
+ this._readModelType = readModelType;
43
+ const registered = artifacts ?? {
44
+ reducers: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.Reducer),
45
+ eventTypes: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.EventType),
46
+ projections: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.Projection)
47
+ };
48
+ const reducerTypes = registered.reducers.filter(type => getReducerMetadata(type)?.readModel === readModelType);
49
+ if (reducerTypes.length > 1) {
50
+ throw new Error(`Multiple reducers found for read model '${readModelType.name}'.`);
51
+ }
52
+ if (reducerTypes.length === 0) {
53
+ const projected = getFromEventMetadata(readModelType).length > 0 || hasModelBoundProperties(readModelType) ||
54
+ getRemovedWithClassMetadata(readModelType).length > 0 || getRemovedWithJoinClassMetadata(readModelType).length > 0 ||
55
+ getClearWithClassMetadata(readModelType).length > 0 || getVariantOfMetadata(readModelType) !== undefined ||
56
+ getEntersOnMetadata(readModelType).length > 0 ||
57
+ registered.projections.some(type => getProjectionMetadata(type)?.readModelType === readModelType);
58
+ if (projected) {
59
+ throw new Error(`Projection-backed read model '${readModelType.name}' is not supported yet; use a kernel-backed test.`);
60
+ }
61
+ throw new Error(`No reducer found for read model '${readModelType.name}'. Pass the read model type to @reducer; projections are not supported yet (use a kernel-backed test).`);
62
+ }
63
+ this._reducerType = reducerTypes[0];
64
+ this._dispatcher = new ReducerEventDispatcher(this._reducerType, registered.eventTypes);
65
+ }
66
+
67
+ /** Fluent entry point for event history. */
68
+ get given(): ReadModelScenarioGivenBuilder<TReadModel> {
69
+ return new ReadModelScenarioGivenBuilder(this);
70
+ }
71
+
72
+ /** Returns the sole materialized model, or null when no model exists. */
73
+ get instance(): Promise<TReadModel | null> {
74
+ return this.process().then(results => {
75
+ const materialized = [...results.values()].filter(result => result.instance !== null);
76
+ if (materialized.length > 1) {
77
+ throw new Error('Multiple read model instances materialized; use instanceForEventSourceId(id).');
78
+ }
79
+ return materialized[0]?.instance ?? null;
80
+ });
81
+ }
82
+
83
+ /** Returns the model for one source, or null if no handler created it or it was deleted. */
84
+ async instanceForEventSourceId(id: string): Promise<TReadModel | null> {
85
+ return (await this.process()).get(id)?.instance ?? null;
86
+ }
87
+
88
+ /** Distinguishes a model removed by a reducer from one never created. */
89
+ async wasDeletedForEventSourceId(id: string): Promise<boolean> {
90
+ return (await this.process()).get(id)?.deleted ?? false;
91
+ }
92
+
93
+ /** Collects events for a source; subsequent reads replay the complete seeded history. */
94
+ collectEventsFor(id: string, events: readonly object[]): void {
95
+ for (const event of events) {
96
+ const metadata = getEventTypeMetadata(event.constructor);
97
+ if (!metadata) throw new Error(`Event '${event.constructor.name}' has no @eventType metadata.`);
98
+ // The kernel delivers JSON objects, not instances of decorated event classes.
99
+ const content: unknown = JSON.parse(JsonSerializer.serialize(event));
100
+ this._events.push({
101
+ sourceId: id,
102
+ content,
103
+ context: {
104
+ eventSourceId: id,
105
+ eventSourceType: 'Default',
106
+ eventStreamType: 'All',
107
+ eventStreamId: 'Default',
108
+ eventStore: '[NotSet]',
109
+ namespace: '[NotSet]',
110
+ sequenceNumber: BigInt(this._events.length),
111
+ eventType: metadata.eventType,
112
+ occurred: new Date(),
113
+ correlationId: '00000000-0000-0000-0000-000000000000',
114
+ causation: [],
115
+ tags: []
116
+ }
117
+ });
118
+ }
119
+ this._results = undefined;
120
+ }
121
+
122
+ private process(): Promise<Map<string, ReducedState<TReadModel>>> {
123
+ if (!this._results) {
124
+ const events = [...this._events];
125
+ this._results = this.reduce(events);
126
+ }
127
+ return this._results;
128
+ }
129
+
130
+ private async reduce(events: readonly SeededEvent[]): Promise<Map<string, ReducedState<TReadModel>>> {
131
+ const results = new Map<string, ReducedState<TReadModel>>();
132
+ const reducer = new (this._reducerType as new () => Record<string, Function>)();
133
+ for (const event of events) {
134
+ const handler = this._dispatcher.handlerFor(event.context.eventType.id.value);
135
+ if (!handler) continue;
136
+ const prior = results.get(event.sourceId);
137
+ const previous = prior?.deleted ? undefined : prior?.instance;
138
+ let next: unknown;
139
+ try {
140
+ next = await this._dispatcher.invoke(reducer, handler, event.content, previous, event.context);
141
+ } catch (cause) {
142
+ throw new Error(`Reducer '${this._reducerType.name}' for read model '${this._readModelType.name}' failed`, { cause });
143
+ }
144
+ results.set(event.sourceId, {
145
+ instance: next === undefined ? null : next as TReadModel | null,
146
+ deleted: next === undefined
147
+ });
148
+ }
149
+ return results;
150
+ }
151
+ }
@@ -0,0 +1,16 @@
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 { ReadModelScenario } from './ReadModelScenario.js';
5
+
6
+ /** Seeds events belonging to one event source, in the order provided. */
7
+ export class ReadModelScenarioGiven<TReadModel extends object> {
8
+ /** Creates a builder for the selected event source. */
9
+ constructor(private readonly _scenario: ReadModelScenario<TReadModel>, private readonly _eventSourceId: string) {}
10
+
11
+ /** Adds events to the scenario without connecting to a Chronicle kernel. */
12
+ events(...events: object[]): ReadModelScenario<TReadModel> {
13
+ this._scenario.collectEventsFor(this._eventSourceId, events);
14
+ return this._scenario;
15
+ }
16
+ }
@@ -0,0 +1,15 @@
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 { ReadModelScenario } from './ReadModelScenario.js';
5
+ import { ReadModelScenarioGiven } from './ReadModelScenarioGiven.js';
6
+
7
+ /** Selects the event source for events seeded into a read model scenario. */
8
+ export class ReadModelScenarioGivenBuilder<TReadModel extends object> {
9
+ constructor(private readonly _scenario: ReadModelScenario<TReadModel>) {}
10
+
11
+ /** Creates a builder for the selected event source. */
12
+ forEventSource(id: string): ReadModelScenarioGiven<TReadModel> {
13
+ return new ReadModelScenarioGiven(this._scenario, id);
14
+ }
15
+ }
@@ -0,0 +1,6 @@
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
+ export { ReadModelScenario } from './ReadModelScenario.js';
5
+ export { ReadModelScenarioGiven } from './ReadModelScenarioGiven.js';
6
+ export { ReadModelScenarioGivenBuilder } from './ReadModelScenarioGivenBuilder.js';