@cratis/chronicle 6.15.0 → 6.16.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 (76) hide show
  1. package/ChronicleClient.ts +1 -1
  2. package/ChronicleOptions.ts +9 -1
  3. package/EventStore.ts +5 -3
  4. package/artifacts/ActivatedArtifact.ts +12 -0
  5. package/artifacts/ArtifactActivationContext.ts +37 -0
  6. package/artifacts/ArtifactDelivery.ts +8 -0
  7. package/artifacts/ArtifactKind.ts +8 -0
  8. package/artifacts/ClientArtifactsActivator.ts +10 -0
  9. package/artifacts/for_ChronicleOptions/when_configuring/with_an_artifact_activator.spec.ts +21 -0
  10. package/artifacts/index.ts +6 -0
  11. package/artifacts/withActivatedArtifact.ts +32 -0
  12. package/dist/ChronicleClient.js +1 -1
  13. package/dist/ChronicleClient.js.map +1 -1
  14. package/dist/ChronicleOptions.d.ts +4 -0
  15. package/dist/ChronicleOptions.d.ts.map +1 -1
  16. package/dist/ChronicleOptions.js +5 -1
  17. package/dist/ChronicleOptions.js.map +1 -1
  18. package/dist/EventStore.d.ts +2 -1
  19. package/dist/EventStore.d.ts.map +1 -1
  20. package/dist/EventStore.js +3 -3
  21. package/dist/EventStore.js.map +1 -1
  22. package/dist/artifacts/ActivatedArtifact.d.ts +10 -0
  23. package/dist/artifacts/ActivatedArtifact.d.ts.map +1 -0
  24. package/dist/artifacts/ActivatedArtifact.js +4 -0
  25. package/dist/artifacts/ActivatedArtifact.js.map +1 -0
  26. package/dist/artifacts/ArtifactActivationContext.d.ts +34 -0
  27. package/dist/artifacts/ArtifactActivationContext.d.ts.map +1 -0
  28. package/dist/artifacts/ArtifactActivationContext.js +4 -0
  29. package/dist/artifacts/ArtifactActivationContext.js.map +1 -0
  30. package/dist/artifacts/ArtifactDelivery.d.ts +6 -0
  31. package/dist/artifacts/ArtifactDelivery.d.ts.map +1 -0
  32. package/dist/artifacts/ArtifactDelivery.js +9 -0
  33. package/dist/artifacts/ArtifactDelivery.js.map +1 -0
  34. package/dist/artifacts/ArtifactKind.d.ts +6 -0
  35. package/dist/artifacts/ArtifactKind.d.ts.map +1 -0
  36. package/dist/artifacts/ArtifactKind.js +9 -0
  37. package/dist/artifacts/ArtifactKind.js.map +1 -0
  38. package/dist/artifacts/ClientArtifactsActivator.d.ts +6 -0
  39. package/dist/artifacts/ClientArtifactsActivator.d.ts.map +1 -0
  40. package/dist/artifacts/ClientArtifactsActivator.js +4 -0
  41. package/dist/artifacts/ClientArtifactsActivator.js.map +1 -0
  42. package/dist/artifacts/index.d.ts +5 -0
  43. package/dist/artifacts/index.d.ts.map +1 -1
  44. package/dist/artifacts/index.js +2 -0
  45. package/dist/artifacts/index.js.map +1 -1
  46. package/dist/artifacts/withActivatedArtifact.d.ts +9 -0
  47. package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -0
  48. package/dist/artifacts/withActivatedArtifact.js +24 -0
  49. package/dist/artifacts/withActivatedArtifact.js.map +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/reactors/ReactorServices.d.ts +12 -0
  55. package/dist/reactors/ReactorServices.d.ts.map +1 -0
  56. package/dist/reactors/ReactorServices.js +4 -0
  57. package/dist/reactors/ReactorServices.js.map +1 -0
  58. package/dist/reactors/Reactors.d.ts +5 -1
  59. package/dist/reactors/Reactors.d.ts.map +1 -1
  60. package/dist/reactors/Reactors.js +99 -39
  61. package/dist/reactors/Reactors.js.map +1 -1
  62. package/dist/reactors/index.d.ts +1 -0
  63. package/dist/reactors/index.d.ts.map +1 -1
  64. package/dist/reducers/Reducers.d.ts +5 -1
  65. package/dist/reducers/Reducers.d.ts.map +1 -1
  66. package/dist/reducers/Reducers.js +73 -28
  67. package/dist/reducers/Reducers.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/index.ts +1 -0
  70. package/package.json +1 -1
  71. package/reactors/ReactorServices.ts +15 -0
  72. package/reactors/Reactors.ts +101 -42
  73. package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +386 -0
  74. package/reactors/index.ts +1 -0
  75. package/reducers/Reducers.ts +75 -31
  76. package/reducers/for_Reducers/when_delivering/with_activation.spec.ts +177 -0
