@cratis/chronicle 6.7.1 → 6.9.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 (70) hide show
  1. package/EventStore.ts +10 -4
  2. package/README.md +14 -5
  3. package/dist/EventStore.d.ts +1 -0
  4. package/dist/EventStore.d.ts.map +1 -1
  5. package/dist/EventStore.js +7 -4
  6. package/dist/EventStore.js.map +1 -1
  7. package/dist/eventSequences/EventLog.d.ts +2 -1
  8. package/dist/eventSequences/EventLog.d.ts.map +1 -1
  9. package/dist/eventSequences/EventLog.js +2 -2
  10. package/dist/eventSequences/EventLog.js.map +1 -1
  11. package/dist/eventSequences/EventSequence.d.ts +3 -1
  12. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  13. package/dist/eventSequences/EventSequence.js +11 -6
  14. package/dist/eventSequences/EventSequence.js.map +1 -1
  15. package/dist/events/constraints/ConstraintBuilder.d.ts +2 -0
  16. package/dist/events/constraints/ConstraintBuilder.d.ts.map +1 -1
  17. package/dist/events/constraints/ConstraintBuilder.js.map +1 -1
  18. package/dist/events/constraints/Constraints.d.ts +8 -3
  19. package/dist/events/constraints/Constraints.d.ts.map +1 -1
  20. package/dist/events/constraints/Constraints.js +147 -6
  21. package/dist/events/constraints/Constraints.js.map +1 -1
  22. package/dist/events/constraints/index.d.ts +2 -0
  23. package/dist/events/constraints/index.d.ts.map +1 -1
  24. package/dist/events/constraints/index.js +2 -0
  25. package/dist/events/constraints/index.js.map +1 -1
  26. package/dist/events/constraints/removeConstraint.d.ts +11 -0
  27. package/dist/events/constraints/removeConstraint.d.ts.map +1 -0
  28. package/dist/events/constraints/removeConstraint.js +24 -0
  29. package/dist/events/constraints/removeConstraint.js.map +1 -0
  30. package/dist/events/constraints/unique.d.ts +21 -0
  31. package/dist/events/constraints/unique.d.ts.map +1 -0
  32. package/dist/events/constraints/unique.js +37 -0
  33. package/dist/events/constraints/unique.js.map +1 -0
  34. package/dist/reactors/Reactors.d.ts.map +1 -1
  35. package/dist/reactors/Reactors.js +64 -13
  36. package/dist/reactors/Reactors.js.map +1 -1
  37. package/dist/reactors/index.d.ts +2 -0
  38. package/dist/reactors/index.d.ts.map +1 -1
  39. package/dist/reactors/index.js +2 -0
  40. package/dist/reactors/index.js.map +1 -1
  41. package/dist/reactors/onceOnly.d.ts +11 -0
  42. package/dist/reactors/onceOnly.d.ts.map +1 -0
  43. package/dist/reactors/onceOnly.js +34 -0
  44. package/dist/reactors/onceOnly.js.map +1 -0
  45. package/dist/reactors/replay.d.ts +14 -0
  46. package/dist/reactors/replay.d.ts.map +1 -0
  47. package/dist/reactors/replay.js +33 -0
  48. package/dist/reactors/replay.js.map +1 -0
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/eventSequences/EventLog.ts +4 -2
  51. package/eventSequences/EventSequence.ts +10 -6
  52. package/events/classDecorators.standard.spec.ts +45 -1
  53. package/events/constraints/ConstraintBuilder.ts +2 -0
  54. package/events/constraints/Constraints.ts +140 -7
  55. package/events/constraints/for_Constraints/when_combining_decorators_with_fluent_constraints.spec.ts +142 -0
  56. package/events/constraints/for_Constraints/when_inheriting_removal_decorators.legacy.spec.ts +36 -0
  57. package/events/constraints/for_Constraints/when_registering_decorated_constraints.spec.ts +63 -0
  58. package/events/constraints/for_Constraints/when_registering_decorated_event_types.spec.ts +90 -0
  59. package/events/constraints/for_Constraints/when_rejecting_incompatible_unique_merges.spec.ts +108 -0
  60. package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +92 -0
  61. package/events/constraints/index.ts +2 -0
  62. package/events/constraints/removeConstraint.ts +28 -0
  63. package/events/constraints/unique.ts +45 -0
  64. package/package.json +2 -2
  65. package/reactors/Reactors.replay.legacy.spec.ts +241 -0
  66. package/reactors/Reactors.ts +65 -14
  67. package/reactors/decorators.standard.spec.ts +39 -0
  68. package/reactors/index.ts +2 -0
  69. package/reactors/onceOnly.ts +34 -0
  70. package/reactors/replay.ts +36 -0
