@cratis/chronicle 6.51.0 → 6.52.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 (100) hide show
  1. package/ChronicleClient.ts +127 -99
  2. package/ChronicleOptions.ts +23 -0
  3. package/dist/ChronicleClient.d.ts +7 -0
  4. package/dist/ChronicleClient.d.ts.map +1 -1
  5. package/dist/ChronicleClient.js +115 -95
  6. package/dist/ChronicleClient.js.map +1 -1
  7. package/dist/ChronicleOptions.d.ts +14 -0
  8. package/dist/ChronicleOptions.d.ts.map +1 -1
  9. package/dist/ChronicleOptions.js +16 -0
  10. package/dist/ChronicleOptions.js.map +1 -1
  11. package/dist/eventSequences/EventSequence.d.ts +12 -0
  12. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  13. package/dist/eventSequences/EventSequence.js +202 -175
  14. package/dist/eventSequences/EventSequence.js.map +1 -1
  15. package/dist/eventSequences/PreparedBatchAppend.d.ts +4 -0
  16. package/dist/eventSequences/PreparedBatchAppend.d.ts.map +1 -0
  17. package/dist/eventSequences/PreparedBatchAppend.js +4 -0
  18. package/dist/eventSequences/PreparedBatchAppend.js.map +1 -0
  19. package/dist/eventSequences/PreparedSingleAppend.d.ts +4 -0
  20. package/dist/eventSequences/PreparedSingleAppend.d.ts.map +1 -0
  21. package/dist/eventSequences/PreparedSingleAppend.js +4 -0
  22. package/dist/eventSequences/PreparedSingleAppend.js.map +1 -0
  23. package/dist/eventSequences/WireBatchEvent.d.ts +6 -0
  24. package/dist/eventSequences/WireBatchEvent.d.ts.map +1 -0
  25. package/dist/eventSequences/WireBatchEvent.js +4 -0
  26. package/dist/eventSequences/WireBatchEvent.js.map +1 -0
  27. package/dist/eventSequences/toContractsCausation.d.ts +16 -0
  28. package/dist/eventSequences/toContractsCausation.d.ts.map +1 -0
  29. package/dist/eventSequences/toContractsCausation.js +15 -0
  30. package/dist/eventSequences/toContractsCausation.js.map +1 -0
  31. package/dist/observation/FastForwardOutcome.d.ts +14 -0
  32. package/dist/observation/FastForwardOutcome.d.ts.map +1 -0
  33. package/dist/observation/FastForwardOutcome.js +17 -0
  34. package/dist/observation/FastForwardOutcome.js.map +1 -0
  35. package/dist/observation/FastForwardResult.d.ts +14 -0
  36. package/dist/observation/FastForwardResult.d.ts.map +1 -0
  37. package/dist/observation/FastForwardResult.js +4 -0
  38. package/dist/observation/FastForwardResult.js.map +1 -0
  39. package/dist/observation/IObservers.d.ts +9 -0
  40. package/dist/observation/IObservers.d.ts.map +1 -1
  41. package/dist/observation/Observers.d.ts +3 -0
  42. package/dist/observation/Observers.d.ts.map +1 -1
  43. package/dist/observation/Observers.js +11 -0
  44. package/dist/observation/Observers.js.map +1 -1
  45. package/dist/observation/index.d.ts +3 -0
  46. package/dist/observation/index.d.ts.map +1 -1
  47. package/dist/observation/index.js +2 -0
  48. package/dist/observation/index.js.map +1 -1
  49. package/dist/observation/toFastForwardResult.d.ts +16 -0
  50. package/dist/observation/toFastForwardResult.d.ts.map +1 -0
  51. package/dist/observation/toFastForwardResult.js +37 -0
  52. package/dist/observation/toFastForwardResult.js.map +1 -0
  53. package/dist/reactors/Reactors.d.ts.map +1 -1
  54. package/dist/reactors/Reactors.js +3 -1
  55. package/dist/reactors/Reactors.js.map +1 -1
  56. package/dist/reducers/ReducerObservation.d.ts +18 -0
  57. package/dist/reducers/ReducerObservation.d.ts.map +1 -0
  58. package/dist/reducers/ReducerObservation.js +4 -0
  59. package/dist/reducers/ReducerObservation.js.map +1 -0
  60. package/dist/reducers/ReducerObservationSession.d.ts +11 -0
  61. package/dist/reducers/ReducerObservationSession.d.ts.map +1 -0
  62. package/dist/reducers/ReducerObservationSession.js +4 -0
  63. package/dist/reducers/ReducerObservationSession.js.map +1 -0
  64. package/dist/reducers/Reducers.d.ts +9 -0
  65. package/dist/reducers/Reducers.d.ts.map +1 -1
  66. package/dist/reducers/Reducers.js +150 -154
  67. package/dist/reducers/Reducers.js.map +1 -1
  68. package/dist/reducers/ReductionOutcome.d.ts +25 -0
  69. package/dist/reducers/ReductionOutcome.d.ts.map +1 -0
  70. package/dist/reducers/ReductionOutcome.js +66 -0
  71. package/dist/reducers/ReductionOutcome.js.map +1 -0
  72. package/dist/tsconfig.tsbuildinfo +1 -1
  73. package/dist/webhooks/WebhookDefinitionBuilder.d.ts.map +1 -1
  74. package/dist/webhooks/WebhookDefinitionBuilder.js +3 -1
  75. package/dist/webhooks/WebhookDefinitionBuilder.js.map +1 -1
  76. package/dist/webhooks/Webhooks.d.ts.map +1 -1
  77. package/dist/webhooks/Webhooks.js +3 -1
  78. package/dist/webhooks/Webhooks.js.map +1 -1
  79. package/eventSequences/EventSequence.ts +265 -186
  80. package/eventSequences/PreparedBatchAppend.ts +7 -0
  81. package/eventSequences/PreparedSingleAppend.ts +7 -0
  82. package/eventSequences/WireBatchEvent.ts +7 -0
  83. package/eventSequences/toContractsCausation.ts +17 -0
  84. package/for_ChronicleClient/messageSizes.spec.ts +37 -0
  85. package/for_ChronicleOptions/when_configuring_message_sizes/with_message_sizes.spec.ts +29 -0
  86. package/observation/FastForwardOutcome.ts +19 -0
  87. package/observation/FastForwardResult.ts +19 -0
  88. package/observation/IObservers.ts +10 -0
  89. package/observation/Observers.spec.ts +41 -4
  90. package/observation/Observers.ts +14 -0
  91. package/observation/index.ts +3 -0
  92. package/observation/toFastForwardResult.ts +40 -0
  93. package/package.json +2 -2
  94. package/reactors/Reactors.ts +3 -1
  95. package/reducers/ReducerObservation.ts +25 -0
  96. package/reducers/ReducerObservationSession.ts +15 -0
  97. package/reducers/Reducers.ts +186 -171
  98. package/reducers/ReductionOutcome.ts +75 -0
  99. package/webhooks/WebhookDefinitionBuilder.ts +3 -1
  100. package/webhooks/Webhooks.ts +3 -1
