@cratis/chronicle 6.34.0 → 6.36.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/testing/projections/ProjectionArithmetic.d.ts +11 -0
- package/dist/testing/projections/ProjectionArithmetic.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionArithmetic.js +70 -0
- package/dist/testing/projections/ProjectionArithmetic.js.map +1 -0
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.js +26 -10
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +10 -3
- package/dist/testing/projections/ProjectionReadModelProcessor.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_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts +85 -0
- package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_legacy_decorators.legacy.spec.ts +91 -0
- package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts +92 -0
- package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
- package/testing/projections/ProjectionArithmetic.ts +71 -0
- package/testing/projections/ProjectionCapabilities.ts +27 -9
- package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +9 -6
- package/testing/projections/ProjectionReadModelProcessor.ts +9 -3
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +3 -3
- package/testing/projections/for_ProjectionCapabilities/when_validating_arithmetic.spec.ts +127 -0
- package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +4 -5
|
@@ -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;
|
package/reducers/Reducers.ts
CHANGED
|
@@ -105,6 +105,7 @@ export class Reducers implements IReducers {
|
|
|
105
105
|
|
|
106
106
|
private readonly _logger: ReturnType<typeof createLogger>;
|
|
107
107
|
private readonly _lifecycle: ConnectionLifecycle;
|
|
108
|
+
private readonly _unsubscribeDisconnected: () => void;
|
|
108
109
|
private readonly _reducers = new Map<string, Constructor>();
|
|
109
110
|
private readonly _queues = new Map<string, AsyncQueue<ReducerMessage>>();
|
|
110
111
|
private readonly _observations = new Map<string, AbortController>();
|
|
@@ -136,7 +137,7 @@ export class Reducers implements IReducers {
|
|
|
136
137
|
) {
|
|
137
138
|
this._logger = createLogger('@cratis/chronicle/reducers', _connection.logger);
|
|
138
139
|
this._lifecycle = lifecycle;
|
|
139
|
-
lifecycle.onDisconnected(async () => {
|
|
140
|
+
this._unsubscribeDisconnected = lifecycle.onDisconnected(async () => {
|
|
140
141
|
this._logger.info('Disconnected — stopping all reducer observations');
|
|
141
142
|
this._registered = false;
|
|
142
143
|
this.disconnectAll();
|
|
@@ -145,6 +146,7 @@ export class Reducers implements IReducers {
|
|
|
145
146
|
|
|
146
147
|
/** Stops observations permanently when the owning client is disposed. */
|
|
147
148
|
dispose(): void {
|
|
149
|
+
this._unsubscribeDisconnected();
|
|
148
150
|
this._disposed = true;
|
|
149
151
|
this._registered = false;
|
|
150
152
|
this.disconnectAll();
|
|
@@ -172,13 +174,16 @@ export class Reducers implements IReducers {
|
|
|
172
174
|
await this.discover();
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
const registrations = [...this._reducers].map(([id, type]) => ({
|
|
178
|
+
id, type, dispatcher: new ReducerEventDispatcher(type, this._clientArtifacts.eventTypes)
|
|
179
|
+
}));
|
|
175
180
|
assertUniqueReadModelIds(this._clientArtifacts.readModels);
|
|
176
181
|
await this.registerReadModels();
|
|
177
182
|
|
|
178
183
|
if (this._disposed) return;
|
|
179
184
|
this._logger.info('Registering reducers', { count: this._reducers.size });
|
|
180
|
-
for (const
|
|
181
|
-
this.startObservation(id,
|
|
185
|
+
for (const { id, type, dispatcher } of registrations) {
|
|
186
|
+
this.startObservation(id, type, dispatcher);
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
this._registered = true;
|
|
@@ -236,12 +241,12 @@ export class Reducers implements IReducers {
|
|
|
236
241
|
return JSON.stringify(minimalSchema);
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
private startObservation(id: string, reducerType: Constructor
|
|
244
|
+
private startObservation(id: string, reducerType: Constructor,
|
|
245
|
+
dispatcher: ReducerEventDispatcher): void {
|
|
240
246
|
runInBackgroundContext(() => {
|
|
241
247
|
if (this._disposed) return;
|
|
242
248
|
const metadata = getReducerMetadata(reducerType)!;
|
|
243
249
|
const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
|
|
244
|
-
const dispatcher = new ReducerEventDispatcher(reducerType, this._clientArtifacts.eventTypes);
|
|
245
250
|
const readModelName = this.getReducerReadModelIdentifier(reducerType);
|
|
246
251
|
|
|
247
252
|
this._logger.info('Starting reducer observation', {
|
|
@@ -269,7 +274,7 @@ export class Reducers implements IReducers {
|
|
|
269
274
|
this._logger.error('Reducer observation loop exited with error', { reducerId: id, error });
|
|
270
275
|
}
|
|
271
276
|
|
|
272
|
-
this.scheduleReobserve(id, reducerType);
|
|
277
|
+
this.scheduleReobserve(id, reducerType, dispatcher);
|
|
273
278
|
}
|
|
274
279
|
|
|
275
280
|
/**
|
|
@@ -282,7 +287,7 @@ export class Reducers implements IReducers {
|
|
|
282
287
|
* retried, and the delay keeps a stream that keeps ending from becoming a hot
|
|
283
288
|
* loop.
|
|
284
289
|
*/
|
|
285
|
-
private scheduleReobserve(id: string, reducerType: Constructor): void {
|
|
290
|
+
private scheduleReobserve(id: string, reducerType: Constructor, dispatcher: ReducerEventDispatcher): void {
|
|
286
291
|
// A disconnect clears the registration; the reconnect re-registers every
|
|
287
292
|
// reducer from scratch, so retrying here as well would double up.
|
|
288
293
|
if (!this._registered || this._disposed) {
|
|
@@ -295,7 +300,7 @@ export class Reducers implements IReducers {
|
|
|
295
300
|
}
|
|
296
301
|
|
|
297
302
|
this._logger.info('Re-establishing reducer observation', { reducerId: id });
|
|
298
|
-
this.startObservation(id, reducerType);
|
|
303
|
+
this.startObservation(id, reducerType, dispatcher);
|
|
299
304
|
}, Reducers._reobserveDelayMs));
|
|
300
305
|
|
|
301
306
|
handle.unref?.();
|
|
@@ -353,6 +358,7 @@ export class Reducers implements IReducers {
|
|
|
353
358
|
|
|
354
359
|
try {
|
|
355
360
|
const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
|
|
361
|
+
if (reducerInstance) dispatcher.validateInstance(reducerInstance);
|
|
356
362
|
|
|
357
363
|
for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
|
|
358
364
|
let lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
|
|
@@ -376,9 +382,12 @@ export class Reducers implements IReducers {
|
|
|
376
382
|
readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
|
|
377
383
|
signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
|
|
378
384
|
replayState: operation.ReplayState
|
|
379
|
-
}, this._artifactActivator, artifact =>
|
|
380
|
-
|
|
381
|
-
|
|
385
|
+
}, this._artifactActivator, artifact => {
|
|
386
|
+
dispatcher.validateInstance(artifact.instance);
|
|
387
|
+
return runActivated(artifact, () =>
|
|
388
|
+
notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
|
|
389
|
+
{ delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
|
|
390
|
+
}, this._connection.logger);
|
|
382
391
|
} else if (reducerInstance) {
|
|
383
392
|
await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
|
|
384
393
|
}
|
|
@@ -448,7 +457,10 @@ export class Reducers implements IReducers {
|
|
|
448
457
|
readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
|
|
449
458
|
signal: controller.signal, delivery: ArtifactDelivery.Events,
|
|
450
459
|
eventContext: toClientEventContext(firstInvocableEvent.Context!)
|
|
451
|
-
}, this._artifactActivator,
|
|
460
|
+
}, this._artifactActivator, artifact => {
|
|
461
|
+
dispatcher.validateInstance(artifact.instance);
|
|
462
|
+
return processEvents(artifact);
|
|
463
|
+
}, this._connection.logger);
|
|
452
464
|
} catch (err) {
|
|
453
465
|
this._logger.error('Error activating reducer', { reducerId: id, error: err });
|
|
454
466
|
exceptionMessages.push(String(err));
|
|
@@ -0,0 +1,51 @@
|
|
|
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 } from 'vitest';
|
|
5
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
6
|
+
import { EventType } from '../../events/EventType.js';
|
|
7
|
+
import type { EventContext } from '../../events/EventContext.js';
|
|
8
|
+
import { handles } from '../index.js';
|
|
9
|
+
import { ReducerEventDispatcher } from '../ReducerEventDispatcher.js';
|
|
10
|
+
|
|
11
|
+
chai.should();
|
|
12
|
+
|
|
13
|
+
@eventType('explicit-reducer-delivery')
|
|
14
|
+
class AuthorRegistered { constructor(readonly name: string) {} }
|
|
15
|
+
|
|
16
|
+
class AuthorReducer {
|
|
17
|
+
@handles(AuthorRegistered)
|
|
18
|
+
update(event: AuthorRegistered, state: { names: string[] } | undefined, context: EventContext) {
|
|
19
|
+
return { names: [...(state?.names ?? []), event.name], id: context.eventSourceId };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class FieldReducer extends AuthorReducer {
|
|
24
|
+
override update = () => ({ names: [], id: 'hidden' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('when an instance field hides an inherited reducer handler', () => {
|
|
28
|
+
it('should reject the field against the preflight method', () => {
|
|
29
|
+
const dispatcher = new ReducerEventDispatcher(FieldReducer, [AuthorRegistered]);
|
|
30
|
+
(() => dispatcher.validateInstance(new FieldReducer())).should.throw(
|
|
31
|
+
"Override 'update' on 'FieldReducer' hides @handles(AuthorRegistered) declared on 'AuthorReducer'; use a method with @handles instead of an instance field.");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('when invoking an explicit reducer handler', () => {
|
|
36
|
+
let result: unknown;
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
const dispatcher = new ReducerEventDispatcher(AuthorReducer, [AuthorRegistered]);
|
|
39
|
+
const instance = new AuthorReducer() as unknown as Record<string, Function>;
|
|
40
|
+
const context: EventContext = {
|
|
41
|
+
sequenceNumber: 1n, eventSourceId: 'author', eventType: EventType.parse('explicit-reducer-delivery'),
|
|
42
|
+
occurred: new Date(), correlationId: '', causation: [], tags: []
|
|
43
|
+
};
|
|
44
|
+
const handler = dispatcher.handlerFor('explicit-reducer-delivery')!;
|
|
45
|
+
const initial = await dispatcher.invoke(instance, handler, new AuthorRegistered('Ada'), undefined, context);
|
|
46
|
+
result = await dispatcher.invoke(instance, handler, new AuthorRegistered('Grace'), initial, context);
|
|
47
|
+
});
|
|
48
|
+
it('should pass the event state and context to the named method', () => {
|
|
49
|
+
(result as object).should.deep.equal({ names: ['Ada', 'Grace'], id: 'author' });
|
|
50
|
+
});
|
|
51
|
+
});
|