@cratis/chronicle 6.21.0 → 6.22.1
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/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +1 -1
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/reactors/ReactorDispatcher.d.ts +20 -0
- package/dist/reactors/ReactorDispatcher.d.ts.map +1 -0
- package/dist/reactors/ReactorDispatcher.js +76 -0
- package/dist/reactors/ReactorDispatcher.js.map +1 -0
- package/dist/reactors/ReactorSideEffects.d.ts +3 -0
- package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
- package/dist/reactors/ReactorSideEffects.js +16 -11
- package/dist/reactors/ReactorSideEffects.js.map +1 -1
- package/dist/reactors/Reactors.d.ts +0 -1
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +5 -71
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts +8 -0
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +14 -1
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReactorDeliveryResult.d.ts +10 -0
- package/dist/testing/ReactorDeliveryResult.d.ts.map +1 -0
- package/dist/testing/ReactorDeliveryResult.js +4 -0
- package/dist/testing/ReactorDeliveryResult.js.map +1 -0
- package/dist/testing/ReactorScenario.d.ts +55 -0
- package/dist/testing/ReactorScenario.d.ts.map +1 -0
- package/dist/testing/ReactorScenario.js +275 -0
- package/dist/testing/ReactorScenario.js.map +1 -0
- package/dist/testing/ReactorScenarioOptions.d.ts +17 -0
- package/dist/testing/ReactorScenarioOptions.d.ts.map +1 -0
- package/dist/testing/ReactorScenarioOptions.js +4 -0
- package/dist/testing/ReactorScenarioOptions.js.map +1 -0
- package/dist/testing/RecordedReactorSideEffect.d.ts +13 -0
- package/dist/testing/RecordedReactorSideEffect.d.ts.map +1 -0
- package/dist/testing/RecordedReactorSideEffect.js +4 -0
- package/dist/testing/RecordedReactorSideEffect.js.map +1 -0
- package/dist/testing/UnsupportedReactorOperation.d.ts +7 -0
- package/dist/testing/UnsupportedReactorOperation.d.ts.map +1 -0
- package/dist/testing/UnsupportedReactorOperation.js +14 -0
- package/dist/testing/UnsupportedReactorOperation.js.map +1 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.spec.ts +37 -2
- package/eventSequences/EventSequence.ts +17 -12
- package/events/constraints/for_Constraints/when_resolving_violation_messages.spec.ts +9 -4
- package/package.json +1 -1
- package/reactors/ReactorDispatcher.ts +95 -0
- package/reactors/ReactorSideEffects.ts +21 -13
- package/reactors/Reactors.ts +11 -83
- package/testing/InProcessEventSequence.ts +16 -1
- package/testing/ReactorDeliveryResult.ts +13 -0
- package/testing/ReactorScenario.rejections.spec.ts +100 -0
- package/testing/ReactorScenario.spec.ts +611 -0
- package/testing/ReactorScenario.ts +279 -0
- package/testing/ReactorScenarioOptions.ts +20 -0
- package/testing/RecordedReactorSideEffect.ts +16 -0
- package/testing/UnsupportedReactorOperation.ts +10 -0
- package/testing/index.ts +5 -0
|
@@ -0,0 +1,279 @@
|
|
|
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 { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import type { Constructor } from '@cratis/fundamentals';
|
|
6
|
+
import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
|
|
7
|
+
import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
|
|
8
|
+
import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
|
|
9
|
+
import { ArtifactKind } from '../artifacts/ArtifactKind.js';
|
|
10
|
+
import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
|
|
11
|
+
import { withActivatedArtifact } from '../artifacts/withActivatedArtifact.js';
|
|
12
|
+
import type { AppendedEvent } from '../events/AppendedEvent.js';
|
|
13
|
+
import type { EventContext } from '../events/EventContext.js';
|
|
14
|
+
import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
15
|
+
import { getFilterTagsFor } from '../events/filterEventsByTagDecorator.js';
|
|
16
|
+
import type { IEventLog } from '../eventSequences/IEventLog.js';
|
|
17
|
+
import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
18
|
+
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
19
|
+
import { EventStoreName } from '../EventStoreName.js';
|
|
20
|
+
import { EventStoreNamespaceName } from '../EventStoreNamespaceName.js';
|
|
21
|
+
import type { IEventStore } from '../IEventStore.js';
|
|
22
|
+
import type { IReadModels } from '../readModels/IReadModels.js';
|
|
23
|
+
import { getReactorEventTypes, invokeReactorHandler, selectReactorHandler } from '../reactors/ReactorDispatcher.js';
|
|
24
|
+
import { getReactorMetadata } from '../reactors/reactor.js';
|
|
25
|
+
import { normalizeReactorSideEffects } from '../reactors/ReactorSideEffects.js';
|
|
26
|
+
import type { ReactorServices } from '../reactors/ReactorServices.js';
|
|
27
|
+
import { EventScenario } from './EventScenario.js';
|
|
28
|
+
import type { ReactorDeliveryResult } from './ReactorDeliveryResult.js';
|
|
29
|
+
import type { ReactorScenarioOptions } from './ReactorScenarioOptions.js';
|
|
30
|
+
import type { RecordedReactorSideEffect } from './RecordedReactorSideEffect.js';
|
|
31
|
+
import { observeUnsupportedOperations } from './InProcessEventSequence.js';
|
|
32
|
+
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
33
|
+
import { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
|
|
34
|
+
|
|
35
|
+
/** Live, ordered reactor deliveries over the fixture-proven EventScenario event boundary. */
|
|
36
|
+
export class ReactorScenario {
|
|
37
|
+
readonly given: { forEventSource(id: string): { events(...events: object[]): Promise<void> } };
|
|
38
|
+
readonly when: { forEventSource(id: string): { events(...events: object[]): Promise<void> } };
|
|
39
|
+
private readonly _events: EventScenario;
|
|
40
|
+
private readonly _entries: ReturnType<typeof getReactorEventTypes>;
|
|
41
|
+
private readonly _eventTypes: readonly Constructor[];
|
|
42
|
+
private readonly _instance?: Record<string, Function>;
|
|
43
|
+
private readonly _store: IEventStore;
|
|
44
|
+
private readonly _controller = new AbortController();
|
|
45
|
+
private readonly _effects: RecordedReactorSideEffect[] = [];
|
|
46
|
+
private readonly _results: ReactorDeliveryResult[] = [];
|
|
47
|
+
private _busy = false;
|
|
48
|
+
private _failed = false;
|
|
49
|
+
// Unsupported calls are attributed only to the delivery that is running when they happen.
|
|
50
|
+
private _delivery?: { violation?: Error };
|
|
51
|
+
private readonly _deliveryContext = new AsyncLocalStorage<{ violation?: Error }>();
|
|
52
|
+
private _deliveryIndex = 0;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly _reactor: Constructor, private readonly _options: ReactorScenarioOptions = {}) {
|
|
55
|
+
const metadata = getReactorMetadata(_reactor);
|
|
56
|
+
if (!metadata) throw new UnsupportedReactorOperation('reactor', _reactor.name, 'A registered @reactor is required.');
|
|
57
|
+
if ((metadata.eventSequenceId ?? 'event-log') !== 'event-log') {
|
|
58
|
+
throw new UnsupportedReactorOperation('reactor.eventSequenceId', _reactor.name, 'Only the default event log is fixture-backed.');
|
|
59
|
+
}
|
|
60
|
+
if (getFilterTagsFor(_reactor).length) {
|
|
61
|
+
throw new UnsupportedReactorOperation('reactor.filterEventsByTag', _reactor.name, 'Tag-filtered delivery is not fixture-backed.');
|
|
62
|
+
}
|
|
63
|
+
if (_options.commandTypes?.length) {
|
|
64
|
+
throw new UnsupportedReactorOperation('options.commandTypes', _reactor.name, 'Command classification belongs to the explicit-composition increment.');
|
|
65
|
+
}
|
|
66
|
+
// Resolve the selected or discovered catalog once; preserve getter-backed constraints and migrations.
|
|
67
|
+
const artifacts = _options.artifacts ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default);
|
|
68
|
+
const constraints = artifacts.constraints;
|
|
69
|
+
// EventScenario rejects an explicitly selected empty catalog, but allows empty default discovery.
|
|
70
|
+
const selectedConstraints = !_options.artifacts && constraints?.length === 0 ? undefined : constraints;
|
|
71
|
+
const eventTypeMigrations = artifacts.eventTypeMigrations;
|
|
72
|
+
this._eventTypes = Object.freeze([...artifacts.eventTypes]);
|
|
73
|
+
this._events = new EventScenario({ ..._options, artifacts: {
|
|
74
|
+
eventTypes: [...this._eventTypes],
|
|
75
|
+
constraints: selectedConstraints === undefined ? undefined : [...selectedConstraints],
|
|
76
|
+
eventTypeMigrations: eventTypeMigrations === undefined ? undefined : [...eventTypeMigrations]
|
|
77
|
+
} });
|
|
78
|
+
this._entries = getReactorEventTypes(_reactor, this._eventTypes);
|
|
79
|
+
// Every unsupported event-sequence operation is latched at its source, whichever result or stream carries it.
|
|
80
|
+
observeUnsupportedOperations(this._events.eventSequence, error => { this.latch(error); });
|
|
81
|
+
this._instance = _options.artifactActivator ? undefined : new (_reactor as new () => Record<string, Function>)();
|
|
82
|
+
this._store = _options.servicesEventStore ?? this.scenarioStore();
|
|
83
|
+
this.given = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, true) }) };
|
|
84
|
+
this.when = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, false) }) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private scenarioStore(): IEventStore {
|
|
88
|
+
const unsupported = (operation: string): never => { throw this.violation(`services.${operation}`,
|
|
89
|
+
'Provide an explicit eventStore test double for this service.'); };
|
|
90
|
+
const readModelMethods = new Set(['register', 'getInstanceById', 'findInstanceById', 'getInstances',
|
|
91
|
+
'getSnapshotsById', 'dehydrateSession', 'release', 'releaseMany']);
|
|
92
|
+
const readModels = new Proxy({}, { get: (_, key) => {
|
|
93
|
+
if (key === 'watch') return async function* () { unsupported('readModels.watch'); };
|
|
94
|
+
if (key === 'materialized') return unsupported('readModels.materialized');
|
|
95
|
+
if (typeof key !== 'string' || !readModelMethods.has(key)) return undefined;
|
|
96
|
+
return async () => unsupported(`readModels.${key}`);
|
|
97
|
+
} }) as IReadModels;
|
|
98
|
+
const eventLog = this._events.eventLog;
|
|
99
|
+
const rejectSubscribed = (events: readonly object[]) => {
|
|
100
|
+
if (events.some(event => {
|
|
101
|
+
const id = getEventTypeMetadata(event.constructor)?.eventType.id.value;
|
|
102
|
+
return this._entries.some(entry => entry.id === id);
|
|
103
|
+
})) {
|
|
104
|
+
throw this.violation('services.eventLog.append.subscribed',
|
|
105
|
+
'Delivery of a reactor\'s own appended events is not supported.');
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const guardedLog = new Proxy(eventLog, { get: (target, key) => {
|
|
109
|
+
if (key === 'append') return async (...args: Parameters<IEventLog['append']>) => {
|
|
110
|
+
rejectSubscribed([args[1]]);
|
|
111
|
+
return target.append(...args);
|
|
112
|
+
};
|
|
113
|
+
if (key === 'appendMany') return async (sourceOrEvents: string | EventForEventSourceId[],
|
|
114
|
+
eventsOrOptions?: object[] | AppendOptions, options?: AppendOptions) => {
|
|
115
|
+
if (typeof sourceOrEvents === 'string') {
|
|
116
|
+
rejectSubscribed(eventsOrOptions as object[]);
|
|
117
|
+
return target.appendMany(sourceOrEvents, eventsOrOptions as object[], options);
|
|
118
|
+
}
|
|
119
|
+
rejectSubscribed(sourceOrEvents.map(entry => entry.event));
|
|
120
|
+
return target.appendMany(sourceOrEvents, eventsOrOptions as AppendOptions | undefined);
|
|
121
|
+
};
|
|
122
|
+
const value = Reflect.get(target, key, target) as unknown;
|
|
123
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
124
|
+
} }) as IEventLog;
|
|
125
|
+
const store = { name: new EventStoreName(this._options.eventStore ?? 'test-event-store'),
|
|
126
|
+
namespace: new EventStoreNamespaceName(this._options.namespace ?? 'default'), eventLog: guardedLog, readModels };
|
|
127
|
+
const unsupportedStoreProperties = new Set(['eventTypes', 'constraints', 'projections', 'reactors', 'reducers',
|
|
128
|
+
'unitOfWorkManager', 'jobs', 'webhooks', 'subscriptions', 'seeding', 'externalServices', 'identities',
|
|
129
|
+
'pii', 'failedPartitions', 'observers']);
|
|
130
|
+
return new Proxy(store, { get: (target, key) => {
|
|
131
|
+
if (typeof key !== 'string') return undefined;
|
|
132
|
+
if (Object.hasOwn(target, key)) return target[key as keyof typeof target];
|
|
133
|
+
if (key === 'getNamespaces') return async () => unsupported('eventStore.getNamespaces');
|
|
134
|
+
if (key === 'getEventSequence') return () => unsupported('eventStore.getEventSequence');
|
|
135
|
+
if (unsupportedStoreProperties.has(key)) return unsupported(`eventStore.${key}`);
|
|
136
|
+
return undefined;
|
|
137
|
+
} }) as IEventStore;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get produced(): readonly unknown[] { return Object.freeze(this._effects.map(effect => effect.value)); }
|
|
141
|
+
get sideEffects(): readonly RecordedReactorSideEffect[] { return Object.freeze([...this._effects]); }
|
|
142
|
+
get results(): readonly ReactorDeliveryResult[] { return Object.freeze([...this._results]); }
|
|
143
|
+
/** Assertion view, deliberately not a callable Promise-like `then`. */
|
|
144
|
+
get then(): { readonly produced: readonly unknown[]; readonly sideEffects: readonly RecordedReactorSideEffect[];
|
|
145
|
+
readonly results: readonly ReactorDeliveryResult[] } {
|
|
146
|
+
return { produced: this.produced, sideEffects: this.sideEffects, results: this.results };
|
|
147
|
+
}
|
|
148
|
+
shouldHaveProduced<T>(type: new (...args: never[]) => T, predicate: (value: T) => boolean = () => true): void {
|
|
149
|
+
if (!this.produced.some(value => value instanceof type && predicate(value))) {
|
|
150
|
+
throw new Error(`Expected reactor '${this._reactor.name}' to produce a matching '${type.name}'.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
shouldNotHaveProduced(type: Function): void {
|
|
154
|
+
if (this.produced.some(value => value instanceof type)) {
|
|
155
|
+
throw new Error(`Expected reactor '${this._reactor.name}' to produce no '${type.name}'.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Replay, redelivery and scheduler semantics need a separate kernel-backed increment. */
|
|
160
|
+
async replay(): Promise<never> {
|
|
161
|
+
throw new UnsupportedReactorOperation('replay', this._reactor.name, 'Replay scheduling and observation state are not supported.');
|
|
162
|
+
}
|
|
163
|
+
async redeliver(): Promise<never> {
|
|
164
|
+
throw new UnsupportedReactorOperation('redeliver', this._reactor.name, 'Retry and checkpoint semantics are not supported.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async deliver(sourceId: string, input: object[], setup: boolean): Promise<void> {
|
|
168
|
+
if (this._failed) throw new UnsupportedReactorOperation('delivery.afterFailure', this._reactor.name,
|
|
169
|
+
'A previous delivery failed; failed-partition retries are not supported.');
|
|
170
|
+
if (this._busy) throw new UnsupportedReactorOperation('delivery.overlap', this._reactor.name, 'Concurrent deliveries have unproven ordering.');
|
|
171
|
+
this._busy = true;
|
|
172
|
+
try {
|
|
173
|
+
if (!setup && !input.length) {
|
|
174
|
+
throw new UnsupportedReactorOperation('when.events', this._reactor.name, 'Empty action batches are not fixture-backed.');
|
|
175
|
+
}
|
|
176
|
+
const before = this._events.appendedEvents.length;
|
|
177
|
+
if (setup) {
|
|
178
|
+
await this._events.given.forEventSource(sourceId).events(...input);
|
|
179
|
+
} else {
|
|
180
|
+
const results = await this._events.when.forEventSource(sourceId).events(...input);
|
|
181
|
+
if (results.some(result => !result.isSuccess)) throw new Error('ReactorScenario action append failed.');
|
|
182
|
+
}
|
|
183
|
+
const events = this._events.appendedEvents.slice(before);
|
|
184
|
+
if (events.length) await this.process(sourceId, events);
|
|
185
|
+
} finally {
|
|
186
|
+
this._busy = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private violation(operation: string, reason: string): UnsupportedReactorOperation {
|
|
191
|
+
return this.latch(new UnsupportedReactorOperation(operation, this._reactor.name, reason)) as UnsupportedReactorOperation;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private latch(error: unknown): unknown {
|
|
195
|
+
// Attribute the call to the delivery whose async context made it, and only while that delivery is running.
|
|
196
|
+
const delivery = this._deliveryContext.getStore();
|
|
197
|
+
if (delivery && delivery === this._delivery &&
|
|
198
|
+
(error instanceof UnsupportedReactorOperation || error instanceof UnsupportedEventSequenceOperation)) {
|
|
199
|
+
delivery.violation ??= error;
|
|
200
|
+
}
|
|
201
|
+
return error;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private throwIfViolated(delivery: { violation?: Error }): void {
|
|
205
|
+
if (delivery.violation) throw delivery.violation;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async process(sourceId: string, events: readonly AppendedEvent[]): Promise<void> {
|
|
209
|
+
const delivery: { violation?: Error } = {};
|
|
210
|
+
this._delivery = delivery;
|
|
211
|
+
const deliveryIndex = this._deliveryIndex++;
|
|
212
|
+
const handled: EventContext[] = [];
|
|
213
|
+
const skipped: EventContext[] = [];
|
|
214
|
+
const outcome: { sourceId: string; handled: EventContext[]; skipped: EventContext[]; completed: boolean; error?: unknown } =
|
|
215
|
+
{ sourceId, handled, skipped, completed: false };
|
|
216
|
+
const services: ReactorServices = { eventStore: this._store, readModels: this._store.readModels, signal: this._controller.signal };
|
|
217
|
+
const first = events.find(event => !!selectReactorHandler(this._entries, this._reactor, this._instance,
|
|
218
|
+
event.eventType.id.value, event.context.observationState ?? 0)?.methodName);
|
|
219
|
+
const process = async (artifact: ActivatedArtifact<Record<string, Function>>) => {
|
|
220
|
+
for (const event of events) {
|
|
221
|
+
// EventScenario's serialized history is the committed boundary, not the original input instance.
|
|
222
|
+
if (event.context.observationState !== 1) {
|
|
223
|
+
throw new UnsupportedReactorOperation('delivery.observationState', this._reactor.name,
|
|
224
|
+
'Only initial live delivery is fixture-backed.');
|
|
225
|
+
}
|
|
226
|
+
const selection = selectReactorHandler(this._entries, this._reactor, artifact.instance,
|
|
227
|
+
event.eventType.id.value, event.context.observationState);
|
|
228
|
+
if (!selection?.methodName || selection.skipReplay) { skipped.push(event.context); continue; }
|
|
229
|
+
await invokeReactorHandler(artifact, selection.methodName, event.content, event.context, services, result => {
|
|
230
|
+
// A swallowed unsupported call fails this event like a throwing handler: nothing is recorded.
|
|
231
|
+
this.throwIfViolated(delivery);
|
|
232
|
+
return this.record(result, event.context, selection.methodName!, deliveryIndex);
|
|
233
|
+
});
|
|
234
|
+
this.throwIfViolated(delivery);
|
|
235
|
+
handled.push(event.context);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
try {
|
|
239
|
+
await this._deliveryContext.run(delivery, async () => {
|
|
240
|
+
if (first && this._options.artifactActivator) {
|
|
241
|
+
await withActivatedArtifact(this._reactor, { kind: ArtifactKind.Reactor,
|
|
242
|
+
artifactId: getReactorMetadata(this._reactor)!.id.value, eventStore: this._store,
|
|
243
|
+
readModels: services.readModels, eventSequenceId: 'event-log', partition: sourceId,
|
|
244
|
+
signal: services.signal, delivery: ArtifactDelivery.Events, eventContext: first.context },
|
|
245
|
+
this._options.artifactActivator, process);
|
|
246
|
+
} else {
|
|
247
|
+
await process({ instance: this._instance ?? {} });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
// A handler that catches an unsupported-service rejection must not turn it into a successful delivery.
|
|
251
|
+
this.throwIfViolated(delivery);
|
|
252
|
+
outcome.completed = true;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
outcome.error = error;
|
|
255
|
+
this._failed = true;
|
|
256
|
+
throw error;
|
|
257
|
+
} finally {
|
|
258
|
+
if (this._delivery === delivery) this._delivery = undefined;
|
|
259
|
+
this._results.push(Object.freeze({ ...outcome, handled: Object.freeze([...handled]), skipped: Object.freeze([...skipped]) }));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async record(result: unknown, context: EventContext, handler: string, deliveryIndex: number): Promise<void> {
|
|
264
|
+
if (await this._options.resultHandler?.(result, context, this._reactor,
|
|
265
|
+
this._options.eventStore ?? 'test-event-store', this._options.namespace ?? 'default')) return;
|
|
266
|
+
if (result == null) return;
|
|
267
|
+
const items = Array.isArray(result) ? result : [result];
|
|
268
|
+
const events = normalizeReactorSideEffects(result, context.eventSourceId,
|
|
269
|
+
context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId);
|
|
270
|
+
if (items.length !== events.length || events.some(entry => !this._eventTypes.includes(entry.event.constructor as Constructor))) {
|
|
271
|
+
throw new UnsupportedReactorOperation('handler.return', this._reactor.name,
|
|
272
|
+
'Only registered events and production EventForEventSourceId wrappers may be recorded; commands and unknown values are unsupported.');
|
|
273
|
+
}
|
|
274
|
+
for (const target of events) {
|
|
275
|
+
this._effects.push(Object.freeze({ kind: 'event', value: target.event, target, triggeringContext: context,
|
|
276
|
+
handler, deliveryIndex }));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 type { Constructor } from '@cratis/fundamentals';
|
|
5
|
+
import type { ClientArtifactsActivator } from '../artifacts/ClientArtifactsActivator.js';
|
|
6
|
+
import type { IEventStore } from '../IEventStore.js';
|
|
7
|
+
import type { ReactorResultHandler } from '../reactors/ReactorResultHandler.js';
|
|
8
|
+
import type { EventScenarioOptions } from './EventScenarioOptions.js';
|
|
9
|
+
|
|
10
|
+
/** Reactor delivery over a fixture-bounded EventScenario catalog. */
|
|
11
|
+
export interface ReactorScenarioOptions extends EventScenarioOptions {
|
|
12
|
+
/** Production activation hook, including constructor injection and per-delivery completion. */
|
|
13
|
+
artifactActivator?: ClientArtifactsActivator;
|
|
14
|
+
/** Optional explicit test double; without one only the event log is available through services. */
|
|
15
|
+
servicesEventStore?: IEventStore;
|
|
16
|
+
/** Application result hook. A true result claims the entire return value; false uses event recording. */
|
|
17
|
+
resultHandler?: ReactorResultHandler;
|
|
18
|
+
/** Reserved for a later increment; commands cannot be classified in basic reactor scenarios. */
|
|
19
|
+
commandTypes?: Constructor[];
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 type { EventContext } from '../events/EventContext.js';
|
|
5
|
+
import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
6
|
+
|
|
7
|
+
/** A returned event recorded without appending, executing or recursively delivering it. */
|
|
8
|
+
export interface RecordedReactorSideEffect {
|
|
9
|
+
readonly kind: 'event';
|
|
10
|
+
readonly value: object;
|
|
11
|
+
readonly target: EventForEventSourceId;
|
|
12
|
+
readonly triggeringContext: EventContext;
|
|
13
|
+
readonly handler: string;
|
|
14
|
+
/** Zero-based index of the source-partition delivery, shared by effects from that batch. */
|
|
15
|
+
readonly deliveryIndex: number;
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
/** An operation whose kernel/observer semantics have not been established for this scenario. */
|
|
5
|
+
export class UnsupportedReactorOperation extends Error {
|
|
6
|
+
constructor(readonly operation: string, readonly artifact: string, reason: string) {
|
|
7
|
+
super(`Unsupported reactor operation '${operation}' for '${artifact}': ${reason} Use a kernel-backed test.`);
|
|
8
|
+
this.name = 'UnsupportedReactorOperation';
|
|
9
|
+
}
|
|
10
|
+
}
|
package/testing/index.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
4
|
export { EventScenario } from './EventScenario.js';
|
|
5
|
+
export { ReactorScenario } from './ReactorScenario.js';
|
|
6
|
+
export type { ReactorScenarioOptions } from './ReactorScenarioOptions.js';
|
|
7
|
+
export type { ReactorDeliveryResult } from './ReactorDeliveryResult.js';
|
|
8
|
+
export type { RecordedReactorSideEffect } from './RecordedReactorSideEffect.js';
|
|
9
|
+
export { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
|
|
5
10
|
export type { EventScenarioOptions } from './EventScenarioOptions.js';
|
|
6
11
|
export { EventScenarioGivenBuilder } from './EventScenarioGivenBuilder.js';
|
|
7
12
|
export { EventScenarioGiven } from './EventScenarioGiven.js';
|