@cratis/chronicle 6.20.0 → 6.22.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/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +6 -100
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/createAppendNotification.d.ts +15 -0
- package/dist/eventSequences/createAppendNotification.d.ts.map +1 -0
- package/dist/eventSequences/createAppendNotification.js +25 -0
- package/dist/eventSequences/createAppendNotification.js.map +1 -0
- package/dist/eventSequences/prepareBatchAppend.d.ts +31 -0
- package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -0
- package/dist/eventSequences/prepareBatchAppend.js +54 -0
- package/dist/eventSequences/prepareBatchAppend.js.map +1 -0
- 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/EventScenario.d.ts +1 -1
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +7 -4
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/EventScenarioGiven.d.ts +1 -1
- package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
- package/dist/testing/EventScenarioGiven.js +2 -5
- package/dist/testing/EventScenarioGiven.js.map +1 -1
- package/dist/testing/EventScenarioSeed.d.ts +2 -2
- package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
- package/dist/testing/EventScenarioSeed.js +2 -2
- package/dist/testing/EventScenarioSeed.js.map +1 -1
- package/dist/testing/EventScenarioWhen.d.ts +1 -1
- package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
- package/dist/testing/EventScenarioWhen.js +2 -3
- package/dist/testing/EventScenarioWhen.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts +26 -7
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +253 -43
- 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.ts +10 -107
- package/eventSequences/createAppendNotification.ts +35 -0
- package/eventSequences/prepareBatchAppend.ts +63 -0
- 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/EventScenario.batches.spec.ts +537 -0
- package/testing/EventScenario.fixtures.spec.ts +2 -13
- package/testing/EventScenario.rejections.spec.ts +5 -39
- package/testing/EventScenario.ts +7 -5
- package/testing/EventScenarioGiven.ts +2 -5
- package/testing/EventScenarioSeed.ts +4 -4
- package/testing/EventScenarioWhen.ts +2 -4
- package/testing/InProcessEventSequence.ts +239 -39
- 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,611 @@
|
|
|
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 { field } from '@cratis/fundamentals';
|
|
5
|
+
import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
|
|
6
|
+
import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
|
|
7
|
+
import { DecoratorType } from '../types/DecoratorType.js';
|
|
8
|
+
import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
|
|
9
|
+
import type { IEventStore } from '../IEventStore.js';
|
|
10
|
+
import { eventType } from '../events/eventTypeDecorator.js';
|
|
11
|
+
import { filterEventsByTag } from '../events/filterEventsByTagDecorator.js';
|
|
12
|
+
import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
|
|
13
|
+
import type { IEventLog } from '../eventSequences/IEventLog.js';
|
|
14
|
+
import type { EventContext } from '../events/EventContext.js';
|
|
15
|
+
import { reactor } from '../reactors/reactor.js';
|
|
16
|
+
import type { ReactorServices } from '../reactors/ReactorServices.js';
|
|
17
|
+
import { chai, describe, it, vi } from 'vitest';
|
|
18
|
+
import fixture from './fixtures/builders.json' with { type: 'json' };
|
|
19
|
+
import { ReactorScenario } from './ReactorScenario.js';
|
|
20
|
+
import { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
|
|
21
|
+
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
22
|
+
import type { ReactorScenarioOptions } from './ReactorScenarioOptions.js';
|
|
23
|
+
|
|
24
|
+
chai.should();
|
|
25
|
+
|
|
26
|
+
@eventType('OracleEventRecorded')
|
|
27
|
+
class Registered {
|
|
28
|
+
@field(String) name: string;
|
|
29
|
+
@field(Boolean) active: boolean;
|
|
30
|
+
constructor(name: string, active = true) { this.name = name; this.active = active; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@eventType('AlternateRecorded')
|
|
34
|
+
class Skipped {
|
|
35
|
+
@field(String) label: string;
|
|
36
|
+
constructor(label: string) { this.label = label; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const calls: { event: Registered; context: EventContext; services: ReactorServices }[] = [];
|
|
40
|
+
@reactor('scenario-reactor')
|
|
41
|
+
class ScenarioReactor {
|
|
42
|
+
registered(event: Registered, context: EventContext, services: ReactorServices) {
|
|
43
|
+
calls.push({ event, context, services });
|
|
44
|
+
return { eventSourceId: 'other', event: new Registered(event.name, false), eventStreamType: 'All', eventStreamId: 'Default' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const options: ReactorScenarioOptions = { artifacts: { eventTypes: [Registered, Skipped] }, constraints: 'disabled',
|
|
49
|
+
clock: () => new Date('2025-01-02T03:04:05.000Z'), correlationId: () => '11111111-2222-3333-4444-555555555555' };
|
|
50
|
+
|
|
51
|
+
describe('ReactorScenario live delivery', () => {
|
|
52
|
+
it('rejects an empty reactor event sequence ID before activation', () => {
|
|
53
|
+
let created = false;
|
|
54
|
+
@reactor('empty-sequence-reactor', '')
|
|
55
|
+
class EmptySequence {
|
|
56
|
+
constructor() { created = true; }
|
|
57
|
+
registered() {}
|
|
58
|
+
}
|
|
59
|
+
(() => new ReactorScenario(EmptySequence, options)).should.throw(UnsupportedReactorOperation, 'reactor.eventSequenceId');
|
|
60
|
+
created.should.be.false;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('preserves getter-backed constraints and migrations from a selected catalog', () => {
|
|
64
|
+
let created = false;
|
|
65
|
+
@reactor('catalog-rejection-reactor')
|
|
66
|
+
class CatalogRejection {
|
|
67
|
+
constructor() { created = true; }
|
|
68
|
+
registered() {}
|
|
69
|
+
}
|
|
70
|
+
class SelectedConstraint {}
|
|
71
|
+
class SelectedMigration {}
|
|
72
|
+
const constrained = { eventTypes: [Registered], get constraints() { return [SelectedConstraint]; } };
|
|
73
|
+
(() => new ReactorScenario(CatalogRejection, { artifacts: constrained })).should.throw(
|
|
74
|
+
UnsupportedEventSequenceOperation, 'artifacts.constraints');
|
|
75
|
+
const migrated = { eventTypes: [Registered], get eventTypeMigrations() { return [SelectedMigration]; } };
|
|
76
|
+
(() => new ReactorScenario(CatalogRejection, { artifacts: migrated, constraints: 'disabled' })).should.throw(
|
|
77
|
+
UnsupportedEventSequenceOperation, 'artifacts.eventTypeMigrations');
|
|
78
|
+
created.should.be.false;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('allows empty default constraint discovery without disabling constraints', async () => {
|
|
82
|
+
const discover = vi.spyOn(TypeDiscoverer.default, 'getTypesByDecoratorType').mockImplementation(kind =>
|
|
83
|
+
kind === DecoratorType.EventType ? [Registered] : []);
|
|
84
|
+
try {
|
|
85
|
+
const scenario = new ReactorScenario(ScenarioReactor);
|
|
86
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
87
|
+
scenario.results[0].completed.should.be.true;
|
|
88
|
+
} finally {
|
|
89
|
+
discover.mockRestore();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects discovered constraint definitions', () => {
|
|
94
|
+
class DiscoveredConstraint {}
|
|
95
|
+
const discover = vi.spyOn(TypeDiscoverer.default, 'getTypesByDecoratorType').mockImplementation(kind => {
|
|
96
|
+
if (kind === DecoratorType.EventType) return [Registered];
|
|
97
|
+
if (kind === DecoratorType.Constraint) return [DiscoveredConstraint];
|
|
98
|
+
return [];
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
(() => new ReactorScenario(ScenarioReactor)).should.throw(
|
|
102
|
+
UnsupportedEventSequenceOperation, 'artifacts.constraints');
|
|
103
|
+
} finally {
|
|
104
|
+
discover.mockRestore();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects discovered migrations even when constraints are explicitly disabled', () => {
|
|
109
|
+
class DiscoveredMigration {}
|
|
110
|
+
const discover = vi.spyOn(TypeDiscoverer.default, 'getTypesByDecoratorType').mockImplementation(kind => {
|
|
111
|
+
if (kind === DecoratorType.EventType) return [Registered];
|
|
112
|
+
if (kind === DecoratorType.EventTypeMigration) return [DiscoveredMigration];
|
|
113
|
+
return [];
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
(() => new ReactorScenario(ScenarioReactor, { constraints: 'disabled' })).should.throw(
|
|
117
|
+
UnsupportedEventSequenceOperation, 'artifacts.eventTypeMigrations');
|
|
118
|
+
} finally {
|
|
119
|
+
discover.mockRestore();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects tag-filtered reactors before activation', () => {
|
|
124
|
+
let created = false;
|
|
125
|
+
@reactor('filtered-scenario-reactor')
|
|
126
|
+
@filterEventsByTag('vip')
|
|
127
|
+
class Filtered {
|
|
128
|
+
constructor() { created = true; }
|
|
129
|
+
registered() {}
|
|
130
|
+
}
|
|
131
|
+
(() => new ReactorScenario(Filtered, options)).should.throw(UnsupportedReactorOperation, 'reactor.filterEventsByTag');
|
|
132
|
+
created.should.be.false;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('keeps one event-type catalog after the caller changes its array', async () => {
|
|
136
|
+
const eventTypes = [Registered, Skipped];
|
|
137
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options, artifacts: { eventTypes } });
|
|
138
|
+
eventTypes.length = 0;
|
|
139
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
140
|
+
scenario.shouldHaveProduced(Registered, event => event.name === 'first');
|
|
141
|
+
eventTypes.push(Registered);
|
|
142
|
+
scenario.results[0].completed.should.be.true;
|
|
143
|
+
@eventType('NotSelectedRecorded')
|
|
144
|
+
class NotSelected {
|
|
145
|
+
@field(String) label: string;
|
|
146
|
+
constructor(label: string) { this.label = label; }
|
|
147
|
+
}
|
|
148
|
+
eventTypes.push(NotSelected);
|
|
149
|
+
await scenario.when.forEventSource('B').events(new NotSelected('unexpected')).then(() => {
|
|
150
|
+
throw new Error('expected rejection');
|
|
151
|
+
}, error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects unsupported promise-returning services asynchronously and preserves property probes', async () => {
|
|
155
|
+
const errors: unknown[] = [];
|
|
156
|
+
@reactor('service-rejection-reactor')
|
|
157
|
+
class ServiceRejection {
|
|
158
|
+
async registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
159
|
+
(Reflect.get(services.readModels, 'then') === undefined).should.be.true;
|
|
160
|
+
(Reflect.get(services.readModels, Symbol.toStringTag) === undefined).should.be.true;
|
|
161
|
+
(Reflect.get(services.eventStore, 'then') === undefined).should.be.true;
|
|
162
|
+
(Reflect.get(services.eventStore, Symbol.toStringTag) === undefined).should.be.true;
|
|
163
|
+
const failures = [
|
|
164
|
+
services.readModels.register(),
|
|
165
|
+
services.readModels.getInstanceById(Registered, 'A'),
|
|
166
|
+
services.readModels.findInstanceById(Registered, 'A'),
|
|
167
|
+
services.readModels.getInstances(Registered),
|
|
168
|
+
services.readModels.getSnapshotsById(Registered, 'A'),
|
|
169
|
+
services.readModels.dehydrateSession('session', Registered, 'A'),
|
|
170
|
+
services.readModels.release(Registered, new Registered('one')),
|
|
171
|
+
services.readModels.releaseMany(Registered, [new Registered('one')]),
|
|
172
|
+
services.eventStore.getNamespaces()
|
|
173
|
+
];
|
|
174
|
+
await Promise.all(failures.map(failure => failure.catch(error => { errors.push(error); })));
|
|
175
|
+
try {
|
|
176
|
+
for await (const _change of services.readModels.watch(Registered)) { /* Unsupported. */ }
|
|
177
|
+
} catch (error) { errors.push(error); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const scenario = new ReactorScenario(ServiceRejection, options);
|
|
181
|
+
let failure: unknown;
|
|
182
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).catch(error => { failure = error; });
|
|
183
|
+
errors.length.should.equal(10);
|
|
184
|
+
errors.every(error => error instanceof UnsupportedReactorOperation && String(error).includes('Use a kernel-backed test.')).should.be.true;
|
|
185
|
+
// Swallowed rejections still fail the delivery with the first unsupported operation.
|
|
186
|
+
(failure === errors[0]).should.be.true;
|
|
187
|
+
scenario.results[0].completed.should.be.false;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('stops a multi-event delivery at the event whose handler swallowed an unsupported call', async () => {
|
|
191
|
+
const invoked: string[] = [];
|
|
192
|
+
@reactor('swallowed-in-batch-reactor')
|
|
193
|
+
class SwallowedInBatch {
|
|
194
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
195
|
+
invoked.push(event.name);
|
|
196
|
+
await services.readModels.getInstances(Registered).catch(() => undefined);
|
|
197
|
+
return new Skipped(`${event.name}-out`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const scenario = new ReactorScenario(SwallowedInBatch, options);
|
|
201
|
+
let failure: unknown;
|
|
202
|
+
await scenario.when.forEventSource('A').events(new Registered('first'), new Registered('second'))
|
|
203
|
+
.catch(error => { failure = error; });
|
|
204
|
+
(failure instanceof UnsupportedReactorOperation).should.be.true;
|
|
205
|
+
invoked.should.deep.equal(['first']);
|
|
206
|
+
scenario.results[0].handled.length.should.equal(0);
|
|
207
|
+
scenario.produced.length.should.equal(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('fails the delivery when the handler catches an unsupported event-log operation', async () => {
|
|
211
|
+
@reactor('swallowed-event-log-reactor')
|
|
212
|
+
class SwallowedEventLog {
|
|
213
|
+
async registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
214
|
+
await services.eventStore.eventLog.completeStream('Orders', 'A').catch(() => undefined);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const scenario = new ReactorScenario(SwallowedEventLog, options);
|
|
218
|
+
let failure: unknown;
|
|
219
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).catch(error => { failure = error; });
|
|
220
|
+
(failure instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
221
|
+
scenario.results[0].completed.should.be.false;
|
|
222
|
+
await scenario.when.forEventSource('A').events(new Registered('second')).then(() => {
|
|
223
|
+
throw new Error('expected rejection');
|
|
224
|
+
}, error => { String(error).should.contain('delivery.afterFailure'); });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('fails the delivery when the handler catches an unsupported append', async () => {
|
|
228
|
+
@reactor('swallowed-append-reactor')
|
|
229
|
+
class SwallowedAppend {
|
|
230
|
+
async registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
231
|
+
await services.eventStore.eventLog.append('A', new Skipped('x'), { subject: 'x' }).catch(() => undefined);
|
|
232
|
+
await services.eventStore.eventLog.appendMany('A', []).catch(() => undefined);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const scenario = new ReactorScenario(SwallowedAppend, options);
|
|
236
|
+
let failure: unknown;
|
|
237
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).catch(error => { failure = error; });
|
|
238
|
+
(failure instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
239
|
+
scenario.results[0].completed.should.be.false;
|
|
240
|
+
await scenario.when.forEventSource('A').events(new Registered('second')).then(() => {
|
|
241
|
+
throw new Error('expected rejection');
|
|
242
|
+
}, error => { String(error).should.contain('delivery.afterFailure'); });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('stops the delivery when the handler swallows an unsupported waitForCompletion', async () => {
|
|
246
|
+
const invoked: string[] = [];
|
|
247
|
+
@reactor('swallowed-wait-reactor')
|
|
248
|
+
class SwallowedWait {
|
|
249
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
250
|
+
invoked.push(event.name);
|
|
251
|
+
const result = await services.eventStore.eventLog.append('A', new Skipped(event.name));
|
|
252
|
+
await result.waitForCompletion().catch(() => undefined);
|
|
253
|
+
return new Skipped(`${event.name}-out`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const scenario = new ReactorScenario(SwallowedWait, options);
|
|
257
|
+
let failure: unknown;
|
|
258
|
+
await scenario.when.forEventSource('A').events(new Registered('first'), new Registered('second'))
|
|
259
|
+
.catch(error => { failure = error; });
|
|
260
|
+
(failure instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
261
|
+
invoked.should.deep.equal(['first']);
|
|
262
|
+
scenario.produced.length.should.equal(0);
|
|
263
|
+
scenario.results[0].completed.should.be.false;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
for (const form of ['source', 'mixed'] as const) {
|
|
267
|
+
it(`stops the delivery when the handler swallows waitForCompletion on a ${form} appendMany result`, async () => {
|
|
268
|
+
const invoked: string[] = [];
|
|
269
|
+
@reactor(`swallowed-many-wait-${form}-reactor`)
|
|
270
|
+
class SwallowedManyWait {
|
|
271
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
272
|
+
invoked.push(event.name);
|
|
273
|
+
const log = services.eventStore.eventLog;
|
|
274
|
+
const results = form === 'source'
|
|
275
|
+
? await log.appendMany('A', [new Skipped(event.name)])
|
|
276
|
+
: await log.appendMany([{ eventSourceId: 'A', event: new Skipped(event.name) }]);
|
|
277
|
+
await results[0].waitForCompletion().catch(() => undefined);
|
|
278
|
+
return new Skipped(`${event.name}-out`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const scenario = new ReactorScenario(SwallowedManyWait, options);
|
|
282
|
+
let failure: unknown;
|
|
283
|
+
await scenario.when.forEventSource('A').events(new Registered('first'), new Registered('second'))
|
|
284
|
+
.catch(error => { failure = error; });
|
|
285
|
+
(failure instanceof UnsupportedEventSequenceOperation).should.be.true;
|
|
286
|
+
invoked.should.deep.equal(['first']);
|
|
287
|
+
scenario.produced.length.should.equal(0);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
it('delivers an append notification to an iterator created before the append', async () => {
|
|
292
|
+
let notified: unknown;
|
|
293
|
+
@reactor('append-operations-reactor')
|
|
294
|
+
class AppendOperations {
|
|
295
|
+
async registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
296
|
+
const iterator = services.eventStore.eventLog.appendOperations[Symbol.asyncIterator]();
|
|
297
|
+
await services.eventStore.eventLog.append('A', new Skipped('observed'));
|
|
298
|
+
notified = (await iterator.next()).value;
|
|
299
|
+
await iterator.return?.();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const scenario = new ReactorScenario(AppendOperations, options);
|
|
303
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
304
|
+
(notified as unknown[]).length.should.equal(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('does not fail a running delivery for leftover work from an earlier delivery', async () => {
|
|
308
|
+
let release!: () => void;
|
|
309
|
+
const leftover = new Promise<void>(resolve => { release = resolve; });
|
|
310
|
+
let straggler: Promise<unknown> | undefined;
|
|
311
|
+
@reactor('leftover-work-reactor')
|
|
312
|
+
class LeftoverWork {
|
|
313
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
314
|
+
if (event.name === 'first') {
|
|
315
|
+
straggler = leftover.then(() => services.readModels.getInstances(Registered)).catch(() => undefined);
|
|
316
|
+
} else {
|
|
317
|
+
release();
|
|
318
|
+
await straggler;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const scenario = new ReactorScenario(LeftoverWork, options);
|
|
323
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
324
|
+
await scenario.when.forEventSource('A').events(new Registered('second'));
|
|
325
|
+
scenario.results.map(result => result.completed).should.deep.equal([true, true]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('does not attribute an unsupported call made after its delivery finished to a later delivery', async () => {
|
|
329
|
+
let services!: ReactorServices;
|
|
330
|
+
@reactor('detached-call-reactor')
|
|
331
|
+
class DetachedCall {
|
|
332
|
+
registered(_event: Registered, _context: EventContext, reactorServices: ReactorServices) { services = reactorServices; }
|
|
333
|
+
}
|
|
334
|
+
const scenario = new ReactorScenario(DetachedCall, options);
|
|
335
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
336
|
+
await services.readModels.getInstances(Registered).catch(() => undefined);
|
|
337
|
+
await scenario.when.forEventSource('A').events(new Registered('second'));
|
|
338
|
+
scenario.results.map(result => result.completed).should.deep.equal([true, true]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('fails the delivery when the handler catches a subscribed self-append rejection', async () => {
|
|
342
|
+
let log!: IEventLog;
|
|
343
|
+
@reactor('swallowed-self-append-reactor')
|
|
344
|
+
class SwallowedSelfAppend {
|
|
345
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
346
|
+
log = services.eventStore.eventLog;
|
|
347
|
+
try { await log.append('A', new Registered(event.name + '-follow')); } catch { /* Swallowed on purpose. */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const scenario = new ReactorScenario(SwallowedSelfAppend, options);
|
|
351
|
+
let failure: unknown;
|
|
352
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).catch(error => { failure = error; });
|
|
353
|
+
(failure instanceof UnsupportedReactorOperation).should.be.true;
|
|
354
|
+
String(failure).should.contain('services.eventLog.append.subscribed');
|
|
355
|
+
scenario.results[0].completed.should.be.false;
|
|
356
|
+
(await log.getNextSequenceNumber()).value.should.equal(1n);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('does not create unhandled rejections for JSON serialization probes of services', async () => {
|
|
360
|
+
@reactor('serialized-services-reactor')
|
|
361
|
+
class SerializedServices {
|
|
362
|
+
registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
363
|
+
JSON.stringify(services, (_key, value: unknown) => typeof value === 'bigint' ? value.toString() : value);
|
|
364
|
+
(Reflect.get(services.readModels, 'toJSON') === undefined).should.be.true;
|
|
365
|
+
(Reflect.get(services.eventStore, 'toJSON') === undefined).should.be.true;
|
|
366
|
+
(Reflect.get(services.eventStore, 'notAStoreMember') === undefined).should.be.true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const scenario = new ReactorScenario(SerializedServices, options);
|
|
370
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
371
|
+
scenario.results[0].completed.should.be.true;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('passes an explicit services event-store double to the handler', async () => {
|
|
375
|
+
const readModels = {} as ReactorServices['readModels'];
|
|
376
|
+
const store = { readModels } as IEventStore;
|
|
377
|
+
@reactor('explicit-store-reactor')
|
|
378
|
+
class ExplicitStore {
|
|
379
|
+
registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
380
|
+
services.eventStore.should.equal(store);
|
|
381
|
+
services.readModels.should.equal(readModels);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const scenario = new ReactorScenario(ExplicitStore, { ...options, servicesEventStore: store });
|
|
385
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
386
|
+
scenario.results[0].completed.should.be.true;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('provides real event-store identifiers with string conversion', async () => {
|
|
390
|
+
const identifiers: string[] = [];
|
|
391
|
+
@reactor('store-name-reactor')
|
|
392
|
+
class StoreNames {
|
|
393
|
+
registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
394
|
+
identifiers.push(String(services.eventStore.name), `${services.eventStore.namespace}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const scenario = new ReactorScenario(StoreNames, options);
|
|
398
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
399
|
+
identifiers.should.deep.equal(['test-event-store', 'default']);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('rejects later deliveries after a failed batch without appending them', async () => {
|
|
403
|
+
const handled: string[] = [];
|
|
404
|
+
let log!: IEventLog;
|
|
405
|
+
@reactor('failed-partition-reactor')
|
|
406
|
+
class Failing {
|
|
407
|
+
registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
408
|
+
log = services.eventStore.eventLog;
|
|
409
|
+
handled.push(event.name);
|
|
410
|
+
if (event.name === 'bad') throw new Error('processing failed');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const scenario = new ReactorScenario(Failing, options);
|
|
414
|
+
await scenario.when.forEventSource('A').events(new Registered('bad'), new Registered('tail')).then(() => {
|
|
415
|
+
throw new Error('expected rejection');
|
|
416
|
+
}, error => { String(error).should.contain('processing failed'); });
|
|
417
|
+
for (const source of ['A', 'B']) {
|
|
418
|
+
await scenario.when.forEventSource(source).events(new Registered('next')).then(() => {
|
|
419
|
+
throw new Error('expected rejection');
|
|
420
|
+
}, error => {
|
|
421
|
+
(error instanceof UnsupportedReactorOperation).should.be.true;
|
|
422
|
+
String(error).should.contain('delivery.afterFailure');
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
handled.should.deep.equal(['bad']);
|
|
426
|
+
scenario.results.length.should.equal(1);
|
|
427
|
+
scenario.results[0].completed.should.be.false;
|
|
428
|
+
(await log.getNextSequenceNumber()).value.should.equal(2n);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('rejects an explicit append of a subscribed event before it enters history', async () => {
|
|
432
|
+
let log!: IEventLog;
|
|
433
|
+
@reactor('self-append-reactor')
|
|
434
|
+
class SelfAppend {
|
|
435
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
436
|
+
log = services.eventStore.eventLog;
|
|
437
|
+
await log.append('A', new Registered(event.name + '-follow'));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const scenario = new ReactorScenario(SelfAppend, options);
|
|
441
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).then(() => {
|
|
442
|
+
throw new Error('expected rejection');
|
|
443
|
+
}, error => {
|
|
444
|
+
(error instanceof UnsupportedReactorOperation).should.be.true;
|
|
445
|
+
String(error).should.contain('services.eventLog.append.subscribed');
|
|
446
|
+
});
|
|
447
|
+
scenario.results[0].completed.should.be.false;
|
|
448
|
+
(await log.getNextSequenceNumber()).value.should.equal(1n);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('records an explicit append of an unobserved event without delivering it', async () => {
|
|
452
|
+
let log!: IEventLog;
|
|
453
|
+
const handled: string[] = [];
|
|
454
|
+
@reactor('unobserved-append-reactor')
|
|
455
|
+
class UnobservedAppend {
|
|
456
|
+
async registered(event: Registered, _context: EventContext, services: ReactorServices) {
|
|
457
|
+
log = services.eventStore.eventLog;
|
|
458
|
+
handled.push(event.name);
|
|
459
|
+
await log.append('A', new Skipped('not observed'));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const scenario = new ReactorScenario(UnobservedAppend, options);
|
|
463
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
464
|
+
handled.should.deep.equal(['first']);
|
|
465
|
+
(await log.getFromSequenceNumber(EventSequenceNumber.first)).length.should.equal(2);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('rejects a mixed-source batch containing a subscribed follow-up event atomically', async () => {
|
|
469
|
+
let log!: IEventLog;
|
|
470
|
+
@reactor('self-append-batch-reactor')
|
|
471
|
+
class SelfAppendBatch {
|
|
472
|
+
async registered(_event: Registered, _context: EventContext, services: ReactorServices) {
|
|
473
|
+
log = services.eventStore.eventLog;
|
|
474
|
+
await log.appendMany([
|
|
475
|
+
{ eventSourceId: 'B', event: new Skipped('allowed') },
|
|
476
|
+
{ eventSourceId: 'A', event: new Registered('not allowed') }
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const scenario = new ReactorScenario(SelfAppendBatch, options);
|
|
481
|
+
await scenario.when.forEventSource('A').events(new Registered('first')).then(() => {
|
|
482
|
+
throw new Error('expected rejection');
|
|
483
|
+
}, error => { String(error).should.contain('services.eventLog.append.subscribed'); });
|
|
484
|
+
(await log.getNextSequenceNumber()).value.should.equal(1n);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('uses fixture-backed history contexts and records returned events without appending them', async () => {
|
|
488
|
+
calls.length = 0;
|
|
489
|
+
const activations: EventContext[] = [];
|
|
490
|
+
const invocations: EventContext[] = [];
|
|
491
|
+
const steps: string[] = [];
|
|
492
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options, artifactActivator: (type, context) => {
|
|
493
|
+
if (context.delivery === ArtifactDelivery.Events) activations.push(context.eventContext);
|
|
494
|
+
return { instance: new type(), run: async (callback, invocation) => {
|
|
495
|
+
if (invocation?.delivery === ArtifactDelivery.Events) invocations.push(invocation.eventContext);
|
|
496
|
+
steps.push('enter'); try { return await callback(); } finally { steps.push('exit'); }
|
|
497
|
+
}, complete: () => { steps.push('complete'); }, dispose: () => { steps.push('dispose'); } };
|
|
498
|
+
} });
|
|
499
|
+
await scenario.given.forEventSource('A').events(new Skipped('first'), new Registered('seed'));
|
|
500
|
+
await scenario.when.forEventSource('A').events(new Registered('act'), new Skipped('last'));
|
|
501
|
+
calls.map(call => call.event.name).should.deep.equal(['seed', 'act']);
|
|
502
|
+
calls[0].event.should.not.be.instanceOf(Registered); // Handler receives serialized plain JSON.
|
|
503
|
+
calls.map(call => call.context.sequenceNumber).should.deep.equal([1n, 2n]);
|
|
504
|
+
calls.map(call => call.context.observationState).should.deep.equal([1, 1]);
|
|
505
|
+
fixture.expected.history.map(entry => BigInt(entry.sequence)).should.deep.equal([0n, 1n, 2n, 3n]);
|
|
506
|
+
fixture.expected.history.every(entry => entry.observationState === 1).should.be.true;
|
|
507
|
+
activations.map(context => context.sequenceNumber).should.deep.equal([1n, 2n]);
|
|
508
|
+
invocations.map(context => context.sequenceNumber).should.deep.equal([1n, 2n]);
|
|
509
|
+
calls.every(call => call.services.eventStore.readModels === call.services.readModels).should.be.true;
|
|
510
|
+
steps.should.deep.equal(['enter', 'exit', 'complete', 'dispose', 'enter', 'exit', 'complete', 'dispose']);
|
|
511
|
+
scenario.results.map(result => [result.handled.length, result.skipped.length, result.completed]).should.deep.equal([[1, 1, true], [1, 1, true]]);
|
|
512
|
+
scenario.produced.length.should.equal(2);
|
|
513
|
+
scenario.shouldHaveProduced(Registered, event => event.name === 'act');
|
|
514
|
+
scenario.shouldNotHaveProduced(Skipped);
|
|
515
|
+
scenario.sideEffects.map(effect => [effect.kind, effect.target.eventSourceId, effect.handler, effect.deliveryIndex])
|
|
516
|
+
.should.deep.equal([['event', 'other', 'registered', 0], ['event', 'other', 'registered', 1]]);
|
|
517
|
+
scenario.then.sideEffects.should.deep.equal(scenario.sideEffects);
|
|
518
|
+
(typeof scenario.then).should.equal('object');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('reuses a default instance across deliveries, as the production runtime does', async () => {
|
|
522
|
+
const instances: object[] = [];
|
|
523
|
+
@reactor('default-instance-reactor')
|
|
524
|
+
class Reused {
|
|
525
|
+
constructor() { instances.push(this); }
|
|
526
|
+
registered() {}
|
|
527
|
+
}
|
|
528
|
+
const scenario = new ReactorScenario(Reused, options);
|
|
529
|
+
await scenario.given.forEventSource('A').events(new Registered('one'));
|
|
530
|
+
await scenario.when.forEventSource('B').events(new Registered('two'));
|
|
531
|
+
instances.length.should.equal(1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('keeps handler and completion failures, stops the batch, and disposes after completion', async () => {
|
|
535
|
+
const steps: string[] = [];
|
|
536
|
+
@reactor('throwing-reactor')
|
|
537
|
+
class Failing {
|
|
538
|
+
registered() { steps.push('handler'); throw new Error('processing failed'); }
|
|
539
|
+
}
|
|
540
|
+
const scenario = new ReactorScenario(Failing, { ...options, artifactActivator: type => ({ instance: new type(),
|
|
541
|
+
complete: () => { steps.push('complete'); throw new Error('completion failed'); },
|
|
542
|
+
dispose: () => { steps.push('dispose'); } }) });
|
|
543
|
+
try {
|
|
544
|
+
await scenario.when.forEventSource('A').events(new Registered('one'), new Registered('two'));
|
|
545
|
+
throw new Error('expected rejection');
|
|
546
|
+
} catch (error) {
|
|
547
|
+
(error instanceof ArtifactCompletionFailed).should.be.true;
|
|
548
|
+
String((error as ArtifactCompletionFailed).processingError).should.contain('processing failed');
|
|
549
|
+
String((error as ArtifactCompletionFailed).completionError).should.contain('completion failed');
|
|
550
|
+
}
|
|
551
|
+
steps.should.deep.equal(['handler', 'complete', 'dispose']);
|
|
552
|
+
scenario.results[0].completed.should.be.false;
|
|
553
|
+
scenario.results[0].handled.length.should.equal(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('lets a result handler claim an effect without recording it', async () => {
|
|
557
|
+
const store = { readModels: {} } as IEventStore;
|
|
558
|
+
const received: unknown[] = [];
|
|
559
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options, servicesEventStore: store,
|
|
560
|
+
resultHandler: (result, context, reactorType, eventStore, namespace) => {
|
|
561
|
+
received.push(result, context.eventSourceId, reactorType, eventStore, namespace);
|
|
562
|
+
return true;
|
|
563
|
+
} });
|
|
564
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
565
|
+
received.length.should.equal(5);
|
|
566
|
+
(received[0] as { event: Registered }).event.name.should.equal('first');
|
|
567
|
+
received.slice(1).should.deep.equal(['A', ScenarioReactor, 'test-event-store', 'default']);
|
|
568
|
+
scenario.produced.length.should.equal(0);
|
|
569
|
+
scenario.sideEffects.length.should.equal(0);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('records returned events when a result handler declines them', async () => {
|
|
573
|
+
let invoked = 0;
|
|
574
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options,
|
|
575
|
+
resultHandler: async () => { invoked++; return false; } });
|
|
576
|
+
await scenario.when.forEventSource('A').events(new Registered('first'));
|
|
577
|
+
invoked.should.equal(1);
|
|
578
|
+
scenario.shouldHaveProduced(Registered, event => event.name === 'first');
|
|
579
|
+
scenario.sideEffects.length.should.equal(1);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('awaits side-effect handling within run and propagates declined or thrown results', async () => {
|
|
583
|
+
const steps: string[] = [];
|
|
584
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options,
|
|
585
|
+
resultHandler: async () => { steps.push('hook'); throw new Error('hook failed'); },
|
|
586
|
+
artifactActivator: type => ({ instance: new type(), run: async callback => {
|
|
587
|
+
steps.push('enter'); try { return await callback(); } finally { steps.push('exit'); }
|
|
588
|
+
}, complete: () => { steps.push('complete'); } }) });
|
|
589
|
+
await scenario.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); }, error => {
|
|
590
|
+
String(error).should.contain('hook failed');
|
|
591
|
+
});
|
|
592
|
+
steps.should.deep.equal(['enter', 'hook', 'exit', 'complete']);
|
|
593
|
+
scenario.produced.length.should.equal(0);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('rejects overlapping deliveries across asynchronous activations', async () => {
|
|
597
|
+
let release!: () => void;
|
|
598
|
+
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
599
|
+
const scenario = new ReactorScenario(ScenarioReactor, { ...options, artifactActivator: async type => {
|
|
600
|
+
await pending; return { instance: new type() };
|
|
601
|
+
} });
|
|
602
|
+
const first = scenario.when.forEventSource('A').events(new Registered('one'));
|
|
603
|
+
await Promise.resolve();
|
|
604
|
+
await scenario.when.forEventSource('B').events(new Registered('two')).then(() => {
|
|
605
|
+
throw new Error('expected rejection');
|
|
606
|
+
}, error => { (error instanceof UnsupportedReactorOperation).should.be.true; });
|
|
607
|
+
release();
|
|
608
|
+
await first;
|
|
609
|
+
scenario.results.length.should.equal(1);
|
|
610
|
+
});
|
|
611
|
+
});
|