@@ -0,0 +1,241 @@
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, EventObservationState, EventType, ReactorMessage } from '@cratis/chronicle.contracts';
5
+ import { describe, expect, it } from 'vitest';
6
+ import type { IClientArtifactsProvider } from '../artifacts/index.js';
7
+ import type { ChronicleConnection } from '../connection/index.js';
8
+ import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
9
+ import type { IEventLog } from '../eventSequences/IEventLog.js';
10
+ import { eventType } from '../events/eventTypeDecorator.js';
11
+ import { onceOnly } from './onceOnly.js';
12
+ import { reactor } from './reactor.js';
13
+ import { Reactors } from './Reactors.js';
14
+ import { replay } from './replay.js';
15
+
16
+ @eventType('reactor-replay-event')
17
+ class SomethingHappened {
18
+ constructor(readonly value: string = '') {}
19
+ }
20
+
21
+ async function observe(type: Function, states: EventObservationState[]) {
22
+ const lifecycle = new ConnectionLifecycle();
23
+ let registration!: ReactorMessage;
24
+ let result!: ReactorMessage;
25
+ let finish!: () => void;
26
+ const finished = new Promise<void>(resolve => { finish = resolve; });
27
+ async function* stream(queue: AsyncIterable<ReactorMessage>) {
28
+ const messages = queue[Symbol.asyncIterator]();
29
+ registration = (await messages.next()).value!;
30
+ yield {
31
+ Events: states.map((state, index) => ({
32
+ Context: EventContext.fromPartial({
33
+ EventType: EventType.fromPartial({ Id: 'reactor-replay-event', Generation: 1 }),
34
+ SequenceNumber: BigInt(index + 1), ObservationState: state
35
+ }),
36
+ Content: JSON.stringify({ value: 'hello' })
37
+ })),
38
+ Partition: 'source', ReplayState: 0, InitialState: ''
39
+ };
40
+ result = (await messages.next()).value!;
41
+ await lifecycle.disconnected(error => { throw error; });
42
+ finish();
43
+ }
44
+ const connection = { reactors: { observe: stream } } as unknown as ChronicleConnection;
45
+ const artifacts = { reactors: [type], eventTypes: [SomethingHappened] } as unknown as IClientArtifactsProvider;
46
+ await new Reactors(artifacts, connection, 'store', 'tenant', lifecycle, {} as IEventLog).register();
47
+ await finished;
48
+ return { registration: registration.Content?.Value0?.Reactor, result: result.Content?.Value1 };
49
+ }
50
+
51
+ // The client constructs reactor instances; share a sink for assertions across that boundary.
52
+ let invoked: string[] = [];
53
+
54
+ @reactor('default-replay-policy')
55
+ class DefaultReactor {
56
+ somethingHappened(event: SomethingHappened) { invoked.push(`live:${event.value}`); }
57
+ }
58
+
59
+ @reactor('once-only-replay-policy')
60
+ @onceOnly()
61
+ class OnceOnlyReactor {
62
+ somethingHappened() { invoked.push('live'); }
63
+ }
64
+
65
+ @reactor('inherited-once-only-policy')
66
+ class InheritedOnceOnlyReactor extends OnceOnlyReactor {}
67
+
68
+ @reactor('method-once-only-policy')
69
+ class MethodOnceOnlyReactor {
70
+ @onceOnly()
71
+ somethingHappened() { invoked.push('live'); }
72
+ }
73
+
74
+ @reactor('replay-alternative-policy')
75
+ class AlternativeReactor {
76
+ @onceOnly()
77
+ somethingHappened() { invoked.push('live'); }
78
+
79
+ @replay()
80
+ replaySomethingHappened(event: SomethingHappened) { invoked.push(`replay:${event.value}`); }
81
+ }
82
+
83
+ @reactor('plain-replay-alternative-policy')
84
+ class PlainAlternativeReactor {
85
+ somethingHappened() { invoked.push('live'); }
86
+
87
+ @replay()
88
+ replaySomethingHappened(event: SomethingHappened) { invoked.push(`replay:${event.value}`); }
89
+ }
90
+
91
+ @reactor('inherited-replay-policy')
92
+ class InheritedReplayReactor extends PlainAlternativeReactor {}
93
+
94
+ @reactor('overridden-replay-policy')
95
+ class OverriddenReplayReactor extends PlainAlternativeReactor {
96
+ @replay()
97
+ replaySomethingHappened(event: SomethingHappened) { invoked.push(`derived:${event.value}`); }
98
+ }
99
+
100
+ @reactor('replay-marked-live-handler-policy')
101
+ class ReplayMarkedLiveHandlerReactor {
102
+ @replay(SomethingHappened)
103
+ somethingHappened() { invoked.push('replay'); }
104
+ }
105
+
106
+ @reactor('unknown-replay-policy')
107
+ class UnknownReplayReactor {
108
+ @replay()
109
+ replayWrongName() {}
110
+ }
111
+
112
+ class UnknownEvent {}
113
+
114
+ @reactor('unregistered-replay-policy')
115
+ class UnregisteredReplayReactor {
116
+ @replay(UnknownEvent)
117
+ rebuild() {}
118
+ }
119
+
120
+ @reactor('duplicate-replay-policy')
121
+ class DuplicateReplayReactor {
122
+ @replay()
123
+ replaySomethingHappened() {}
124
+
125
+ @replay(SomethingHappened)
126
+ rebuild() {}
127
+ }
128
+
129
+ @reactor('once-only-replay-handler-policy')
130
+ class OnceOnlyReplayHandlerReactor {
131
+ somethingHappened() { invoked.push('live'); }
132
+
133
+ @onceOnly()
134
+ @replay()
135
+ replaySomethingHappened() { invoked.push('replay'); }
136
+ }
137
+
138
+ @reactor('replay-only-policy')
139
+ class ReplayOnlyReactor {
140
+ @replay(SomethingHappened)
141
+ rebuild(event: SomethingHappened) { invoked.push(`replay:${event.value}`); }
142
+ }
143
+
144
+ describe('reactor replay policy', () => {
145
+ it('registers reactors as replayable by default and dispatches ordinary handlers for both deliveries', async () => {
146
+ invoked = [];
147
+ const delivery = await observe(DefaultReactor, [EventObservationState.Initial, EventObservationState.Replay]);
148
+ expect(delivery.registration?.IsReplayable).toBe(true);
149
+ expect(invoked).toEqual(['live:hello', 'live:hello']);
150
+ });
151
+
152
+ it('registers a class marked onceOnly as non-replayable', async () => {
153
+ invoked = [];
154
+ const delivery = await observe(OnceOnlyReactor, [EventObservationState.Initial]);
155
+ expect(delivery.registration?.IsReplayable).toBe(false);
156
+ expect(invoked).toEqual(['live']);
157
+ });
158
+
159
+ it('does not inherit a class-level onceOnly marker on registration', async () => {
160
+ invoked = [];
161
+ const delivery = await observe(InheritedOnceOnlyReactor, [EventObservationState.Initial, EventObservationState.Replay]);
162
+ expect(delivery.registration?.IsReplayable).toBe(true);
163
+ expect(invoked).toEqual(['live', 'live']);
164
+ });
165
+
166
+ it('skips a method-level onceOnly handler only for replayed events and acknowledges both', async () => {
167
+ invoked = [];
168
+ const delivery = await observe(MethodOnceOnlyReactor, [EventObservationState.Initial, EventObservationState.Replay]);
169
+ expect(delivery.registration?.IsReplayable).toBe(true);
170
+ expect(invoked).toEqual(['live']);
171
+ expect(delivery.result?.LastSuccessfulObservation).toBe(2n);
172
+ });
173
+
174
+ it('runs the replay-specific handler instead of the ordinary handler during replay', async () => {
175
+ invoked = [];
176
+ const delivery = await observe(AlternativeReactor, [EventObservationState.Initial, EventObservationState.Replay]);
177
+ expect(invoked).toEqual(['live', 'replay:hello']);
178
+ expect(delivery.result?.LastSuccessfulObservation).toBe(2n);
179
+ });
180
+
181
+ it('runs only the alternative replay handler when both handlers are plain', async () => {
182
+ invoked = [];
183
+ await observe(PlainAlternativeReactor, [EventObservationState.Initial, EventObservationState.Replay]);
184
+ expect(invoked).toEqual(['live', 'replay:hello']);
185
+ });
186
+
187
+ it('discovers an inherited replay handler', async () => {
188
+ invoked = [];
189
+ await observe(InheritedReplayReactor, [EventObservationState.Initial, EventObservationState.Replay]);
190
+ expect(invoked).toEqual(['live', 'replay:hello']);
191
+ });
192
+
193
+ it('prefers a derived replay handler to the inherited one', async () => {
194
+ invoked = [];
195
+ await observe(OverriddenReplayReactor, [EventObservationState.Initial, EventObservationState.Replay]);
196
+ expect(invoked).toEqual(['live', 'derived:hello']);
197
+ });
198
+
199
+ it('does not invoke a replay-marked live-named handler on live delivery', async () => {
200
+ invoked = [];
201
+ await observe(ReplayMarkedLiveHandlerReactor, [EventObservationState.Initial, EventObservationState.Replay]);
202
+ expect(invoked).toEqual(['replay']);
203
+ });
204
+
205
+ it('rejects a convention replay handler with no registered event type', async () => {
206
+ await expect(observe(UnknownReplayReactor, [])).rejects.toThrow(/replayWrongName.*no registered event type/);
207
+ });
208
+
209
+ it('rejects an explicit replay handler for an unregistered event type', async () => {
210
+ await expect(observe(UnregisteredReplayReactor, [])).rejects.toThrow(/rebuild.*no registered event type/);
211
+ });
212
+
213
+ it('rejects two replay handlers for the same event type', async () => {
214
+ await expect(observe(DuplicateReplayReactor, [])).rejects.toThrow(/multiple replay handlers.*reactor-replay-event/);
215
+ });
216
+
217
+ it('rejects a static legacy replay method', () => {
218
+ expect(() => {
219
+ class StaticReplayReactor {
220
+ @replay(SomethingHappened)
221
+ static rebuild() {}
222
+ }
223
+ return StaticReplayReactor;
224
+ }).toThrow('Replay requires a public instance method.');
225
+ });
226
+
227
+ it('does not fall back to the ordinary handler when the replay handler is onceOnly', async () => {
228
+ invoked = [];
229
+ const delivery = await observe(OnceOnlyReplayHandlerReactor, [EventObservationState.Initial, EventObservationState.Replay]);
230
+ expect(invoked).toEqual(['live']);
231
+ expect(delivery.result?.LastSuccessfulObservation).toBe(2n);
232
+ });
233
+
234
+ it('subscribes to an event handled only during replay and acknowledges live deliveries', async () => {
235
+ invoked = [];
236
+ const delivery = await observe(ReplayOnlyReactor, [EventObservationState.Initial, EventObservationState.Replay]);
237
+ expect(delivery.registration?.EventTypes).toHaveLength(1);
238
+ expect(invoked).toEqual(['replay:hello']);
239
+ expect(delivery.result?.LastSuccessfulObservation).toBe(2n);
240
+ });
241
+ });
@@ -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, ReactorMessage } from '@cratis/chronicle.contracts';
7
+ import { EventObservationState, ObservationState, ReactorMessage } 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';
@@ -18,6 +18,8 @@ import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
18
18
  import { IReactors } from './IReactors.js';
