@cratis/chronicle 6.34.0 → 6.35.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/ChronicleClient.ts +8 -2
- package/EventStore.ts +8 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +10 -2
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +8 -0
- package/dist/EventStore.js.map +1 -1
- package/dist/events/handles.d.ts +12 -0
- package/dist/events/handles.d.ts.map +1 -0
- package/dist/events/handles.js +33 -0
- package/dist/events/handles.js.map +1 -0
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -0
- package/dist/events/index.js.map +1 -1
- package/dist/observation/EventHandler.d.ts +7 -0
- package/dist/observation/EventHandler.d.ts.map +1 -0
- package/dist/observation/EventHandler.js +4 -0
- package/dist/observation/EventHandler.js.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.js +40 -0
- package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
- package/dist/observation/getEventHandlers.d.ts +5 -0
- package/dist/observation/getEventHandlers.d.ts.map +1 -0
- package/dist/observation/getEventHandlers.js +40 -0
- package/dist/observation/getEventHandlers.js.map +1 -0
- package/dist/observation/getInstanceMethods.d.ts +4 -0
- package/dist/observation/getInstanceMethods.d.ts.map +1 -0
- package/dist/observation/getInstanceMethods.js +29 -0
- package/dist/observation/getInstanceMethods.js.map +1 -0
- package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
- package/dist/reactors/ReactorDispatcher.js +27 -32
- package/dist/reactors/ReactorDispatcher.js.map +1 -1
- package/dist/reactors/Reactors.d.ts +1 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +27 -14
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reactors/index.d.ts +1 -0
- package/dist/reactors/index.d.ts.map +1 -1
- package/dist/reactors/index.js +1 -0
- package/dist/reactors/index.js.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
- package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.js +5 -17
- package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
- package/dist/reducers/ReducerEventHandler.d.ts +3 -6
- package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
- package/dist/reducers/Reducers.d.ts +1 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +22 -10
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/reducers/index.d.ts +1 -0
- package/dist/reducers/index.d.ts.map +1 -1
- package/dist/reducers/index.js +1 -0
- package/dist/reducers/index.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts +1 -0
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +9 -1
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.js +1 -0
- package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
- package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
- package/events/handles.ts +37 -0
- package/events/index.ts +1 -0
- package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
- package/observation/EventHandler.ts +9 -0
- package/observation/createHandlerInstanceValidator.ts +42 -0
- package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
- package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
- package/observation/getEventHandlers.ts +43 -0
- package/observation/getInstanceMethods.ts +29 -0
- package/package.json +2 -2
- package/reactors/ReactorDispatcher.ts +26 -30
- package/reactors/Reactors.ts +32 -16
- package/reactors/decorators.standard.spec.ts +8 -0
- package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
- package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
- package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
- package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
- package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
- package/reactors/index.ts +1 -0
- package/reducers/ReducerEventDispatcher.ts +5 -16
- package/reducers/ReducerEventHandler.ts +4 -6
- package/reducers/Reducers.ts +24 -12
- package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
- package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
- package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
- package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
- package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
- package/reducers/index.ts +1 -0
- package/testing/ReactorScenario.ts +9 -1
- package/testing/ReducerReadModelProcessor.ts +1 -0
- package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
- package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
package/reactors/Reactors.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { getTagsFor } from '../events/tagDecorator.js';
|
|
|
14
14
|
import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
|
|
15
15
|
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
16
16
|
import type { IEventLog } from '../eventSequences/IEventLog.js';
|
|
17
|
+
import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
|
|
17
18
|
import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
|
|
18
19
|
import { IReactors } from './IReactors.js';
|
|
19
20
|
import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
|
|
@@ -105,6 +106,7 @@ export class Reactors implements IReactors {
|
|
|
105
106
|
|
|
106
107
|
private readonly _logger: ReturnType<typeof createLogger>;
|
|
107
108
|
private readonly _lifecycle: ConnectionLifecycle;
|
|
109
|
+
private readonly _unsubscribeDisconnected: () => void;
|
|
108
110
|
private readonly _reactors = new Map<string, Constructor>();
|
|
109
111
|
private readonly _queues = new Map<string, AsyncQueue<ReactorMessage>>();
|
|
110
112
|
private readonly _observations = new Map<string, AbortController>();
|
|
@@ -133,7 +135,7 @@ export class Reactors implements IReactors {
|
|
|
133
135
|
) {
|
|
134
136
|
this._logger = createLogger('@cratis/chronicle/reactors', _connection.logger);
|
|
135
137
|
this._lifecycle = lifecycle;
|
|
136
|
-
lifecycle.onDisconnected(async () => {
|
|
138
|
+
this._unsubscribeDisconnected = lifecycle.onDisconnected(async () => {
|
|
137
139
|
this._logger.info('Disconnected — stopping all reactor observations');
|
|
138
140
|
this._registered = false;
|
|
139
141
|
this.disconnectAll();
|
|
@@ -142,6 +144,7 @@ export class Reactors implements IReactors {
|
|
|
142
144
|
|
|
143
145
|
/** Stops observations permanently when the owning client is disposed. */
|
|
144
146
|
dispose(): void {
|
|
147
|
+
this._unsubscribeDisconnected();
|
|
145
148
|
this._disposed = true;
|
|
146
149
|
this._registered = false;
|
|
147
150
|
this.disconnectAll();
|
|
@@ -170,20 +173,24 @@ export class Reactors implements IReactors {
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
if (this._disposed) return;
|
|
176
|
+
const registrations = [...this._reactors].map(([id, type]) => ({
|
|
177
|
+
id, type, eventTypes: getReactorEventTypes(type, this._clientArtifacts.eventTypes)
|
|
178
|
+
})).map(registration => ({ ...registration, validateInstance: createHandlerInstanceValidator(registration.type,
|
|
179
|
+
registration.eventTypes.flatMap(entry => [entry.methodName, entry.replayMethodName].filter((name): name is string => !!name))) }));
|
|
173
180
|
this._logger.info('Registering reactors', { count: this._reactors.size });
|
|
174
|
-
for (const
|
|
175
|
-
this.startObservation(id,
|
|
181
|
+
for (const { id, type, eventTypes, validateInstance } of registrations) {
|
|
182
|
+
this.startObservation(id, type, eventTypes, validateInstance);
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
this._registered = true;
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
private startObservation(id: string, reactorType: Constructor
|
|
188
|
+
private startObservation(id: string, reactorType: Constructor,
|
|
189
|
+
eventTypes: ReactorEventTypeEntry[], validateInstance: (instance: object) => void): void {
|
|
182
190
|
runInBackgroundContext(() => {
|
|
183
191
|
if (this._disposed) return;
|
|
184
192
|
const metadata = getReactorMetadata(reactorType)!;
|
|
185
193
|
const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
|
|
186
|
-
const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
|
|
187
194
|
|
|
188
195
|
this._logger.info('Starting reactor observation', {
|
|
189
196
|
reactorId: id,
|
|
@@ -192,7 +199,7 @@ export class Reactors implements IReactors {
|
|
|
192
199
|
handlers: eventTypes.map(e => e.methodName)
|
|
193
200
|
});
|
|
194
201
|
|
|
195
|
-
void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
|
|
202
|
+
void this.runObservation(id, reactorType, eventSequenceId, eventTypes, validateInstance);
|
|
196
203
|
});
|
|
197
204
|
}
|
|
198
205
|
|
|
@@ -200,15 +207,16 @@ export class Reactors implements IReactors {
|
|
|
200
207
|
id: string,
|
|
201
208
|
reactorType: Constructor,
|
|
202
209
|
eventSequenceId: string,
|
|
203
|
-
eventTypes: ReactorEventTypeEntry[]
|
|
210
|
+
eventTypes: ReactorEventTypeEntry[],
|
|
211
|
+
validateInstance: (instance: object) => void
|
|
204
212
|
): Promise<void> {
|
|
205
213
|
try {
|
|
206
|
-
await this.observeReactor(id, reactorType, eventSequenceId, eventTypes);
|
|
214
|
+
await this.observeReactor(id, reactorType, eventSequenceId, eventTypes, validateInstance);
|
|
207
215
|
} catch (error) {
|
|
208
216
|
this._logger.error('Reactor observation loop exited with error', { reactorId: id, error });
|
|
209
217
|
}
|
|
210
218
|
|
|
211
|
-
this.scheduleReobserve(id, reactorType);
|
|
219
|
+
this.scheduleReobserve(id, reactorType, eventTypes, validateInstance);
|
|
212
220
|
}
|
|
213
221
|
|
|
214
222
|
/**
|
|
@@ -220,7 +228,7 @@ export class Reactors implements IReactors {
|
|
|
220
228
|
* the whole client reconnects. Both endings are therefore retried, and the delay
|
|
221
229
|
* keeps a stream that keeps ending from becoming a hot loop.
|
|
222
230
|
*/
|
|
223
|
-
private scheduleReobserve(id: string, reactorType: Constructor): void {
|
|
231
|
+
private scheduleReobserve(id: string, reactorType: Constructor, eventTypes: ReactorEventTypeEntry[], validateInstance: (instance: object) => void): void {
|
|
224
232
|
// A disconnect clears the registration; the reconnect re-registers every
|
|
225
233
|
// reactor from scratch, so retrying here as well would double up.
|
|
226
234
|
if (!this._registered || this._disposed) {
|
|
@@ -233,7 +241,7 @@ export class Reactors implements IReactors {
|
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
this._logger.info('Re-establishing reactor observation', { reactorId: id });
|
|
236
|
-
this.startObservation(id, reactorType);
|
|
244
|
+
this.startObservation(id, reactorType, eventTypes, validateInstance);
|
|
237
245
|
}, Reactors._reobserveDelayMs));
|
|
238
246
|
|
|
239
247
|
handle.unref?.();
|
|
@@ -243,7 +251,8 @@ export class Reactors implements IReactors {
|
|
|
243
251
|
id: string,
|
|
244
252
|
reactorType: Constructor,
|
|
245
253
|
eventSequenceId: string,
|
|
246
|
-
eventTypes: ReactorEventTypeEntry[]
|
|
254
|
+
eventTypes: ReactorEventTypeEntry[],
|
|
255
|
+
validateInstance: (instance: object) => void
|
|
247
256
|
): Promise<void> {
|
|
248
257
|
const queue = new AsyncQueue<ReactorMessage>();
|
|
249
258
|
const controller = new AbortController();
|
|
@@ -278,6 +287,7 @@ export class Reactors implements IReactors {
|
|
|
278
287
|
|
|
279
288
|
try {
|
|
280
289
|
const reactorInstance = this._artifactActivator ? undefined : new (reactorType as new () => Record<string, Function>)();
|
|
290
|
+
if (reactorInstance) validateInstance(reactorInstance);
|
|
281
291
|
const services: ReactorServices | undefined = this._eventStore && {
|
|
282
292
|
eventStore: this._eventStore, readModels: this._eventStore.readModels, signal: controller.signal
|
|
283
293
|
};
|
|
@@ -302,9 +312,12 @@ export class Reactors implements IReactors {
|
|
|
302
312
|
readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
|
|
303
313
|
signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
|
|
304
314
|
replayState: eventsToObserve.ReplayState
|
|
305
|
-
}, this._artifactActivator, artifact =>
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
}, this._artifactActivator, artifact => {
|
|
316
|
+
validateInstance(artifact.instance);
|
|
317
|
+
return runActivated(artifact, () =>
|
|
318
|
+
notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
|
|
319
|
+
{ delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState });
|
|
320
|
+
}, this._connection.logger);
|
|
308
321
|
} else if (reactorInstance) {
|
|
309
322
|
await notifyReplayLifecycle(reactorInstance, eventsToObserve.ReplayState, eventsToObserve.Partition);
|
|
310
323
|
}
|
|
@@ -388,7 +401,10 @@ export class Reactors implements IReactors {
|
|
|
388
401
|
readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
|
|
389
402
|
signal: controller.signal, delivery: ArtifactDelivery.Events,
|
|
390
403
|
eventContext: toClientEventContext(firstInvocableEvent.Context!)
|
|
391
|
-
}, this._artifactActivator,
|
|
404
|
+
}, this._artifactActivator, artifact => {
|
|
405
|
+
validateInstance(artifact.instance);
|
|
406
|
+
return processEvents(artifact);
|
|
407
|
+
}, this._connection.logger);
|
|
392
408
|
} catch (err) {
|
|
393
409
|
this._logger.error('Error activating reactor', { reactorId: id, error: err });
|
|
394
410
|
exceptionMessages.push(String(err));
|
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import { onceOnly, isOnceOnly } from './onceOnly.js';
|
|
6
6
|
import { replay, getReplayEventType } from './replay.js';
|
|
7
|
+
import { handles } from '../index.js';
|
|
8
|
+
import { getHandledEventType } from '../events/handles.js';
|
|
7
9
|
|
|
8
10
|
class EventForReplay {}
|
|
9
11
|
|
|
10
12
|
@onceOnly()
|
|
11
13
|
class StandardReactor {
|
|
14
|
+
@handles(EventForReplay)
|
|
15
|
+
@onceOnly()
|
|
16
|
+
notify() {}
|
|
17
|
+
|
|
12
18
|
@onceOnly()
|
|
13
19
|
eventForReplay() {}
|
|
14
20
|
|
|
@@ -31,6 +37,8 @@ describe('standard reactor policy decorators', () => {
|
|
|
31
37
|
});
|
|
32
38
|
|
|
33
39
|
it('marks methods without modifying the class or other methods', () => {
|
|
40
|
+
expect(getHandledEventType(StandardReactor.prototype.notify)).toBe(EventForReplay);
|
|
41
|
+
expect(isOnceOnly(StandardReactor.prototype.notify)).toBe(true);
|
|
34
42
|
expect(isOnceOnly(StandardReactor.prototype.eventForReplay)).toBe(true);
|
|
35
43
|
expect(isOnceOnly(StandardReactor.prototype.replayEventForReplay)).toBe(false);
|
|
36
44
|
expect(getReplayEventType(StandardReactor.prototype.replayEventForReplay)).toBe(true);
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { EventObservationState } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
6
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
7
|
+
import { handles, onceOnly, replay } from '../index.js';
|
|
8
|
+
import { getReactorEventTypes, selectReactorHandler } from '../ReactorDispatcher.js';
|
|
9
|
+
|
|
10
|
+
chai.should();
|
|
11
|
+
|
|
12
|
+
@eventType('selected-handler')
|
|
13
|
+
class AuthorRegistered {}
|
|
14
|
+
|
|
15
|
+
function selected(type: new () => object, state: EventObservationState) {
|
|
16
|
+
return selectReactorHandler(getReactorEventTypes(type, [AuthorRegistered]), type, undefined, 'selected-handler', state)!;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('when selecting an explicit handler with a replay alternative', () => {
|
|
20
|
+
class Observer {
|
|
21
|
+
@handles(AuthorRegistered)
|
|
22
|
+
@onceOnly()
|
|
23
|
+
notify() {}
|
|
24
|
+
|
|
25
|
+
@replay(AuthorRegistered)
|
|
26
|
+
rebuild() {}
|
|
27
|
+
}
|
|
28
|
+
it('should select the explicit method for live events', () => {
|
|
29
|
+
selected(Observer, EventObservationState.Initial).methodName!.should.equal('notify');
|
|
30
|
+
});
|
|
31
|
+
it('should select the replay alternative even when the live method is onceOnly', () => {
|
|
32
|
+
selected(Observer, EventObservationState.Replay).should.deep.equal({ methodName: 'rebuild', isReplay: true, skipReplay: false });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('when selecting an explicit handler without a replay alternative', () => {
|
|
37
|
+
class Observer { @handles(AuthorRegistered) notify() {} }
|
|
38
|
+
let handler: ReturnType<typeof selected>;
|
|
39
|
+
beforeEach(() => { handler = selected(Observer, EventObservationState.Replay); });
|
|
40
|
+
it('should use the explicit method during replay too', () => {
|
|
41
|
+
handler.should.deep.equal({ methodName: 'notify', isReplay: true, skipReplay: false });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('when an explicit handler is onceOnly', () => {
|
|
46
|
+
class Observer { @onceOnly() @handles(AuthorRegistered) notify() {} }
|
|
47
|
+
it('should not skip live delivery', () => {
|
|
48
|
+
selected(Observer, EventObservationState.Initial).skipReplay.should.be.false;
|
|
49
|
+
});
|
|
50
|
+
it('should skip replay delivery', () => {
|
|
51
|
+
selected(Observer, EventObservationState.Replay).skipReplay.should.be.true;
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('when a method declares both live and replay roles', () => {
|
|
56
|
+
class Observer { @handles(AuthorRegistered) @replay(AuthorRegistered) notify() {} }
|
|
57
|
+
it('should reject the ambiguous declaration during discovery', () => {
|
|
58
|
+
(() => getReactorEventTypes(Observer, [AuthorRegistered])).should.throw(/notify.*Observer.*cannot combine @handles and @replay/);
|
|
59
|
+
});
|
|
60
|
+
it('should reject replay above handles too', () => {
|
|
61
|
+
class Reversed { @replay(AuthorRegistered) @handles(AuthorRegistered) notify() {} }
|
|
62
|
+
(() => getReactorEventTypes(Reversed, [AuthorRegistered])).should.throw(/notify.*Reversed.*cannot combine @handles and @replay/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
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, ObservationState, ReplayState, type ReactorMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, 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 type { IEventStore } from '../../IEventStore.js';
|
|
11
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
12
|
+
import { handles, reactor, Reactors } from '../index.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
|
|
16
|
+
@eventType('field-reactor-event')
|
|
17
|
+
class AuthorRegistered {}
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
class Base {
|
|
20
|
+
@handles(AuthorRegistered) notify() { calls.push('base'); }
|
|
21
|
+
beginReplay() { calls.push('replay'); }
|
|
22
|
+
endReplay() {}
|
|
23
|
+
}
|
|
24
|
+
@reactor('field-reactor')
|
|
25
|
+
class Derived extends Base {
|
|
26
|
+
override notify = () => { calls.push('field'); };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const replayState of [ReplayState.REPLAY_STATE_None, ReplayState.BeginReplay]) {
|
|
30
|
+
describe(`when activating a reactor with a field override for replay state ${replayState}`, () => {
|
|
31
|
+
let result: ReactorMessage;
|
|
32
|
+
let disposed: boolean;
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
calls.length = 0;
|
|
35
|
+
disposed = false;
|
|
36
|
+
const lifecycle = new ConnectionLifecycle();
|
|
37
|
+
let finish!: () => void;
|
|
38
|
+
const finished = new Promise<void>(resolve => { finish = resolve; });
|
|
39
|
+
async function* stream(queue: AsyncIterable<ReactorMessage>) {
|
|
40
|
+
const messages = queue[Symbol.asyncIterator]();
|
|
41
|
+
await messages.next();
|
|
42
|
+
yield { Events: [{ Content: '{}', Context: EventContext.fromPartial({
|
|
43
|
+
EventType: EventType.fromPartial({ Id: 'field-reactor-event', Generation: 1 }), SequenceNumber: 1n
|
|
44
|
+
}) }], Partition: 'author', ReplayState: replayState, InitialState: '' };
|
|
45
|
+
result = (await messages.next()).value!;
|
|
46
|
+
await lifecycle.disconnected(error => { throw error; });
|
|
47
|
+
finish();
|
|
48
|
+
}
|
|
49
|
+
const reactors = new Reactors({ reactors: [Derived], eventTypes: [AuthorRegistered] } as unknown as IClientArtifactsProvider,
|
|
50
|
+
{ reactors: { observe: stream } } as unknown as ChronicleConnection, 'store', 'tenant', lifecycle, {} as IEventLog,
|
|
51
|
+
undefined, { readModels: {} } as IEventStore, type => ({ instance: new type(), dispose: () => { disposed = true; } }));
|
|
52
|
+
try { await reactors.register(); await finished; } finally { reactors.dispose(); }
|
|
53
|
+
});
|
|
54
|
+
it('should fail delivery without invoking the field or replay notification', () => {
|
|
55
|
+
calls.should.have.lengthOf(0);
|
|
56
|
+
result.Content!.Value1!.State.should.equal(ObservationState.Failed);
|
|
57
|
+
result.Content!.Value1!.ExceptionMessages.join(' ').should.contain(
|
|
58
|
+
"Override 'notify' on 'Derived' hides @handles(AuthorRegistered) declared on 'Base'; use a method with @handles instead of an instance field.");
|
|
59
|
+
});
|
|
60
|
+
it('should release the rejected activation lease', () => { disposed.should.be.true; });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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, ObservationState, ReplayState, type ReactorMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, 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 type { IEventStore } from '../../IEventStore.js';
|
|
11
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
12
|
+
import { handles, reactor, Reactors, replay } from '../index.js';
|
|
13
|
+
|
|
14
|
+
chai.should();
|
|
15
|
+
|
|
16
|
+
@eventType('instance-reactor-event')
|
|
17
|
+
class AuthorRegistered {}
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
|
|
20
|
+
@reactor('bound-conventional-reactor')
|
|
21
|
+
class BoundConventional {
|
|
22
|
+
readonly marker = 'bound conventional';
|
|
23
|
+
constructor() { this.authorRegistered = this.authorRegistered.bind(this); }
|
|
24
|
+
authorRegistered() { calls.push(this.marker); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@reactor('bound-explicit-reactor')
|
|
28
|
+
class BoundExplicit {
|
|
29
|
+
readonly marker = 'bound explicit';
|
|
30
|
+
constructor() { this.notify = this.notify.bind(this); }
|
|
31
|
+
@handles(AuthorRegistered)
|
|
32
|
+
notify() { calls.push(this.marker); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class BoundExplicitBase {
|
|
36
|
+
readonly marker = 'twice bound explicit';
|
|
37
|
+
constructor() { this.notify = this.notify.bind(this); }
|
|
38
|
+
@handles(AuthorRegistered)
|
|
39
|
+
notify() { calls.push(this.marker); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@reactor('twice-bound-explicit-reactor')
|
|
43
|
+
class TwiceBoundExplicit extends BoundExplicitBase {
|
|
44
|
+
constructor() { super(); this.notify = this.notify.bind(this); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class Conventional {
|
|
48
|
+
authorRegistered() { calls.push('base'); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@reactor('field-conventional-reactor')
|
|
52
|
+
class FieldConventional extends Conventional {
|
|
53
|
+
override authorRegistered = () => { calls.push('field conventional'); };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@reactor('bound-replay-reactor')
|
|
57
|
+
class BoundReplay {
|
|
58
|
+
readonly marker = 'bound replay';
|
|
59
|
+
constructor() { this.restore = this.restore.bind(this); }
|
|
60
|
+
@replay(AuthorRegistered)
|
|
61
|
+
restore() { calls.push(this.marker); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class Replay {
|
|
65
|
+
@replay(AuthorRegistered)
|
|
66
|
+
restore() { calls.push('base replay'); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@reactor('field-replay-reactor')
|
|
70
|
+
class FieldReplay extends Replay {
|
|
71
|
+
override restore = () => { calls.push('field replay'); };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const { type, expected, observationState } of [
|
|
75
|
+
{ type: BoundConventional, expected: 'bound conventional', observationState: EventObservationState.Initial },
|
|
76
|
+
{ type: BoundExplicit, expected: 'bound explicit', observationState: EventObservationState.Initial },
|
|
77
|
+
{ type: TwiceBoundExplicit, expected: 'twice bound explicit', observationState: EventObservationState.Initial },
|
|
78
|
+
{ type: FieldConventional, expected: 'field conventional', observationState: EventObservationState.Initial },
|
|
79
|
+
{ type: BoundReplay, expected: 'bound replay', observationState: EventObservationState.Replay },
|
|
80
|
+
{ type: FieldReplay, expected: 'field replay', observationState: EventObservationState.Replay }
|
|
81
|
+
]) {
|
|
82
|
+
describe(`when delivering to the ${expected} reactor handler without an activator`, () => {
|
|
83
|
+
let result: ReactorMessage;
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
calls.length = 0;
|
|
86
|
+
const lifecycle = new ConnectionLifecycle();
|
|
87
|
+
let finish!: () => void;
|
|
88
|
+
const finished = new Promise<void>(resolve => { finish = resolve; });
|
|
89
|
+
async function* stream(queue: AsyncIterable<ReactorMessage>) {
|
|
90
|
+
const messages = queue[Symbol.asyncIterator]();
|
|
91
|
+
await messages.next();
|
|
92
|
+
yield { Events: [{ Content: '{}', Context: EventContext.fromPartial({
|
|
93
|
+
EventType: EventType.fromPartial({ Id: 'instance-reactor-event', Generation: 1 }), SequenceNumber: 1n,
|
|
94
|
+
ObservationState: observationState
|
|
95
|
+
}) }], Partition: 'author', ReplayState: ReplayState.REPLAY_STATE_None, InitialState: '' };
|
|
96
|
+
result = (await messages.next()).value!;
|
|
97
|
+
await lifecycle.disconnected(error => { throw error; });
|
|
98
|
+
finish();
|
|
99
|
+
}
|
|
100
|
+
const reactors = new Reactors({ reactors: [type], eventTypes: [AuthorRegistered] } as unknown as IClientArtifactsProvider,
|
|
101
|
+
{ reactors: { observe: stream } } as unknown as ChronicleConnection, 'store', 'tenant', lifecycle, {} as IEventLog,
|
|
102
|
+
undefined, { readModels: {} } as IEventStore);
|
|
103
|
+
try { await reactors.register(); await finished; } finally { reactors.dispose(); }
|
|
104
|
+
});
|
|
105
|
+
it('should invoke the instance handler', () => { calls.should.deep.equal([expected]); });
|
|
106
|
+
it('should complete delivery', () => { result.Content!.Value1!.State.should.equal(ObservationState.Success); });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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, type ReactorMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, 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 { handles, onceOnly, reactor, Reactors, replay } from '../index.js';
|
|
12
|
+
|
|
13
|
+
chai.should();
|
|
14
|
+
|
|
15
|
+
@eventType('legacy-delivered-handler')
|
|
16
|
+
class AuthorRegistered { constructor(readonly name = '') {} }
|
|
17
|
+
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
@reactor('legacy-delivery')
|
|
20
|
+
class AuthorReactor {
|
|
21
|
+
@handles(AuthorRegistered)
|
|
22
|
+
@onceOnly()
|
|
23
|
+
notify(event: AuthorRegistered) { calls.push(`live:${event.name}`); }
|
|
24
|
+
}
|
|
25
|
+
@reactor('legacy-delivery-with-replay')
|
|
26
|
+
class ReplayAuthorReactor extends AuthorReactor {
|
|
27
|
+
@replay(AuthorRegistered)
|
|
28
|
+
rebuild(event: AuthorRegistered) { calls.push(`replay:${event.name}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [type, expected] of [
|
|
32
|
+
[AuthorReactor, ['live:Ada']],
|
|
33
|
+
[ReplayAuthorReactor, ['live:Ada', 'replay:Ada']]
|
|
34
|
+
] as const) {
|
|
35
|
+
describe(`when observing live and replay events with explicit legacy handlers on ${type.name}`, () => {
|
|
36
|
+
let registration: ReactorMessage;
|
|
37
|
+
let result: ReactorMessage;
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
calls.length = 0;
|
|
40
|
+
const lifecycle = new ConnectionLifecycle();
|
|
41
|
+
let finish!: () => void;
|
|
42
|
+
const finished = new Promise<void>(resolve => { finish = resolve; });
|
|
43
|
+
async function* stream(queue: AsyncIterable<ReactorMessage>) {
|
|
44
|
+
const messages = queue[Symbol.asyncIterator]();
|
|
45
|
+
registration = (await messages.next()).value!;
|
|
46
|
+
yield {
|
|
47
|
+
Events: [EventObservationState.Initial, EventObservationState.Replay].map((state, index) => ({
|
|
48
|
+
Context: EventContext.fromPartial({
|
|
49
|
+
EventType: EventType.fromPartial({ Id: 'legacy-delivered-handler', Generation: 1 }),
|
|
50
|
+
SequenceNumber: BigInt(index + 1), ObservationState: state
|
|
51
|
+
}),
|
|
52
|
+
Content: JSON.stringify({ name: 'Ada' })
|
|
53
|
+
})),
|
|
54
|
+
Partition: 'author', ReplayState: 0, InitialState: ''
|
|
55
|
+
};
|
|
56
|
+
result = (await messages.next()).value!;
|
|
57
|
+
await lifecycle.disconnected(error => { throw error; });
|
|
58
|
+
finish();
|
|
59
|
+
}
|
|
60
|
+
const connection = { reactors: { observe: stream } } as unknown as ChronicleConnection;
|
|
61
|
+
const artifacts = { reactors: [type], eventTypes: [AuthorRegistered] } as unknown as IClientArtifactsProvider;
|
|
62
|
+
const reactors = new Reactors(artifacts, connection, 'store', 'tenant', lifecycle, {} as IEventLog);
|
|
63
|
+
try {
|
|
64
|
+
await reactors.register();
|
|
65
|
+
await finished;
|
|
66
|
+
} finally { reactors.dispose(); }
|
|
67
|
+
});
|
|
68
|
+
it('should subscribe to the explicitly named event type', () => {
|
|
69
|
+
registration.Content!.Value0!.Reactor!.EventTypes.map(entry => entry.EventType!.Id)
|
|
70
|
+
.should.deep.equal(['legacy-delivered-handler']);
|
|
71
|
+
});
|
|
72
|
+
it('should invoke only the appropriate handlers', () => { calls.should.deep.equal(expected); });
|
|
73
|
+
it('should acknowledge both deliveries', () => {
|
|
74
|
+
result.Content!.Value1!.LastSuccessfulObservation.should.equal(2n);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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 { beforeEach, chai, describe, it, vi } from 'vitest';
|
|
5
|
+
import type { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
6
|
+
import type { ChronicleConnection } from '../../connection/index.js';
|
|
7
|
+
import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
|
|
8
|
+
import type { IEventLog } from '../../eventSequences/IEventLog.js';
|
|
9
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
10
|
+
import { handles, reactor, Reactors } from '../index.js';
|
|
11
|
+
|
|
12
|
+
chai.should();
|
|
13
|
+
|
|
14
|
+
@eventType('registration-handled-event')
|
|
15
|
+
class AuthorRegistered {}
|
|
16
|
+
@eventType('unselected-handled-event')
|
|
17
|
+
class Unregistered {}
|
|
18
|
+
|
|
19
|
+
@reactor('valid-handlers')
|
|
20
|
+
class Valid { @handles(AuthorRegistered) notify() {} }
|
|
21
|
+
@reactor('missing-handler-event')
|
|
22
|
+
class Missing { @handles(Unregistered) notify() {} }
|
|
23
|
+
@reactor('duplicate-handlers')
|
|
24
|
+
class Duplicate {
|
|
25
|
+
@handles(AuthorRegistered) notify() {}
|
|
26
|
+
authorRegistered() {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [type, message] of [
|
|
30
|
+
[Missing, /notify.*Missing.*Unregistered.*no registered event type/],
|
|
31
|
+
[Duplicate, /multiple handlers.*registration-handled-event.*notify.*authorRegistered/]
|
|
32
|
+
] as const) {
|
|
33
|
+
describe(`when registering a reactor with invalid explicit handlers on ${type.name}`, () => {
|
|
34
|
+
const observe = vi.fn();
|
|
35
|
+
let error: unknown;
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
observe.mockReset();
|
|
38
|
+
error = undefined;
|
|
39
|
+
const artifacts = { reactors: [Valid, type], eventTypes: [AuthorRegistered] } as unknown as IClientArtifactsProvider;
|
|
40
|
+
const connection = { reactors: { observe } } as unknown as ChronicleConnection;
|
|
41
|
+
const reactors = new Reactors(artifacts, connection, 'store', 'tenant', new ConnectionLifecycle(), {} as IEventLog);
|
|
42
|
+
try { await reactors.register(); } catch (caught) { error = caught; }
|
|
43
|
+
finally { reactors.dispose(); }
|
|
44
|
+
});
|
|
45
|
+
it('should reject registration with an actionable error', () => {
|
|
46
|
+
(error instanceof Error).should.be.true;
|
|
47
|
+
(error as Error).message.should.match(message);
|
|
48
|
+
});
|
|
49
|
+
it('should not start any observation before validating all reactors', () => {
|
|
50
|
+
observe.mock.calls.should.have.lengthOf(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
package/reactors/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export { ReactorId } from './ReactorId.js';
|
|
5
5
|
export { reactor, getReactorMetadata, isReactor } from './reactor.js';
|
|
6
|
+
export { handles } from '../events/handles.js';
|
|
6
7
|
export { onceOnly } from './onceOnly.js';
|
|
7
8
|
export { replay } from './replay.js';
|
|
8
9
|
export type { ReactorMetadata } from './reactor.js';
|
|
@@ -2,31 +2,20 @@
|
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
4
|
import type { Constructor } from '@cratis/fundamentals';
|
|
5
|
-
import {
|
|
5
|
+
import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
|
|
6
|
+
import { getEventHandlers } from '../observation/getEventHandlers.js';
|
|
6
7
|
import type { EventContext } from '../events/EventContext.js';
|
|
7
8
|
import type { ReducerEventHandler } from './ReducerEventHandler.js';
|
|
8
9
|
|
|
9
10
|
/** Selects and invokes the same reducer handlers for kernel deliveries and in-process scenarios. */
|
|
10
11
|
export class ReducerEventDispatcher {
|
|
11
12
|
readonly handlers: ReducerEventHandler[];
|
|
13
|
+
readonly validateInstance: (instance: object) => void;
|
|
12
14
|
|
|
13
15
|
/** Discovers event methods from the artifact event types. */
|
|
14
16
|
constructor(reducerType: Constructor, eventTypes: readonly Constructor[]) {
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
for (const eventTypeClass of eventTypes) {
|
|
18
|
-
const metadata = getEventTypeMetadata(eventTypeClass);
|
|
19
|
-
if (!metadata) continue;
|
|
20
|
-
const className = (eventTypeClass as Function).name;
|
|
21
|
-
const methodName = className.charAt(0).toLowerCase() + className.slice(1);
|
|
22
|
-
if (typeof proto[methodName] === 'function') {
|
|
23
|
-
this.handlers.push({
|
|
24
|
-
id: metadata.eventType.id.value,
|
|
25
|
-
generation: metadata.eventType.generation.value,
|
|
26
|
-
methodName
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
}
|
|
17
|
+
this.handlers = getEventHandlers(reducerType, eventTypes);
|
|
18
|
+
this.validateInstance = createHandlerInstanceValidator(reducerType, this.handlers.map(handler => handler.methodName));
|
|
30
19
|
}
|
|
31
20
|
|
|
32
21
|
/** Resolves a registered event type, or returns undefined for an unsubscribed event. */
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readonly methodName: string;
|
|
9
|
-
}
|
|
4
|
+
import type { EventHandler } from '../observation/EventHandler.js';
|
|
5
|
+
|
|
6
|
+
/** Event type and method selected for a reducer observation. */
|
|
7
|
+
export type ReducerEventHandler = EventHandler;
|