@@ -5,7 +5,7 @@ import 'reflect-metadata';
5
5
  import { createLogger } from '../logging/createLogger.js';
6
6
  import { runInBackgroundContext } from '../telemetry/runInBackgroundContext.js';
7
7
  import { Constructor } from '@cratis/fundamentals';
8
- import { ObservationState, ReadModelObserverType, ReducerMessage, ReplayState } from '@cratis/chronicle.contracts';
8
+ import { AppendedEvent, EventGenerationDelivery, ReadModelObserverType, ReduceOperationMessage, ReducerMessage, ReplayState } from '@cratis/chronicle.contracts';
9
9
  import { IClientArtifactsProvider } from '../artifacts/index.js';
10
10
  import { ChronicleConnection } from '../connection/index.js';
11
11
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
@@ -19,6 +19,9 @@ import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
19
19
  import { IReducers } from './IReducers.js';
20
20
  import { getReducerMetadata } from './reducer.js';
21
21
  import { ReducerEventDispatcher } from './ReducerEventDispatcher.js';
22
+ import { ReducerObservation } from './ReducerObservation.js';
23
+ import { ReducerObservationSession } from './ReducerObservationSession.js';
24
+ import { ReductionOutcome } from './ReductionOutcome.js';
22
25
  import { getReadModelMetadata } from '../readModels/index.js';
23
26
  import { getReadModelId } from '../readModels/readModel.js';