19
19
  import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
20
20
  import { getReactorMetadata } from './reactor.js';
21
+ import { isOnceOnly } from './onceOnly.js';
22
+ import { getReplayEventType } from './replay.js';
21
23
  import type { ReactorResultHandler } from './ReactorResultHandler.js';
22
24
 
23
25
  /** Expression used to partition reactor observations by event source ID. */
@@ -29,7 +31,8 @@ const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
29
31
  interface EventTypeEntry {
30
32
  readonly id: string;
31
33
  readonly generation: number;
32
- readonly methodName: string;
34
+ readonly methodName?: string;
35
+ readonly replayMethodName?: string;
33
36
  }
34
37
 
35
38
  /**
@@ -240,7 +243,7 @@ export class Reactors implements IReactors {
240
243
  EventType: { Id: et.id, Generation: et.generation, Tombstone: false },
241
244
  Key: EVENT_SOURCE_ID_KEY
242
245
  })),
243
- IsReplayable: false,
246
+ IsReplayable: !isOnceOnly(reactorType),
244
247
  Tags: getTagsFor(reactorType).map(t => t.value),
245
248
  Filters: {
246
249
  FilterTags: getFilterTagsFor(reactorType).map(t => t.value),
@@ -288,7 +291,19 @@ export class Reactors implements IReactors {
288
291
 
289
292
  const entry = eventTypes.find(et => et.id === eventTypeId);
290
293
  if (!entry) {
291
- this._logger.debug('No handler registered for event type — skipping', { reactorId: id, eventTypeId });
294
+ this._logger.debug('No reactor handler found', { reactorId: id, eventTypeId });
295
+ lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
296
+ continue;
297
+ }
298
+
299
+ const isReplay = (event.Context!.ObservationState & EventObservationState.Replay) !== 0;
300
+ const methodName = isReplay ? (entry.replayMethodName ?? entry.methodName) : entry.methodName;
301
+ if (!methodName || (isReplay && isOnceOnly(reactorInstance[methodName]))) {
302
+ if (isReplay && methodName && isOnceOnly(reactorInstance[methodName])) {
303
+ this._logger.debug('Reactor handler skipped for replay', { reactorId: id, eventTypeId, method: methodName });
304
+ } else {
305
+ this._logger.debug('No reactor handler found', { reactorId: id, eventTypeId, isReplay });
306
+ }
292
307
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
293
308
  continue;
294
309
  }
@@ -299,12 +314,12 @@ export class Reactors implements IReactors {
299
314
 
300
315
  this._logger.info('Invoking reactor handler', {
301
316
  reactorId: id,
302
- method: entry.methodName,
317
+ method: methodName,
303
318
  sequenceNumber: event.Context!.SequenceNumber.toString(),
304
319
  eventTypeId
305
320
  });
306
321
 
307
- const handlerResult = await reactorInstance[entry.methodName](content, context);
322
+ const handlerResult = await reactorInstance[methodName](content, context);
308
323
  await dispatchReactorSideEffects(this._eventLog, handlerResult, context, reactorType as Function,
309
324
  this._eventStoreName, this._namespace, this._resultHandler);
310
325
 
@@ -350,19 +365,55 @@ export class Reactors implements IReactors {
350
365
  private getEventTypesFor(reactorType: Constructor): EventTypeEntry[] {
351
366
  const proto = reactorType.prototype as Record<string, unknown>;
352
367
  const entries: EventTypeEntry[] = [];
368
+ const replayHandlers = new Map<string, string>();
369
+ const eventTypes = new Map<string, { eventTypeClass: Function; id: string; generation: number }>();
353
370
 
354
371
  for (const eventTypeClass of this._clientArtifacts.eventTypes) {
355
- const eventTypeMeta = getEventTypeMetadata(eventTypeClass);
356
- if (!eventTypeMeta) continue;
372
+ const metadata = getEventTypeMetadata(eventTypeClass);
373
+ if (metadata) {
374
+ eventTypes.set((eventTypeClass as Function).name, {
375
+ eventTypeClass: eventTypeClass as Function,
376
+ id: metadata.eventType.id.value,
377
+ generation: metadata.eventType.generation.value
378
+ });
379
+ }
380
+ }
357
381
 
358
- const className = (eventTypeClass as Function).name;
359
- const methodName = className.charAt(0).toLowerCase() + className.slice(1);
382
+ // A derived method shadows a base method of the same name, even if it is not marked for replay.
383
+ const seenMethods = new Set<string>();
384
+ for (let current = proto; current && current !== Object.prototype; current = Object.getPrototypeOf(current) as Record<string, unknown>) {
385
+ for (const name of Object.getOwnPropertyNames(current)) {
386
+ if (seenMethods.has(name)) continue;
387
+ seenMethods.add(name);
388
+ const method = current[name];
389
+ if (typeof method !== 'function') continue;
390
+ const replayEventType = getReplayEventType(method);
391
+ if (replayEventType === undefined) continue;
392
+
393
+ const eventType = replayEventType === true
394
+ ? eventTypes.get(name.startsWith('replay') ? name.slice('replay'.length) : '')
395
+ : [...eventTypes.values()].find(candidate => candidate.eventTypeClass === replayEventType);
396
+ if (!eventType) {
397
+ throw new Error(`Replay handler '${name}' on reactor '${(reactorType as Function).name}' has no registered event type.`);
398
+ }
399
+ if (replayHandlers.has(eventType.id)) {
400
+ throw new Error(`Reactor '${(reactorType as Function).name}' has multiple replay handlers for event type '${eventType.id}': '${replayHandlers.get(eventType.id)}' and '${name}'.`);
401
+ }
402
+ replayHandlers.set(eventType.id, name);
403
+ }
404
+ }
360
405
 
361
- if (typeof proto[methodName] === 'function') {
406
+ for (const [className, eventType] of eventTypes) {
407
+ const methodName = className.charAt(0).toLowerCase() + className.slice(1);
408
+ const liveMethod = proto[methodName];
409
+ const liveMethodName = typeof liveMethod === 'function' && getReplayEventType(liveMethod) === undefined ? methodName : undefined;
410
+ const replayMethodName = replayHandlers.get(eventType.id);
411
+ if (liveMethodName || replayMethodName) {
362
412
  entries.push({
363
- id: eventTypeMeta.eventType.id.value,
364
- generation: eventTypeMeta.eventType.generation.value,
365
- methodName
413
+ id: eventType.id,
414
+ generation: eventType.generation,
415
+ methodName: liveMethodName,
416
+ replayMethodName
366
417
  });
367
418
  }
368
419
  }
@@ -0,0 +1,39 @@
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 { onceOnly, isOnceOnly } from './onceOnly.js';
6
+ import { replay, getReplayEventType } from './replay.js';
7
+
8
+ class EventForReplay {}
9
+
10
+ @onceOnly()
11
+ class StandardReactor {
12
+ @onceOnly()
13
+ eventForReplay() {}
14
+
15
+ @replay()
16
+ replayEventForReplay() {}
17
+
18
+ @replay(EventForReplay)
19
+ rebuild() {}
20
+ }
21
+
22
+ class OrdinaryReactor {}
23
+
24
+ class InheritedReactor extends StandardReactor {}
25
+
26
+ describe('standard reactor policy decorators', () => {
27
+ it('marks the class independently of its handlers', () => {
28
+ expect(isOnceOnly(StandardReactor)).toBe(true);
29
+ expect(isOnceOnly(OrdinaryReactor)).toBe(false);
30
+ expect(isOnceOnly(InheritedReactor)).toBe(false);
31
+ });
32
+
33
+ it('marks methods without modifying the class or other methods', () => {
34
+ expect(isOnceOnly(StandardReactor.prototype.eventForReplay)).toBe(true);
35
+ expect(isOnceOnly(StandardReactor.prototype.replayEventForReplay)).toBe(false);
36
+ expect(getReplayEventType(StandardReactor.prototype.replayEventForReplay)).toBe(true);
37
+ expect(getReplayEventType(StandardReactor.prototype.rebuild)).toBe(EventForReplay);
38
+ });
39
+ });
package/reactors/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  export { ReactorId } from './ReactorId.js';
5
5
  export { reactor, getReactorMetadata, isReactor } from './reactor.js';
6
+ export { onceOnly } from './onceOnly.js';
7
+ export { replay } from './replay.js';
6
8
  export type { ReactorMetadata } from './reactor.js';
7
9
  export type { IReactors } from './IReactors.js';
8
10
  export { Reactors } from './Reactors.js';
@@ -0,0 +1,34 @@
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
+
6
+ const METADATA_KEY = 'chronicle:reactor:onceOnly';
7
+
8
+ /**
9
+ * Reactors replay by default. Excludes a whole reactor from replay, or skips one
10
+ * handler for replayed events. A class-level marker is not inherited by subclasses.
11
+ * This does not prevent ordinary redelivery after a failed partition is retried.
12
+ * @returns A class or method decorator for legacy and standard TypeScript decorators.
13
+ */
14
+ export function onceOnly(): ClassDecorator & MethodDecorator & ((value: Function, context: ClassDecoratorContext | ClassMethodDecoratorContext) => void) {
15
+ return (target: object, propertyOrContext?: string | symbol | ClassDecoratorContext | ClassMethodDecoratorContext, descriptor?: PropertyDescriptor) => {
16
+ if (typeof propertyOrContext === 'object') {
17
+ if (propertyOrContext.kind !== 'class' && propertyOrContext.kind !== 'method') {
18
+ throw new TypeError('Once-only can only decorate a reactor class or method.');
19
+ }
20
+ Reflect.defineMetadata(METADATA_KEY, true, target);
21
+ } else if (propertyOrContext === undefined) {
22
+ Reflect.defineMetadata(METADATA_KEY, true, target);
23
+ } else if (descriptor?.value) {
24
+ Reflect.defineMetadata(METADATA_KEY, true, descriptor.value);
25
+ } else {
26
+ throw new TypeError('Once-only can only decorate a reactor class or method.');
27
+ }
28
+ };
29
+ }
30
+
31
+ /** Whether this class or handler is marked as once-only (without inheriting the marker). */
32
+ export function isOnceOnly(target: Function): boolean {
33
+ return Reflect.getOwnMetadata(METADATA_KEY, target) === true;
34
+ }
@@ -0,0 +1,36 @@
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
+
6
+ const METADATA_KEY = 'chronicle:reactor:replay';
7
+
8
+ /**
9
+ * Marks a method as the alternative handler for an event during replay.
10
+ * Without an explicit event type, name the method `replay<EventClassName>`.
11
+ * The regular camelCase handler continues to handle live events, but this method replaces
12
+ * it during replay. Marking this replay handler with @onceOnly() means neither handler
13
+ * runs for replayed events.
14
+ * @param eventType - Optional event constructor for a differently named replay handler.
15
+ * @returns A method decorator for legacy and standard TypeScript decorators.
16
+ */
17
+ export function replay(eventType?: Function): MethodDecorator & ((value: Function, context: ClassMethodDecoratorContext) => void) {
18
+ return (target: object, propertyOrContext: string | symbol | ClassMethodDecoratorContext, descriptor?: PropertyDescriptor) => {
19
+ const method = typeof propertyOrContext === 'object' ? target : descriptor?.value;
20
+ if (typeof propertyOrContext === 'object' && (propertyOrContext.kind !== 'method' || propertyOrContext.static || propertyOrContext.private)) {
21
+ throw new TypeError('Replay requires a public instance method.');
22
+ }
23
+ if (typeof propertyOrContext !== 'object' && typeof target === 'function') {
24
+ throw new TypeError('Replay requires a public instance method.');
25
+ }
26
+ if (typeof method !== 'function') {
27
+ throw new TypeError('Replay requires a method.');
28
+ }
29
+ Reflect.defineMetadata(METADATA_KEY, eventType ?? true, method);
30
+ };
31
+ }
32
+
33
+ /** Gets the explicit event type, or the convention marker, for a replay handler. */
34
+ export function getReplayEventType(method: Function): Function | true | undefined {
35
+ return Reflect.getOwnMetadata(METADATA_KEY, method) as Function | true | undefined;
36
+ }