@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.
- package/EventStore.ts +10 -4
- package/README.md +14 -5
- package/dist/EventStore.d.ts +1 -0
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +7 -4
- package/dist/EventStore.js.map +1 -1
- package/dist/eventSequences/EventLog.d.ts +2 -1
- package/dist/eventSequences/EventLog.d.ts.map +1 -1
- package/dist/eventSequences/EventLog.js +2 -2
- package/dist/eventSequences/EventLog.js.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts +3 -1
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +11 -6
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/events/constraints/ConstraintBuilder.d.ts +2 -0
- package/dist/events/constraints/ConstraintBuilder.d.ts.map +1 -1
- package/dist/events/constraints/ConstraintBuilder.js.map +1 -1
- package/dist/events/constraints/Constraints.d.ts +8 -3
- package/dist/events/constraints/Constraints.d.ts.map +1 -1
- package/dist/events/constraints/Constraints.js +147 -6
- package/dist/events/constraints/Constraints.js.map +1 -1
- package/dist/events/constraints/index.d.ts +2 -0
- package/dist/events/constraints/index.d.ts.map +1 -1
- package/dist/events/constraints/index.js +2 -0
- package/dist/events/constraints/index.js.map +1 -1
- package/dist/events/constraints/removeConstraint.d.ts +11 -0
- package/dist/events/constraints/removeConstraint.d.ts.map +1 -0
- package/dist/events/constraints/removeConstraint.js +24 -0
- package/dist/events/constraints/removeConstraint.js.map +1 -0
- package/dist/events/constraints/unique.d.ts +21 -0
- package/dist/events/constraints/unique.d.ts.map +1 -0
- package/dist/events/constraints/unique.js +37 -0
- package/dist/events/constraints/unique.js.map +1 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +64 -13
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reactors/index.d.ts +2 -0
- package/dist/reactors/index.d.ts.map +1 -1
- package/dist/reactors/index.js +2 -0
- package/dist/reactors/index.js.map +1 -1
- package/dist/reactors/onceOnly.d.ts +11 -0
- package/dist/reactors/onceOnly.d.ts.map +1 -0
- package/dist/reactors/onceOnly.js +34 -0
- package/dist/reactors/onceOnly.js.map +1 -0
- package/dist/reactors/replay.d.ts +14 -0
- package/dist/reactors/replay.d.ts.map +1 -0
- package/dist/reactors/replay.js +33 -0
- package/dist/reactors/replay.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventLog.ts +4 -2
- package/eventSequences/EventSequence.ts +10 -6
- package/events/classDecorators.standard.spec.ts +45 -1
- package/events/constraints/ConstraintBuilder.ts +2 -0
- package/events/constraints/Constraints.ts +140 -7
- package/events/constraints/for_Constraints/when_combining_decorators_with_fluent_constraints.spec.ts +142 -0
- package/events/constraints/for_Constraints/when_inheriting_removal_decorators.legacy.spec.ts +36 -0
- package/events/constraints/for_Constraints/when_registering_decorated_constraints.spec.ts +63 -0
- package/events/constraints/for_Constraints/when_registering_decorated_event_types.spec.ts +90 -0
- package/events/constraints/for_Constraints/when_rejecting_incompatible_unique_merges.spec.ts +108 -0
- package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +92 -0
- package/events/constraints/index.ts +2 -0
- package/events/constraints/removeConstraint.ts +28 -0
- package/events/constraints/unique.ts +45 -0
- package/package.json +2 -2
- package/reactors/Reactors.replay.legacy.spec.ts +241 -0
- package/reactors/Reactors.ts +65 -14
- package/reactors/decorators.standard.spec.ts +39 -0
- package/reactors/index.ts +2 -0
- package/reactors/onceOnly.ts +34 -0
- 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
|
+
});
|
package/reactors/Reactors.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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:
|
|
317
|
+
method: methodName,
|
|
303
318
|
sequenceNumber: event.Context!.SequenceNumber.toString(),
|
|
304
319
|
eventTypeId
|
|
305
320
|
});
|
|
306
321
|
|
|
307
|
-
const handlerResult = await reactorInstance[
|
|
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
|
|
356
|
-
if (
|
|
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
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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:
|
|
364
|
-
generation:
|
|
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
|
+
}
|