@@ -4,7 +4,7 @@
4
4
  import 'reflect-metadata';
5
5
  import { diag } from '@opentelemetry/api';
6
6
  import { Constructor } from '@cratis/fundamentals';
7
- import { ObservationState, ReadModelObserverType, ReducerMessage } from '@cratis/chronicle.contracts';
7
+ import { ObservationState, ReadModelObserverType, ReducerMessage, 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';
@@ -21,6 +21,12 @@ import { getReadModelId } from '../readModels/readModel.js';
21
21
  import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
22
22
  import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
23
23
  import { JsonSchemaGenerator } from '../schemas/index.js';
24
+ import type { IEventStore } from '../IEventStore.js';
25
+ import type { ClientArtifactsActivator } from '../artifacts/ClientArtifactsActivator.js';
26
+ import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
27
+ import { ArtifactKind } from '../artifacts/ArtifactKind.js';
28
+ import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
29
+ import { withActivatedArtifact, runActivated } from '../artifacts/withActivatedArtifact.js';
24
30
 
25
31
  /** Expression used to partition reducer observations by event source ID. */
26
32
  const EVENT_SOURCE_ID_KEY = '$eventSourceId';
@@ -117,7 +123,9 @@ export class Reducers implements IReducers {
117
123
  private readonly _eventStoreName: string,
118
124
  private readonly _namespace: string,
119
125
  lifecycle: ConnectionLifecycle,
120
- private readonly _defaultSinkTypeId: string
126
+ private readonly _defaultSinkTypeId: string,
127
+ private readonly _eventStore?: IEventStore,
128
+ private readonly _artifactActivator?: ClientArtifactsActivator
121
129
  ) {
122
130
  this._lifecycle = lifecycle;
123
131
  lifecycle.onDisconnected(async () => {
@@ -333,7 +341,7 @@ export class Reducers implements IReducers {
333
341
  });
334
342
 
335
343
  try {
336
- const reducerInstance = new (reducerType as new () => Record<string, Function>)();
344
+ const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
337
345
 
338
346
  for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
339
347
  let lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
@@ -351,7 +359,18 @@ export class Reducers implements IReducers {
351
359
  });
352
360
 
353
361
  try {
354
- await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
362
+ if (this._artifactActivator && operation.ReplayState !== ReplayState.REPLAY_STATE_None) {
363
+ if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
364
+ await withActivatedArtifact(reducerType, {
365
+ kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
366
+ readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
367
+ signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
368
+ replayState: operation.ReplayState
369
+ }, this._artifactActivator, artifact => runActivated(artifact, () =>
370
+ notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition)));
371
+ } else if (reducerInstance) {
372
+ await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
373
+ }
355
374
  } catch (err) {
356
375
  this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: String(err) });
357
376
  exceptionMessages.push(String(err));
@@ -363,41 +382,66 @@ export class Reducers implements IReducers {
363
382
  ? JSON.parse(operation.InitialState) as unknown
364
383
  : undefined;
365
384
 
366
- for (const event of state === ObservationState.Failed ? [] : operation.Events) {
367
- try {
368
- const eventTypeId = event.Context?.EventType?.Id;
369
- if (!eventTypeId) {
370
- this._logger.warn('Event missing event type context', { reducerId: id });
371
- continue;
372
- }
373
-
374
- const entry = dispatcher.handlerFor(eventTypeId);
375
- if (!entry) {
376
- this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
385
+ const processEvents = async (artifact: ActivatedArtifact<Record<string, Function>>) => {
386
+ for (const event of state === ObservationState.Failed ? [] : operation.Events) {
387
+ try {
388
+ const eventTypeId = event.Context?.EventType?.Id;
389
+ if (!eventTypeId) {
390
+ this._logger.warn('Event missing event type context', { reducerId: id });
391
+ continue;
392
+ }
393
+
394
+ const entry = dispatcher.handlerFor(eventTypeId);
395
+ if (!entry) {
396
+ this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
397
+ lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
398
+ continue;
399
+ }
400
+
401
+ const content = JSON.parse(event.Content) as Record<string, unknown>;
402
+ const context = toClientEventContext(event.Context!);
403
+
404
+ this._logger.info('Invoking reducer handler', {
405
+ reducerId: id,
406
+ method: entry.methodName,
407
+ sequenceNumber: event.Context!.SequenceNumber.toString(),
408
+ eventTypeId,
409
+ hasState: currentState !== undefined
410
+ });
411
+
412
+ currentState = await runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, currentState, context));
377
413
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
378
- continue;
414
+ } catch (err) {
415
+ this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });
416
+ exceptionMessages.push(String(err));
417
+ exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
418
+ state = ObservationState.Failed;
419
+ break;
379
420
  }
421
+ }
422
+ };
380
423
 
381
- const content = JSON.parse(event.Content) as Record<string, unknown>;
382
- const context = toClientEventContext(event.Context!);
383
-
384
- this._logger.info('Invoking reducer handler', {
385
- reducerId: id,
386
- method: entry.methodName,
387
- sequenceNumber: event.Context!.SequenceNumber.toString(),
388
- eventTypeId,
389
- hasState: currentState !== undefined
390
- });
391
-
392
- currentState = await dispatcher.invoke(reducerInstance, entry, content, currentState, context);
393
- lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
424
+ const firstInvocableEvent = this._artifactActivator && state === ObservationState.Success
425
+ ? operation.Events.find(event =>
426
+ !!event.Context?.EventType?.Id && !!dispatcher.handlerFor(event.Context.EventType.Id))
427
+ : undefined;
428
+ if (firstInvocableEvent && this._artifactActivator) {
429
+ try {
430
+ if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
431
+ await withActivatedArtifact(reducerType as new () => Record<string, Function>, {
432
+ kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
433
+ readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
434
+ signal: controller.signal, delivery: ArtifactDelivery.Events,
435
+ eventContext: toClientEventContext(firstInvocableEvent.Context!)
436
+ }, this._artifactActivator, processEvents);
394
437
  } catch (err) {
395
- this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });
438
+ this._logger.error('Error activating reducer', { reducerId: id, error: String(err) });
396
439
  exceptionMessages.push(String(err));
397
440
  exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
398
441
  state = ObservationState.Failed;
399
- break;
400
442
  }