24
27
  import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
@@ -31,14 +34,10 @@ import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
31
34
  import { ArtifactKind } from '../artifacts/ArtifactKind.js';
32
35
  import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
33
36
  import { withActivatedArtifact, runActivated } from '../artifacts/withActivatedArtifact.js';
34
- import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
35
37
 
36
38
  /** Expression used to partition reducer observations by event source ID. */
37
39
  const EVENT_SOURCE_ID_KEY = '$eventSourceId';
38
40
 
39
- /** Sentinel sequence number sent back when no event was successfully processed. */
40
- const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
41
-
42
41
  /**
43
42
  * A push-based async queue that implements {@link AsyncIterable} for use with nice-grpc
44
43
  * bidirectional streaming. Values pushed via {@link send} are yielded in order to
@@ -259,24 +258,18 @@ export class Reducers implements IReducers {
259
258
  handlers: dispatcher.handlers.map(e => e.methodName)
260
259
  });
261
260
 
262
- void this.runObservation(id, reducerType, eventSequenceId, readModelName, dispatcher);
261
+ void this.runObservation({ id, reducerType, eventSequenceId, readModelName, dispatcher });
263
262
  });
264
263
  }
265
264
 
266
- private async runObservation(
267
- id: string,
268
- reducerType: Constructor,
269
- eventSequenceId: string,
270
- readModelName: string,
271
- dispatcher: ReducerEventDispatcher
272
- ): Promise<void> {
265
+ private async runObservation(observation: ReducerObservation): Promise<void> {
273
266
  try {
274
- await this.observeReducer(id, reducerType, eventSequenceId, readModelName, dispatcher);
267
+ await this.observeReducer(observation);
275
268
  } catch (error) {
276
- this._logger.error('Reducer observation loop exited with error', { reducerId: id, error });
269
+ this._logger.error('Reducer observation loop exited with error', { reducerId: observation.id, error });
277
270
  }
278
271
 
279
- this.scheduleReobserve(id, reducerType, dispatcher);
272
+ this.scheduleReobserve(observation.id, observation.reducerType, observation.dispatcher);
280
273
  }
281
274
 
282
275
  /**
@@ -317,20 +310,51 @@ export class Reducers implements IReducers {
317
310
  return (reducerType as Function).name;
318
311
  }
319
312
 
320
- private async observeReducer(
321
- id: string,
322
- reducerType: Constructor,
323
- eventSequenceId: string,
324
- readModelName: string,
325
- dispatcher: ReducerEventDispatcher
326
- ): Promise<void> {
313
+ private async observeReducer(observation: ReducerObservation): Promise<void> {
314
+ const { id, reducerType } = observation;
327
315
  const queue = new AsyncQueue<ReducerMessage>();
328
316
  const controller = new AbortController();
329
317
  this._queues.set(id, queue);
330
318
  this._observations.set(id, controller);
331
- const isActive = getReducerMetadata(reducerType)?.isActive ?? true;
332
319
 
333
- queue.send({
320
+ queue.send(this.createRegistrationMessage(observation));
321
+
322
+ try {
323
+ const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
324
+ if (reducerInstance) observation.dispatcher.validateInstance(reducerInstance);
325
+ const session: ReducerObservationSession = { ...observation, signal: controller.signal, reducerInstance };
326
+
327
+ for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
328
+ // Delivery steps are awaited here, not inside a wrapper async function, so that
329
+ // async-context updates made by handlers reach this loop as they did originally.
330
+ const outcome = this.beginReduction(session, operation);
331
+ await this.notifyReplayTransition(session, operation, outcome);
332
+ outcome.currentState = operation.InitialState
333
+ ? JSON.parse(operation.InitialState) as unknown
334
+ : undefined;
335
+ await this.reduceEvents(session, operation, outcome);
336
+ queue.send(outcome.toMessage(operation.Partition));
337
+ }
338
+ } catch (err) {
339
+ if (!this._queues.has(id)) {
340
+ this._logger.debug('Reducer observation stream closed cleanly', { reducerId: id });
341
+ } else {
342
+ this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: err });
343
+ }
344
+ } finally {
345
+ // Only retire our own queue: a reconnect can already have replaced it,
346
+ // and deleting the new one would leave that observation untracked and
347
+ // leak a duplicate stream on every reconnect.
348
+ if (this._queues.get(id) === queue) {
349
+ this._queues.delete(id);
350
+ this._observations.delete(id);
351
+ }
352
+ queue.complete();
353
+ }
354
+ }
355
+
356
+ private createRegistrationMessage({ id, reducerType, eventSequenceId, readModelName, dispatcher }: ReducerObservation): ReducerMessage {
357
+ return {
334
358
  Content: {
335
359
  Value0: {
336
360
  ConnectionId: this._lifecycle.connectionId,
@@ -345,7 +369,8 @@ export class Reducers implements IReducers {
345
369
  })),
346
370
  ReadModel: readModelName,
347
371
  Hash: '',
348
- IsActive: isActive,
372
+ IsActive: getReducerMetadata(reducerType)?.isActive ?? true,
373
+ GenerationDelivery: EventGenerationDelivery.Compatibility,
349
374
  Tags: getTagsFor(reducerType).map(t => t.value),
350
375
  Filters: {
351
376
  FilterTags: getFilterTagsFor(reducerType).map(t => t.value),
@@ -356,162 +381,152 @@ export class Reducers implements IReducers {
356
381
  },
357
382
  Value1: undefined
358
383
  }
384
+ };
385
+ }
386
+
387
+ private beginReduction(session: ReducerObservationSession, operation: ReduceOperationMessage): ReductionOutcome {
388
+ this._logger.debug('Received reduce operation', {
389
+ reducerId: session.id,
390
+ count: operation.Events.length,
391
+ hasInitialState: operation.InitialState !== '',
392
+ replayState: operation.ReplayState
359
393
  });
360
394
 
395
+ return new ReductionOutcome();
396
+ }
397
+
398
+ private async notifyReplayTransition(
399
+ session: ReducerObservationSession,
400
+ operation: ReduceOperationMessage,
401
+ outcome: ReductionOutcome
402
+ ): Promise<void> {
361
403
  try {
362
- const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
363
- if (reducerInstance) dispatcher.validateInstance(reducerInstance);
404
+ if (this._artifactActivator && operation.ReplayState !== ReplayState.REPLAY_STATE_None) {
405
+ await this.notifyActivatedReplayTransition(session, operation, this._artifactActivator);
406
+ } else if (session.reducerInstance) {
407
+ await notifyReplayLifecycle(session.reducerInstance, operation.ReplayState, operation.Partition);
408
+ }
409
+ } catch (err) {
410
+ this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: session.id, error: err });
411
+ outcome.recordReplayNotificationFailure(err);
412
+ }
413
+ }
364
414
 
365
- for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
366
- let lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
367
- let state = ObservationState.Success;
368
- const exceptionMessages: string[] = [];
369
- let exceptionStackTrace = '';
370
- let readModelState: string | undefined;
371
-
372
- this._logger.debug('Received reduce operation', {
373
- reducerId: id,
374
- count: operation.Events.length,
375
- hasInitialState: operation.InitialState !== '',
376
- replayState: operation.ReplayState
377
- });
415
+ private async notifyActivatedReplayTransition(
416
+ { id, reducerType, eventSequenceId, dispatcher, signal }: ReducerObservationSession,
417
+ operation: ReduceOperationMessage,
418
+ activator: ClientArtifactsActivator
419
+ ): Promise<void> {
420
+ const eventStore = this.requireEventStore();
421
+ await withActivatedArtifact(reducerType, {
422
+ kind: ArtifactKind.Reducer, artifactId: id, eventStore,
423
+ readModels: eventStore.readModels, eventSequenceId, partition: operation.Partition,
424
+ signal, delivery: ArtifactDelivery.ReplayNotification,
425
+ replayState: operation.ReplayState
426
+ }, activator, artifact => {
427
+ dispatcher.validateInstance(artifact.instance);
428
+ return runActivated(artifact, () =>
429
+ notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
430
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
431
+ }, this._connection.logger);
432
+ }
378
433
 
379
- try {
380
- if (this._artifactActivator && operation.ReplayState !== ReplayState.REPLAY_STATE_None) {
381
- if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
382
- await withActivatedArtifact(reducerType, {
383
- kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
384
- readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
385
- signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
386
- replayState: operation.ReplayState
387
- }, this._artifactActivator, artifact => {
388
- dispatcher.validateInstance(artifact.instance);
389
- return runActivated(artifact, () =>
390
- notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
391
- { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
392
- }, this._connection.logger);
393
- } else if (reducerInstance) {
394
- await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
395
- }
396
- } catch (err) {
397
- this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: err });
398
- if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
399
- exceptionMessages.push(String(err.processingError));
400
- }
401
- exceptionMessages.push(String(err));
402
- exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
403
- state = ObservationState.Failed;
404
- }
434
+ private requireEventStore(): IEventStore {
435
+ if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
436
+ return this._eventStore;
437
+ }
405
438
 
406
- let currentState: unknown = operation.InitialState
407
- ? JSON.parse(operation.InitialState) as unknown
408
- : undefined;
439
+ private async reduceEvents(
440
+ session: ReducerObservationSession,
441
+ operation: ReduceOperationMessage,
442
+ outcome: ReductionOutcome
443
+ ): Promise<void> {
444
+ const { id, reducerType, eventSequenceId, dispatcher, signal, reducerInstance } = session;
445
+ const firstInvocableEvent = this._artifactActivator && !outcome.failed
446
+ ? operation.Events.find(event =>
447
+ !!event.Context?.EventType?.Id && !!dispatcher.handlerFor(event.Context.EventType.Id))
448
+ : undefined;
449
+ if (!firstInvocableEvent || !this._artifactActivator) {
450
+ await this.processEvents(session, operation, outcome, { instance: reducerInstance ?? {} });
451
+ return;
452
+ }
409
453
 
410
- const processEvents = async (artifact: ActivatedArtifact<Record<string, Function>>) => {
411
- for (const event of state === ObservationState.Failed ? [] : operation.Events) {
412
- try {
413
- const eventTypeId = event.Context?.EventType?.Id;
414
- if (!eventTypeId) {
415
- this._logger.warn('Event missing event type context', { reducerId: id });
416
- continue;
417
- }
418
-
419
- const entry = dispatcher.handlerFor(eventTypeId);
420
- if (!entry) {
421
- this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
422
- lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
423
- continue;
424
- }
425
-
426
- const content = JSON.parse(event.Content) as Record<string, unknown>;
427
- const context = toClientEventContext(event.Context!);
428
-
429
- this._logger.info('Invoking reducer handler', {
430
- reducerId: id,
431
- method: entry.methodName,
432
- sequenceNumber: event.Context!.SequenceNumber.toString(),
433
- eventTypeId,
434
- hasState: currentState !== undefined
435
- });
436
-
437
- currentState = await runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, currentState, context),
438
- { delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName });
439
- lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
440
- } catch (err) {
441
- this._logger.error('Error handling event in reducer', { reducerId: id, error: err });
442
- exceptionMessages.push(String(err));
443
- exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
444
- state = ObservationState.Failed;
445
- break;
446
- }
447
- }
448
- };
454
+ try {
455
+ const eventStore = this.requireEventStore();
456
+ await withActivatedArtifact(reducerType as new () => Record<string, Function>, {
457
+ kind: ArtifactKind.Reducer, artifactId: id, eventStore,
458
+ readModels: eventStore.readModels, eventSequenceId, partition: operation.Partition,
459
+ signal, delivery: ArtifactDelivery.Events,
460
+ eventContext: toClientEventContext(firstInvocableEvent.Context!)
461
+ }, this._artifactActivator, artifact => {
462
+ dispatcher.validateInstance(artifact.instance);
463
+ return this.processEvents(session, operation, outcome, artifact);
464
+ }, this._connection.logger);
465
+ } catch (err) {
466
+ this._logger.error('Error activating reducer', { reducerId: id, error: err });
467
+ outcome.recordActivationFailure(err);
468
+ }
469
+ }
449
470
 
450
- const firstInvocableEvent = this._artifactActivator && state === ObservationState.Success
451
- ? operation.Events.find(event =>
452
- !!event.Context?.EventType?.Id && !!dispatcher.handlerFor(event.Context.EventType.Id))
453
- : undefined;
454
- if (firstInvocableEvent && this._artifactActivator) {
455
- try {
456
- if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
457
- await withActivatedArtifact(reducerType as new () => Record<string, Function>, {
458
- kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
459
- readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
460
- signal: controller.signal, delivery: ArtifactDelivery.Events,
461
- eventContext: toClientEventContext(firstInvocableEvent.Context!)
462
- }, this._artifactActivator, artifact => {
463
- dispatcher.validateInstance(artifact.instance);
464
- return processEvents(artifact);
465
- }, this._connection.logger);
466
- } catch (err) {
467
- this._logger.error('Error activating reducer', { reducerId: id, error: err });
468
- exceptionMessages.push(String(err));
469
- const completionStack = err instanceof Error ? (err.stack ?? '') : '';
470
- exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
471
- ? `${exceptionStackTrace}\n${completionStack}` : completionStack;
472
- state = ObservationState.Failed;
473
- if (err instanceof ArtifactCompletionFailed) {
474
- lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
475
- }
476
- }
477
- } else {
478
- await processEvents({ instance: reducerInstance ?? {} });
479
- }
471
+ private async processEvents(
472
+ session: ReducerObservationSession,
473
+ operation: ReduceOperationMessage,
474
+ outcome: ReductionOutcome,
475
+ artifact: ActivatedArtifact<Record<string, Function>>
476
+ ): Promise<void> {
477
+ for (const event of outcome.failed ? [] : operation.Events) {
478
+ try {
479
+ // Skipped events stay synchronous; only an actual handler invocation is awaited.
480
+ const pending = this.processEvent(session, event, outcome, artifact);
481
+ if (pending) await pending;
482
+ } catch (err) {
483
+ this._logger.error('Error handling event in reducer', { reducerId: session.id, error: err });
484
+ outcome.recordFailure(err);
485
+ break;
486
+ }
487
+ }
488
+ }
480
489
 
481
- if (state === ObservationState.Success && currentState !== undefined) {
482
- readModelState = JSON.stringify(currentState);
483
- }
490
+ private processEvent(
491
+ { id, dispatcher }: ReducerObservationSession,
492
+ event: AppendedEvent,
493
+ outcome: ReductionOutcome,
494
+ artifact: ActivatedArtifact<Record<string, Function>>
495
+ ): Promise<void> | undefined {
496
+ const eventTypeId = event.Context?.EventType?.Id;
497
+ if (!eventTypeId) {
498
+ this._logger.warn('Event missing event type context', { reducerId: id });
499
+ return;
500
+ }
484
501
 
485
- queue.send({
486
- Content: {
487
- Value0: undefined,
488
- Value1: {
489
- Partition: operation.Partition,
490
- State: state,
491
- LastSuccessfulObservation: lastSuccessfullyObservedEvent,
492
- ExceptionMessages: exceptionMessages,
493
- ExceptionStackTrace: exceptionStackTrace,
494
- ReadModelState: readModelState ?? ''
495
- }
496
- }
497
- });
498
- }
499
- } catch (err) {
500
- if (!this._queues.has(id)) {
501
- this._logger.debug('Reducer observation stream closed cleanly', { reducerId: id });
502
- } else {
503
- this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: err });
504
- }
505
- } finally {
506
- // Only retire our own queue: a reconnect can already have replaced it,
507
- // and deleting the new one would leave that observation untracked and
508
- // leak a duplicate stream on every reconnect.
509
- if (this._queues.get(id) === queue) {
510
- this._queues.delete(id);
511
- this._observations.delete(id);
512
- }
513
- queue.complete();
502
+ const entry = dispatcher.handlerFor(eventTypeId);
503
+ if (!entry) {
504
+ this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
505
+ outcome.lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
506
+ return;
514
507
  }
508
+
509
+ const content = JSON.parse(event.Content) as Record<string, unknown>;
510
+ const context = toClientEventContext(event.Context!);
511
+
512
+ this._logger.info('Invoking reducer handler', {
513
+ reducerId: id,
514
+ method: entry.methodName,
515
+ sequenceNumber: event.Context!.SequenceNumber.toString(),
516
+ eventTypeId,
517
+ hasState: outcome.currentState !== undefined
518
+ });
519
+
520
+ return this.invokeHandler(
521
+ () => runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, outcome.currentState, context),
522
+ { delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName }),
523
+ outcome,
524
+ event.Context!.SequenceNumber);
525
+ }
526
+
527
+ private async invokeHandler(invoke: () => Promise<unknown>, outcome: ReductionOutcome, sequenceNumber: bigint): Promise<void> {
528
+ outcome.currentState = await invoke();
529
+ outcome.lastSuccessfullyObservedEvent = sequenceNumber;
515
530
  }
516
531
 
517
532
  private disconnectAll(): void {
@@ -0,0 +1,75 @@
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 { ObservationState, ReducerMessage } from '@cratis/chronicle.contracts';
5
+ import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
6
+
7
+ /** Sentinel sequence number sent back when no event was successfully processed. */
8
+ export const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
9
+
10
+ const stackOf = (error: unknown): string => error instanceof Error ? (error.stack ?? '') : '';
11
+
12
+ /**
13
+ * Accumulates the result of reducing one reduce operation: the state, the failure details and the
14
+ * last event that was successfully reduced.
15
+ */
16
+ export class ReductionOutcome {
17
+ state = ObservationState.Success;
18
+ lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
19
+ readonly exceptionMessages: string[] = [];
20
+ exceptionStackTrace = '';
21
+ currentState: unknown;
22
+
23
+ /** Gets whether the reduction has failed. */
24
+ get failed(): boolean {
25
+ return this.state === ObservationState.Failed;
26
+ }
27
+
28
+ /** Records a failure of the reduction. */
29
+ recordFailure(error: unknown): void {
30
+ this.exceptionMessages.push(String(error));
31
+ this.exceptionStackTrace = stackOf(error);
32
+ this.state = ObservationState.Failed;
33
+ }
34
+
35
+ /** Records a failure to notify of a replay lifecycle transition. */
36
+ recordReplayNotificationFailure(error: unknown): void {
37
+ if (error instanceof ArtifactCompletionFailed && error.processingError !== undefined) {
38
+ this.exceptionMessages.push(String(error.processingError));
39
+ }
40
+ this.recordFailure(error);
41
+ }
42
+
43
+ /** Records a failure to activate the reducer or to complete its activation. */
44
+ recordActivationFailure(error: unknown): void {
45
+ this.exceptionMessages.push(String(error));
46
+ const completionStack = stackOf(error);
47
+ this.exceptionStackTrace = error instanceof ArtifactCompletionFailed && this.exceptionStackTrace
48
+ ? `${this.exceptionStackTrace}\n${completionStack}` : completionStack;
49
+ this.state = ObservationState.Failed;
50
+ if (error instanceof ArtifactCompletionFailed) {
51
+ this.lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
52
+ }
53
+ }
54
+
55
+ /** Creates the result message reported back to the kernel. */
56
+ toMessage(partition: string): ReducerMessage {
57
+ const readModelState = this.state === ObservationState.Success && this.currentState !== undefined
58
+ ? JSON.stringify(this.currentState)
59
+ : undefined;
60
+
61
+ return {
62
+ Content: {
63
+ Value0: undefined,
64
+ Value1: {
65
+ Partition: partition,
66
+ State: this.state,
67
+ LastSuccessfulObservation: this.lastSuccessfullyObservedEvent,
68
+ ExceptionMessages: this.exceptionMessages,
69
+ ExceptionStackTrace: this.exceptionStackTrace,
70
+ ReadModelState: readModelState ?? ''
71
+ }
72
+ }
73
+ };
74
+ }
75
+ }
@@ -138,7 +138,9 @@ export class WebhookDefinitionBuilder implements IWebhookDefinitionBuilder {
138
138
  Headers: Object.fromEntries(this._headers)
139
139
  },
140
140
  IsReplayable: this._isReplayable,
141
- IsActive: this._isActive
141
+ IsActive: this._isActive,
142
+ IsNotReplayable: !this._isReplayable,
143
+ IsInactive: !this._isActive
142
144
  };
143
145
  }
144
146
  }
@@ -102,7 +102,9 @@ export class Webhooks implements IWebhooks {
102
102
  Headers: webhook.Headers
103
103
  },
104
104
  IsReplayable: webhook.IsReplayable,
105
- IsActive: webhook.IsActive
105
+ IsActive: webhook.IsActive,
106
+ IsNotReplayable: !webhook.IsReplayable,
107
+ IsInactive: !webhook.IsActive
106
108
  }));
107
109
  }
108
110