443
+ } else {
444
+ await processEvents({ instance: reducerInstance ?? {} });
401
445
  }
402
446
 
403
447
  if (state === ObservationState.Success && currentState !== undefined) {
@@ -0,0 +1,177 @@
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, ReplayState, ObservationState, type ReducerMessage } from '@cratis/chronicle.contracts';
5
+ import { chai, describe, it, vi } from 'vitest';
6
+ import type { ChronicleConnection } from '../../../connection/index.js';
7
+ import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle.js';
8
+ import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifactsProvider.js';
9
+ import { ArtifactDelivery } from '../../../artifacts/ArtifactDelivery.js';
10
+ import { ArtifactKind } from '../../../artifacts/ArtifactKind.js';
11
+ import type { ArtifactActivationContext } from '../../../artifacts/ArtifactActivationContext.js';
12
+ import type { ClientArtifactsActivator } from '../../../artifacts/ClientArtifactsActivator.js';
13
+ import type { IEventStore } from '../../../IEventStore.js';
14
+ import { eventType } from '../../../events/eventTypeDecorator.js';
15
+ import { reducer } from '../../reducer.js';
16
+ import { Reducers } from '../../Reducers.js';
17
+
18
+ chai.should();
19
+
20
+ @eventType('reduction-activation-event')
21
+ class ReductionEvent { constructor(readonly amount: number = 0) {} }
22
+ class ReductionState { count = 0; }
23
+
24
+ const instances: object[] = [];
25
+ const notifications: string[] = [];
26
+
27
+ @reducer('reduction-activation', undefined, ReductionState)
28
+ class CountingReducer {
29
+ constructor() { instances.push(this); }
30
+ beginReplay() { notifications.push('begin'); }
31
+ endReplay() {}
32
+ reductionEvent(event: ReductionEvent, state: ReductionState | undefined) {
33
+ return { count: (state?.count ?? 0) + event.amount };
34
+ }
35
+ }
36
+
37
+ @reducer('reduction-notification-failure', undefined, ReductionState)
38
+ class FailingNotificationReducer {
39
+ beginReplay() { throw new Error('notification failed'); }
40
+ endReplay() {}
41
+ reductionEvent() { notifications.push('unexpected event'); return { count: 42 }; }
42
+ }
43
+
44
+ @reducer('reduction-failure', undefined, ReductionState)
45
+ class FailingReducer {
46
+ reductionEvent() { throw new Error('reduction failed'); }
47
+ }
48
+
49
+ function event(amount: number, sequence: bigint) {
50
+ return { Content: JSON.stringify({ amount }), Context: EventContext.fromPartial({
51
+ EventType: EventType.fromPartial({ Id: 'reduction-activation-event', Generation: 1 }),
52
+ EventSourceId: 'book-1', SequenceNumber: sequence
53
+ }) };
54
+ }
55
+
56
+ function batch(events: ReturnType<typeof event>[], replayState = ReplayState.REPLAY_STATE_None) {
57
+ return { Events: events, ReplayState: replayState, Partition: 'book-1', InitialState: '' };
58
+ }
59
+
60
+ async function observe(type: Function, batches: ReturnType<typeof batch>[], activator?: ClientArtifactsActivator) {
61
+ const acknowledgements: NonNullable<ReducerMessage['Content']>['Value1'][] = [];
62
+ const store = { namespace: { value: 'tenant-a' }, readModels: {} } as IEventStore;
63
+ const lifecycle = new ConnectionLifecycle();
64
+ let finished!: () => void;
65
+ const complete = new Promise<void>(resolve => { finished = resolve; });
66
+ async function* stream(queue: AsyncIterable<ReducerMessage>) {
67
+ const messages = queue[Symbol.asyncIterator]();
68
+ await messages.next();
69
+ for (const delivery of batches) {
70
+ yield delivery;
71
+ acknowledgements.push((await messages.next()).value!.Content!.Value1);
72
+ }
73
+ await lifecycle.disconnected(error => { throw error; });
74
+ finished();
75
+ }
76
+ const connection = { reducers: { observe: stream }, readModels: { registerMany: async () => ({}) } } as unknown as ChronicleConnection;
77
+ const artifacts = { reducers: [type], eventTypes: [ReductionEvent], readModels: [] } as unknown as IClientArtifactsProvider;
78
+ await new Reducers(artifacts, connection, 'store', 'tenant-a', lifecycle, 'sink', store, activator).register();
79
+ await complete;
80
+ return { acknowledgements, store };
81
+ }
82
+
83
+ describe('when delivering reducer batches', () => {
84
+ it('should reuse the default instance across deliveries and preserve reduction state', async () => {
85
+ instances.length = 0;
86
+ const result = await observe(CountingReducer, [batch([event(1, 1n), event(2, 2n)]), batch([event(3, 3n)])]);
87
+ instances.length.should.equal(1);
88
+ result.acknowledgements.map(value => JSON.parse(value!.ReadModelState)).should.deep.equal([{ count: 3 }, { count: 3 }]);
89
+ });
90
+
91
+ it('should activate once per batch and separately for replay notifications', async () => {
92
+ instances.length = 0;
93
+ notifications.length = 0;
94
+ const contexts: ArtifactActivationContext[] = [];
95
+ const steps: string[] = [];
96
+ const activator: ClientArtifactsActivator = (type, context) => {
97
+ contexts.push(context);
98
+ return { instance: new type(), run: async callback => { steps.push('enter');
99
+ try { return await callback(); } finally { steps.push('exit'); } },
100
+ dispose: () => { steps.push('dispose'); } };
101
+ };
102
+ const result = await observe(CountingReducer, [batch([event(1, 1n), event(2, 2n)], ReplayState.BeginReplay), batch([event(3, 3n)])], activator);
103
+ instances.length.should.equal(3);
104
+ contexts.map(context => context.delivery).should.deep.equal([ArtifactDelivery.ReplayNotification, ArtifactDelivery.Events, ArtifactDelivery.Events]);
105
+ contexts[0].kind.should.equal(ArtifactKind.Reducer);
106
+ contexts[1].eventStore.should.equal(result.store);
107
+ contexts[1].readModels.should.equal(result.store.readModels);
108
+ if (contexts[1].delivery === ArtifactDelivery.Events) contexts[1].eventContext.sequenceNumber.should.equal(1n);
109
+ notifications.should.deep.equal(['begin']);
110
+ steps.should.deep.equal(['enter', 'exit', 'dispose', 'enter', 'exit', 'enter', 'exit', 'dispose', 'enter', 'exit', 'dispose']);
111
+ result.acknowledgements[0]?.ReadModelState.should.equal('{"count":3}');
112
+ });
113
+
114
+ it('should notify before events on the default path', async () => {
115
+ notifications.length = 0;
116
+ const result = await observe(CountingReducer, [batch([event(1, 1n)], ReplayState.BeginReplay)]);
117
+ notifications.should.deep.equal(['begin']);
118
+ result.acknowledgements[0]?.ReadModelState.should.equal('{"count":1}');
119
+ });
120
+
121
+ it('should fail notification without running events in either mode', async () => {
122
+ for (const activator of [undefined, (type => ({ instance: new type() })) as ClientArtifactsActivator]) {
123
+ notifications.length = 0;
124
+ const result = await observe(FailingNotificationReducer, [batch([event(1, 1n)], ReplayState.BeginReplay)], activator);
125
+ notifications.should.deep.equal([]);
126
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
127
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['Error: notification failed']);
128
+ }
129
+ });
130
+
131
+ it('should fail event delivery on synchronous and asynchronous activation errors without disposing a lease', async () => {
132
+ for (const mode of ['sync', 'async'] as const) {
133
+ const dispose = vi.fn();
134
+ const message = `${mode} reducer activation failed`;
135
+ const activator: ClientArtifactsActivator = type => {
136
+ const lease = { instance: new type(), dispose };
137
+ if (mode === 'sync') throw new Error(message);
138
+ return Promise.reject(new Error(message)).then(() => lease);
139
+ };
140
+ const result = await observe(CountingReducer, [batch([event(1, 1n)])], activator);
141
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
142
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal([`Error: ${message}`]);
143
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
144
+ result.acknowledgements[0]?.ReadModelState.should.equal('');
145
+ dispose.mock.calls.length.should.equal(0);
146
+ }
147
+ });
148
+
149
+ it('should fail replay notification on synchronous and asynchronous activation errors without disposing a lease', async () => {
150
+ for (const mode of ['sync', 'async'] as const) {
151
+ notifications.length = 0;
152
+ const dispose = vi.fn();
153
+ const message = `${mode} reducer replay activation failed`;
154
+ const activator: ClientArtifactsActivator = type => {
155
+ const lease = { instance: new type(), dispose };
156
+ if (mode === 'sync') throw new Error(message);
157
+ return Promise.reject(new Error(message)).then(() => lease);
158
+ };
159
+ const result = await observe(CountingReducer, [batch([event(1, 1n)], ReplayState.BeginReplay)], activator);
160
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
161
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal([`Error: ${message}`]);
162
+ result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(4294967295n);
163
+ result.acknowledgements[0]?.ReadModelState.should.equal('');
164
+ notifications.should.deep.equal([]);
165
+ dispose.mock.calls.length.should.equal(0);
166
+ }
167
+ });
168
+
169
+ it('should dispose after handler failure without acknowledging the failed event', async () => {
170
+ let disposed = 0;
171
+ const activator: ClientArtifactsActivator = type => ({ instance: new type(), dispose: () => { disposed++; } });
172
+ const result = await observe(FailingReducer, [batch([event(1, 1n)])], activator);
173
+ disposed.should.equal(1);
174
+ result.acknowledgements[0]?.State.should.equal(ObservationState.Failed);
175
+ result.acknowledgements[0]?.ExceptionMessages.should.deep.equal(['Error: reduction failed']);
176
+ });
177
+ });