@cratis/chronicle 6.40.0 → 6.41.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/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +4 -4
- package/compliance/resolveEventSubject.ts +19 -0
- package/compliance/subject.ts +2 -2
- package/dist/compliance/resolveEventSubject.d.ts +3 -0
- package/dist/compliance/resolveEventSubject.d.ts.map +1 -0
- package/dist/compliance/resolveEventSubject.js +21 -0
- package/dist/compliance/resolveEventSubject.js.map +1 -0
- package/dist/compliance/subject.d.ts +2 -2
- package/dist/compliance/subject.js +2 -2
- package/dist/eventSequences/AppendOptions.d.ts +1 -1
- package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
- package/dist/eventSequences/EventForEventSourceId.d.ts +1 -1
- package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +2 -2
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -1
- package/dist/eventSequences/prepareBatchAppend.js +2 -1
- package/dist/eventSequences/prepareBatchAppend.js.map +1 -1
- package/dist/eventSequences/prepareSingleAppend.d.ts +1 -0
- package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -1
- package/dist/eventSequences/prepareSingleAppend.js +3 -1
- package/dist/eventSequences/prepareSingleAppend.js.map +1 -1
- package/dist/testing/EventScenario.js +1 -1
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/EventScenarioGiven.d.ts +3 -1
- package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
- package/dist/testing/EventScenarioGiven.js +4 -2
- package/dist/testing/EventScenarioGiven.js.map +1 -1
- package/dist/testing/EventScenarioGivenBuilder.d.ts +2 -1
- package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -1
- package/dist/testing/EventScenarioGivenBuilder.js +1 -1
- package/dist/testing/EventScenarioGivenBuilder.js.map +1 -1
- package/dist/testing/EventScenarioSeed.d.ts +3 -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 +3 -1
- package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
- package/dist/testing/EventScenarioWhen.js +5 -3
- package/dist/testing/EventScenarioWhen.js.map +1 -1
- package/dist/testing/EventScenarioWhenBuilder.d.ts +2 -1
- package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -1
- package/dist/testing/EventScenarioWhenBuilder.js +1 -1
- package/dist/testing/EventScenarioWhenBuilder.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts +1 -2
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +19 -27
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts +3 -2
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +5 -5
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/ReadModelScenario.d.ts +2 -1
- package/dist/testing/ReadModelScenario.d.ts.map +1 -1
- package/dist/testing/ReadModelScenario.js +10 -1
- package/dist/testing/ReadModelScenario.js.map +1 -1
- package/dist/testing/ReadModelScenarioGiven.d.ts +3 -1
- package/dist/testing/ReadModelScenarioGiven.d.ts.map +1 -1
- package/dist/testing/ReadModelScenarioGiven.js +4 -2
- package/dist/testing/ReadModelScenarioGiven.js.map +1 -1
- package/dist/testing/ReadModelScenarioGivenBuilder.d.ts +2 -1
- package/dist/testing/ReadModelScenarioGivenBuilder.d.ts.map +1 -1
- package/dist/testing/ReadModelScenarioGivenBuilder.js +2 -2
- package/dist/testing/ReadModelScenarioGivenBuilder.js.map +1 -1
- package/dist/testing/singleAppendRoute.d.ts +9 -0
- package/dist/testing/singleAppendRoute.d.ts.map +1 -0
- package/dist/testing/singleAppendRoute.js +28 -0
- package/dist/testing/singleAppendRoute.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/AppendOptions.ts +1 -1
- package/eventSequences/EventForEventSourceId.ts +1 -1
- package/eventSequences/EventSequence.ts +2 -2
- package/eventSequences/for_EventSequence/when_appending/subject_behaviors.fixture.ts +239 -0
- package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.spec.ts +34 -0
- package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts +38 -0
- package/eventSequences/prepareBatchAppend.ts +2 -1
- package/eventSequences/prepareSingleAppend.ts +3 -1
- package/package.json +2 -2
- package/testing/EventScenario.batches.spec.ts +2 -2
- package/testing/EventScenario.fixtures.spec.ts +2 -2
- package/testing/EventScenario.rejections.spec.ts +2 -2
- package/testing/EventScenario.ts +1 -1
- package/testing/EventScenarioGiven.ts +3 -2
- package/testing/EventScenarioGivenBuilder.ts +2 -1
- package/testing/EventScenarioSeed.ts +5 -4
- package/testing/EventScenarioWhen.ts +4 -3
- package/testing/EventScenarioWhenBuilder.ts +2 -1
- package/testing/InProcessEventSequence.ts +20 -28
- package/testing/ReactorScenario.ts +7 -7
- package/testing/ReadModelScenario.spec.ts +31 -0
- package/testing/ReadModelScenario.ts +12 -1
- package/testing/ReadModelScenarioGiven.ts +4 -2
- package/testing/ReadModelScenarioGivenBuilder.ts +3 -2
- package/testing/for_EventScenario/given/routed_scenario.fixture.ts +39 -0
- package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +1 -1
- package/testing/for_EventScenario/when_using_routed_builders/with_routing_options.spec.ts +116 -0
- package/testing/for_ReactorScenario/when_using_routed_builders/with_routing_options.spec.ts +88 -0
- package/testing/for_ReadModelScenario/when_seeding_with_options/with_route_validation.spec.ts +52 -0
- package/testing/singleAppendRoute.spec.ts +19 -0
- package/testing/singleAppendRoute.ts +33 -0
|
@@ -17,6 +17,7 @@ import { EventTypeId } from '../events/EventTypeId.js';
|
|
|
17
17
|
import { EventTypeGeneration } from '../events/EventTypeGeneration.js';
|
|
18
18
|
import { Identity } from '../identity/Identity.js';
|
|
19
19
|
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
20
|
+
import { singleAppendRoute } from './singleAppendRoute.js';
|
|
20
21
|
import type { AppendResult } from '../eventSequences/AppendResult.js';
|
|
21
22
|
import type { AppendedEventWithResult } from '../eventSequences/AppendedEventWithResult.js';
|
|
22
23
|
import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
@@ -36,6 +37,11 @@ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOpe
|
|
|
36
37
|
// JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls.
|
|
37
38
|
const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u;
|
|
38
39
|
|
|
40
|
+
function resolveStoredSubject(subject: string | undefined, eventSourceId: string): string {
|
|
41
|
+
// Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF.
|
|
42
|
+
return subject && !/^\p{White_Space}*$/u.test(subject) ? subject : eventSourceId;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
function matchesConstrainedValue(value: unknown, content: unknown): boolean {
|
|
40
46
|
// Use production serialization for concepts, not their wrapper object's runtime type.
|
|
41
47
|
// Keep rejecting other unproven conversions, such as a Date assigned to a string field.
|
|
@@ -129,7 +135,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
129
135
|
get appendedEvents(): readonly AppendedEvent[] { return Object.freeze(this._history.map(event => this.snapshot(event))); }
|
|
130
136
|
|
|
131
137
|
/** Validate and stage all setup appends, then commit and notify in single-append order. */
|
|
132
|
-
async seed(source: string, events: object[]): Promise<void> {
|
|
138
|
+
async seed(source: string, events: object[], options?: AppendOptions): Promise<void> {
|
|
133
139
|
if (this._setup || this._busy) throw this.unsupported('given.events', this.id.value, 'Overlapping setup calls are not fixture-backed.');
|
|
134
140
|
this._setup = true;
|
|
135
141
|
this._stagedSeeds = [];
|
|
@@ -138,7 +144,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
138
144
|
for (const event of events) {
|
|
139
145
|
this._allowSeedAppend = true;
|
|
140
146
|
let pending: Promise<AppendResult>;
|
|
141
|
-
try { pending = this.append(source, event); }
|
|
147
|
+
try { pending = this.append(source, event, options); }
|
|
142
148
|
finally { this._allowSeedAppend = false; }
|
|
143
149
|
const result = await pending;
|
|
144
150
|
if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result, (_, value) =>
|
|
@@ -167,13 +173,13 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
167
173
|
try {
|
|
168
174
|
if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.');
|
|
169
175
|
if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.');
|
|
170
|
-
const route =
|
|
176
|
+
const route = singleAppendRoute(options, event.constructor.name, (operation, artifact, reason) => this.unsupported(operation, artifact, reason), true);
|
|
171
177
|
const metadata = this._catalog.get(event.constructor);
|
|
172
178
|
if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.');
|
|
173
179
|
let prepared: ReturnType<typeof prepareSingleAppend>;
|
|
174
180
|
let content: Record<string, unknown>;
|
|
175
181
|
try {
|
|
176
|
-
prepared = prepareSingleAppend(event,
|
|
182
|
+
prepared = prepareSingleAppend(event, { subject: options?.subject, correlationId: this._correlationId?.() });
|
|
177
183
|
content = this.checkedContent(event, prepared.content, 'append.content',
|
|
178
184
|
'Content differs from the event schema; null, missing and extra values are not proven.');
|
|
179
185
|
} catch (error) {
|
|
@@ -206,7 +212,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
206
212
|
eventStore: this._store, namespace: this._namespace,
|
|
207
213
|
sequenceNumber: sequenceNumber.value, eventSourceId,
|
|
208
214
|
eventSourceType: route.sourceType, eventStreamType: route.streamType, eventStreamId: route.streamId,
|
|
209
|
-
subject: eventSourceId, hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
|
|
215
|
+
subject: resolveStoredSubject(prepared.subject, eventSourceId), hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
|
|
210
216
|
eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(),
|
|
211
217
|
causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
|
|
212
218
|
tags: prepared.tags.map(tag => new Tag(tag))
|
|
@@ -253,6 +259,9 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
253
259
|
}
|
|
254
260
|
const individual = entry as EventForEventSourceId;
|
|
255
261
|
if (individual.occurred !== undefined) this.checkedDate(individual.occurred, 'appendMany.occurred');
|
|
262
|
+
if (individual.subject !== undefined && individual.subject !== null && typeof individual.subject !== 'string') {
|
|
263
|
+
throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.');
|
|
264
|
+
}
|
|
256
265
|
const tags = individual.tags;
|
|
257
266
|
if (tags && (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string'))) {
|
|
258
267
|
throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
|
|
@@ -287,8 +296,8 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
287
296
|
this.validateSource(eventSourceId, 'appendMany.source');
|
|
288
297
|
const wire = eventsToAppend[index];
|
|
289
298
|
for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType,
|
|
290
|
-
streamId: wire.EventStreamId
|
|
291
|
-
const identifiers =
|
|
299
|
+
streamId: wire.EventStreamId })) {
|
|
300
|
+
const identifiers = /^[A-Za-z0-9_-]*$/;
|
|
292
301
|
if (value !== undefined && (typeof value !== 'string' || !identifiers.test(value))) {
|
|
293
302
|
throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.');
|
|
294
303
|
}
|
|
@@ -306,7 +315,7 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
306
315
|
const stored: AppendedEvent = { eventType, content, context: {
|
|
307
316
|
eventStore: this._store, namespace: this._namespace, sequenceNumber: sequenceNumber.value, eventSourceId,
|
|
308
317
|
eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All',
|
|
309
|
-
eventStreamId: wire.EventStreamId || 'Default', subject: wire.Subject, hash, causedBy: identity,
|
|
318
|
+
eventStreamId: wire.EventStreamId || 'Default', subject: resolveStoredSubject(wire.Subject, eventSourceId), hash, causedBy: identity,
|
|
310
319
|
observationState: EventObservationState.Initial, eventType, occurred, correlationId: correlationId.toString(),
|
|
311
320
|
causation: batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
|
|
312
321
|
tags: wire.Tags.map(tag => new Tag(tag))
|
|
@@ -397,26 +406,6 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
397
406
|
}
|
|
398
407
|
}
|
|
399
408
|
|
|
400
|
-
private singleAppendRoute(options: AppendOptions | undefined, artifact: string): { sourceType: string; streamType: string; streamId: string } {
|
|
401
|
-
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
|
|
402
|
-
throw this.unsupported('append.options', artifact, 'Append options must be an object.');
|
|
403
|
-
}
|
|
404
|
-
if (options && (Reflect.ownKeys(options).some(key => !['sourceType', 'streamType', 'streamId'].includes(String(key))) ||
|
|
405
|
-
options.correlationId !== undefined || options.subject !== undefined || options.occurred !== undefined ||
|
|
406
|
-
options.eventSourceId !== undefined || options.concurrencyScope !== undefined || options.tags !== undefined ||
|
|
407
|
-
options.concurrencyScopes !== undefined)) {
|
|
408
|
-
throw this.unsupported('append.options', artifact, 'Only sourceType, streamType and streamId routing options are fixture-backed for single append.');
|
|
409
|
-
}
|
|
410
|
-
const route = { sourceType: options?.sourceType, streamType: options?.streamType, streamId: options?.streamId };
|
|
411
|
-
for (const [name, value] of Object.entries(route)) {
|
|
412
|
-
if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value))) {
|
|
413
|
-
throw this.unsupported(`append.${name}`, String(value), 'Only simple routing identifiers or empty defaults are fixture-backed.');
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
// ts-proto omits empty strings; the append pipeline resolves omissions to these exact values.
|
|
417
|
-
return { sourceType: route.sourceType || 'Default', streamType: route.streamType || 'All', streamId: route.streamId || 'Default' };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
409
|
private validateBatchOptions(options?: AppendOptions): void {
|
|
421
410
|
if (options === undefined) return;
|
|
422
411
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
@@ -427,6 +416,9 @@ export class InProcessEventSequence implements IEventSequence {
|
|
|
427
416
|
options.concurrencyScope !== undefined || options.concurrencyScopes !== undefined || options.eventSourceId !== undefined) {
|
|
428
417
|
throw this.unsupported('appendMany.options', this.id.value, 'Concurrency and unrecognized metadata are not fixture-backed.');
|
|
429
418
|
}
|
|
419
|
+
if (options.subject !== undefined && options.subject !== null && typeof options.subject !== 'string') {
|
|
420
|
+
throw this.unsupported('appendMany.subject', this.id.value, 'Subject must be a string.');
|
|
421
|
+
}
|
|
430
422
|
if (options.tags && (!Array.isArray(options.tags) || options.tags.some(tag => typeof tag !== 'string'))) {
|
|
431
423
|
throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
|
|
432
424
|
}
|
|
@@ -35,8 +35,8 @@ import { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
|
|
|
35
35
|
|
|
36
36
|
/** Live, ordered reactor deliveries over the fixture-proven EventScenario event boundary. */
|
|
37
37
|
export class ReactorScenario {
|
|
38
|
-
readonly given: { forEventSource(id: string): { events(...events: object[]): Promise<void> } };
|
|
39
|
-
readonly when: { forEventSource(id: string): { events(...events: object[]): Promise<void> } };
|
|
38
|
+
readonly given: { forEventSource(id: string, options?: AppendOptions): { events(...events: object[]): Promise<void> } };
|
|
39
|
+
readonly when: { forEventSource(id: string, options?: AppendOptions): { events(...events: object[]): Promise<void> } };
|
|
40
40
|
private readonly _events: EventScenario;
|
|
41
41
|
private readonly _entries: ReturnType<typeof getReactorEventTypes>;
|
|
42
42
|
private readonly _eventTypes: readonly Constructor[];
|
|
@@ -85,8 +85,8 @@ export class ReactorScenario {
|
|
|
85
85
|
this._instance = _options.artifactActivator ? undefined : new (_reactor as new () => Record<string, Function>)();
|
|
86
86
|
if (this._instance) this._validateInstance(this._instance);
|
|
87
87
|
this._store = _options.servicesEventStore ?? this.scenarioStore();
|
|
88
|
-
this.given = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, true) }) };
|
|
89
|
-
this.when = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, false) }) };
|
|
88
|
+
this.given = { forEventSource: (id, options) => ({ events: (...events) => this.deliver(id, events, true, options) }) };
|
|
89
|
+
this.when = { forEventSource: (id, options) => ({ events: (...events) => this.deliver(id, events, false, options) }) };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
private scenarioStore(): IEventStore {
|
|
@@ -171,7 +171,7 @@ export class ReactorScenario {
|
|
|
171
171
|
throw new UnsupportedReactorOperation('redeliver', this._reactor.name, 'Retry and checkpoint semantics are not supported.');
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
private async deliver(sourceId: string, input: object[], setup: boolean): Promise<void> {
|
|
174
|
+
private async deliver(sourceId: string, input: object[], setup: boolean, options?: AppendOptions): Promise<void> {
|
|
175
175
|
if (this._failed) throw new UnsupportedReactorOperation('delivery.afterFailure', this._reactor.name,
|
|
176
176
|
'A previous delivery failed; failed-partition retries are not supported.');
|
|
177
177
|
if (this._busy) throw new UnsupportedReactorOperation('delivery.overlap', this._reactor.name, 'Concurrent deliveries have unproven ordering.');
|
|
@@ -182,9 +182,9 @@ export class ReactorScenario {
|
|
|
182
182
|
}
|
|
183
183
|
const before = this._events.appendedEvents.length;
|
|
184
184
|
if (setup) {
|
|
185
|
-
await this._events.given.forEventSource(sourceId).events(...input);
|
|
185
|
+
await this._events.given.forEventSource(sourceId, options).events(...input);
|
|
186
186
|
} else {
|
|
187
|
-
const results = await this._events.when.forEventSource(sourceId).events(...input);
|
|
187
|
+
const results = await this._events.when.forEventSource(sourceId, options).events(...input);
|
|
188
188
|
const failures = results.filter(result => !result.isSuccess);
|
|
189
189
|
if (failures.length) throw new Error(`ReactorScenario action append failed: ${JSON.stringify(failures, (_, value) =>
|
|
190
190
|
typeof value === 'bigint' ? value.toString() : value)}`);
|
|
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
5
5
|
import { ConceptAs, field, Guid } from '@cratis/fundamentals';
|
|
6
6
|
import type { Constructor } from '@cratis/fundamentals';
|
|
7
7
|
import { pii } from '../compliance/pii.js';
|
|
8
|
+
import { subject } from '../compliance/subject.js';
|
|
8
9
|
import { eventType } from '../events/eventTypeDecorator.js';
|
|
9
10
|
import type { EventContext } from '../events/EventContext.js';
|
|
10
11
|
import { fromEvent } from '../projections/modelBound/fromEvent.js';
|
|
@@ -219,6 +220,36 @@ describe('ReadModelScenario', () => {
|
|
|
219
220
|
expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
|
|
220
221
|
});
|
|
221
222
|
|
|
223
|
+
it('maps a seeded event subject annotation to the projection Subject context', async () => {
|
|
224
|
+
class PersonalData {
|
|
225
|
+
@field(String) personId = 'person-a';
|
|
226
|
+
}
|
|
227
|
+
subject()(PersonalData.prototype, 'personId');
|
|
228
|
+
eventType('scenario-personal-data')(PersonalData);
|
|
229
|
+
class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
|
|
230
|
+
fromEvent(PersonalData)(SubjectState);
|
|
231
|
+
setFromContext(PersonalData, 'subject')(SubjectState.prototype, 'subject');
|
|
232
|
+
const scenario = new ReadModelScenario(SubjectState, { ...artifacts, eventTypes: [PersonalData] });
|
|
233
|
+
scenario.given.forEventSource('source-a').events(new PersonalData());
|
|
234
|
+
expect(await scenario.instance).toMatchObject({ subject: 'person-a' });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it.each([undefined, null, '', ' \t\n', '\u0085\u2003'])('defaults an annotated subject %j to the event source', async value => {
|
|
238
|
+
class PersonalData {
|
|
239
|
+
// Keep null out of serialized content while exercising subject resolution.
|
|
240
|
+
get personId(): string | null | undefined { return value; }
|
|
241
|
+
}
|
|
242
|
+
field(String)(PersonalData.prototype, 'personId');
|
|
243
|
+
subject()(PersonalData.prototype, 'personId');
|
|
244
|
+
eventType('scenario-empty-subject')(PersonalData);
|
|
245
|
+
class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
|
|
246
|
+
fromEvent(PersonalData)(SubjectState);
|
|
247
|
+
setFromContext(PersonalData, 'subject')(SubjectState.prototype, 'subject');
|
|
248
|
+
const scenario = new ReadModelScenario(SubjectState, { ...artifacts, eventTypes: [PersonalData] });
|
|
249
|
+
scenario.given.forEventSource('source-a').events(new PersonalData());
|
|
250
|
+
expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
|
|
251
|
+
});
|
|
252
|
+
|
|
222
253
|
it('defaults a projection Hash and CausedBy mapping to kernel sentinels when the seed omits them', async () => {
|
|
223
254
|
class ContextDefaults { @field(String) id = ''; @field(String) hash = ''; @field(String) causedByName = ''; }
|
|
224
255
|
setFromContext(ItemAdded, 'hash')(ContextDefaults.prototype, 'hash');
|
|
@@ -7,6 +7,7 @@ import type { Constructor } from '@cratis/fundamentals';
|
|
|
7
7
|
import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
|
|
8
8
|
import type { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
|
|
9
9
|
import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
10
|
+
import { resolveEventSubject } from '../compliance/resolveEventSubject.js';
|
|
10
11
|
import { getFromEventMetadata } from '../projections/modelBound/fromEvent.js';
|
|
11
12
|
import { getRemovedWithClassMetadata } from '../projections/modelBound/removedWith.js';
|
|
12
13
|
import { getRemovedWithJoinClassMetadata } from '../projections/modelBound/removedWithJoin.js';
|
|
@@ -24,6 +25,8 @@ import type { ReadModelState } from './ReadModelState.js';
|
|
|
24
25
|
import { ReducerReadModelProcessor } from './ReducerReadModelProcessor.js';
|
|
25
26
|
import type { ScenarioEvent } from './ScenarioEvent.js';
|
|
26
27
|
import type { EventScenario } from './EventScenario.js';
|
|
28
|
+
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
29
|
+
import { singleAppendRoute } from './singleAppendRoute.js';
|
|
27
30
|
import type { ReactorScenario } from './ReactorScenario.js';
|
|
28
31
|
import { ProjectionReadModelProcessor } from './projections/ProjectionReadModelProcessor.js';
|
|
29
32
|
import { UnsupportedProjectionOperation } from './projections/UnsupportedProjectionOperation.js';
|
|
@@ -124,18 +127,26 @@ export class ReadModelScenario<TReadModel extends object> {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
/** Collects events for a source; subsequent reads replay the complete seeded history. */
|
|
127
|
-
collectEventsFor(id: string, events: readonly object[]): void {
|
|
130
|
+
collectEventsFor(id: string, events: readonly object[], options?: AppendOptions): void {
|
|
128
131
|
if (this._observed) throw this.mixedHistory();
|
|
132
|
+
const route = singleAppendRoute(options, events[0]?.constructor.name ?? this._modelName);
|
|
133
|
+
if (route.sourceType !== 'Default' || route.streamType !== 'All' || route.streamId !== 'Default') {
|
|
134
|
+
throw new UnsupportedProjectionOperation(this._modelName, 'given.events', `${route.sourceType}/${route.streamType}/${route.streamId}`,
|
|
135
|
+
'seeded events must use the default event source type and stream; routed events require a kernel-backed test');
|
|
136
|
+
}
|
|
129
137
|
for (const event of events) {
|
|
130
138
|
const metadata = getEventTypeMetadata(event.constructor);
|
|
131
139
|
if (!metadata) throw new Error(`Event '${event.constructor.name}' has no @eventType metadata.`);
|
|
132
140
|
// The kernel delivers JSON objects, not instances of decorated event classes.
|
|
133
141
|
const content: unknown = JSON.parse(JsonSerializer.serialize(event));
|
|
142
|
+
const subject = resolveEventSubject(event);
|
|
134
143
|
this._events.push({
|
|
135
144
|
sourceId: id,
|
|
136
145
|
content,
|
|
137
146
|
context: {
|
|
138
147
|
eventSourceId: id,
|
|
148
|
+
// Match the kernel's string.IsNullOrWhiteSpace, including U+0085 but not U+FEFF.
|
|
149
|
+
subject: subject && !/^\p{White_Space}*$/u.test(subject) ? subject : id,
|
|
139
150
|
eventSourceType: 'Default',
|
|
140
151
|
eventStreamType: 'All',
|
|
141
152
|
eventStreamId: 'Default',
|
|
@@ -1,16 +1,18 @@
|
|
|
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
|
+
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
4
5
|
import type { ReadModelScenario } from './ReadModelScenario.js';
|
|
5
6
|
|
|
6
7
|
/** Seeds events belonging to one event source, in the order provided. */
|
|
7
8
|
export class ReadModelScenarioGiven<TReadModel extends object> {
|
|
8
9
|
/** Creates a builder for the selected event source. */
|
|
9
|
-
constructor(private readonly _scenario: ReadModelScenario<TReadModel>, private readonly _eventSourceId: string
|
|
10
|
+
constructor(private readonly _scenario: ReadModelScenario<TReadModel>, private readonly _eventSourceId: string,
|
|
11
|
+
private readonly _options?: AppendOptions) {}
|
|
10
12
|
|
|
11
13
|
/** Adds events to the scenario without connecting to a Chronicle kernel. */
|
|
12
14
|
events(...events: object[]): ReadModelScenario<TReadModel> {
|
|
13
|
-
this._scenario.collectEventsFor(this._eventSourceId, events);
|
|
15
|
+
this._scenario.collectEventsFor(this._eventSourceId, events, this._options);
|
|
14
16
|
return this._scenario;
|
|
15
17
|
}
|
|
16
18
|
}
|
|
@@ -1,6 +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
|
+
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
4
5
|
import type { ReadModelScenario } from './ReadModelScenario.js';
|
|
5
6
|
import { ReadModelScenarioGiven } from './ReadModelScenarioGiven.js';
|
|
6
7
|
|
|
@@ -9,7 +10,7 @@ export class ReadModelScenarioGivenBuilder<TReadModel extends object> {
|
|
|
9
10
|
constructor(private readonly _scenario: ReadModelScenario<TReadModel>) {}
|
|
10
11
|
|
|
11
12
|
/** Creates a builder for the selected event source. */
|
|
12
|
-
forEventSource(id: string): ReadModelScenarioGiven<TReadModel> {
|
|
13
|
-
return new ReadModelScenarioGiven(this._scenario, id);
|
|
13
|
+
forEventSource(id: string, options?: AppendOptions): ReadModelScenarioGiven<TReadModel> {
|
|
14
|
+
return new ReadModelScenarioGiven(this._scenario, id, options);
|
|
14
15
|
}
|
|
15
16
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { field } from '@cratis/fundamentals';
|
|
6
|
+
import { eventType } from '../../../events/eventTypeDecorator.js';
|
|
7
|
+
import { constraint } from '../../../events/constraints/constraint.js';
|
|
8
|
+
import type { IConstraintBuilder } from '../../../events/constraints/IConstraintBuilder.js';
|
|
9
|
+
import type { EventContext } from '../../../events/EventContext.js';
|
|
10
|
+
import { reactor } from '../../../reactors/reactor.js';
|
|
11
|
+
import { EventScenario, ReactorScenario, UnsupportedEventSequenceOperation } from '../../index.js';
|
|
12
|
+
|
|
13
|
+
@eventType('BuilderRouteClaim')
|
|
14
|
+
export class Claim { @field(String) key: string; constructor(key: string) { this.key = key; } }
|
|
15
|
+
@eventType('BuilderRouteEffect')
|
|
16
|
+
export class Effect { @field(String) key: string; constructor(key: string) { this.key = key; } }
|
|
17
|
+
@constraint('BuilderRouteKey')
|
|
18
|
+
class ScopedKey {
|
|
19
|
+
define(builder: IConstraintBuilder) {
|
|
20
|
+
builder.perEventSourceType().perEventStreamType().perEventStreamId().unique(key => key.on(Claim, event => event.key));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
@reactor('BuilderRouteReactor')
|
|
24
|
+
export class RoutedReactor {
|
|
25
|
+
claim(event: Claim, context: EventContext) {
|
|
26
|
+
return new Effect(`${event.key}:${context.eventSourceType}/${context.eventStreamType}/${context.eventStreamId}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const route = { sourceType: 'Customer', streamType: 'Orders', streamId: 'West' };
|
|
30
|
+
export const artifacts = { eventTypes: [Claim, Effect], constraints: [ScopedKey] };
|
|
31
|
+
export const createEvents = () => new EventScenario({ artifacts });
|
|
32
|
+
export const createReactor = () => new ReactorScenario(RoutedReactor, { artifacts });
|
|
33
|
+
export const routing = (context: EventContext) => ({ sourceType: context.eventSourceType, streamType: context.eventStreamType, streamId: context.eventStreamId });
|
|
34
|
+
export async function rejection(action: () => unknown): Promise<UnsupportedEventSequenceOperation> {
|
|
35
|
+
return Promise.resolve().then(action).then(() => { throw new Error('Expected unsupported operation'); }, error => {
|
|
36
|
+
if (!(error instanceof UnsupportedEventSequenceOperation)) throw error;
|
|
37
|
+
return error;
|
|
38
|
+
});
|
|
39
|
+
}
|
package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts
CHANGED
|
@@ -83,7 +83,7 @@ describe('when a scoped append uses unsupported routes or metadata', () => {
|
|
|
83
83
|
await unsupported(() => scenario.appendMany('B', [new Claim('Beta')], options), `appendMany.${dimension}`);
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
for (const options of [{ tags: [] }, {
|
|
86
|
+
for (const options of [{ tags: [] }, { occurred: new Date() }, { correlationId: '00000000-0000-0000-0000-000000000001' },
|
|
87
87
|
{ concurrencyScopes: {} }, { eventSourceId: 'other' }, { arbitrary: true }]) {
|
|
88
88
|
await unsupported(() => scenario.append('B', new Claim('Beta'), options as AppendOptions), 'append.options');
|
|
89
89
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { chai, describe, it, vi } from 'vitest';
|
|
5
|
+
import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
|
|
6
|
+
import { Claim, createEvents, rejection, route, routing } from '../given/routed_scenario.fixture.js';
|
|
7
|
+
|
|
8
|
+
chai.should();
|
|
9
|
+
describe('when seeding with routed builders', () => {
|
|
10
|
+
it('should record each setup route through single append without action results', async () => {
|
|
11
|
+
const scenario = createEvents();
|
|
12
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Alpha'), new Claim('Beta'));
|
|
13
|
+
scenario.appendedEvents.map(event => routing(event.context)).should.deep.equal([route, route]);
|
|
14
|
+
scenario.results.should.have.lengthOf(0);
|
|
15
|
+
(await scenario.append('B', new Claim('Alpha'), route)).isSuccess.should.be.true;
|
|
16
|
+
(await scenario.append('B', new Claim('Beta'), route)).isSuccess.should.be.false;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should roll back routed setup and preserve claims in other scopes', async () => {
|
|
20
|
+
const scenario = createEvents();
|
|
21
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Taken'));
|
|
22
|
+
await scenario.given.forEventSource('A').events(new Claim('DefaultKey'));
|
|
23
|
+
const before = scenario.appendedEvents;
|
|
24
|
+
await scenario.given.forEventSource('B', route).events(new Claim('Temporary'), new Claim('Taken')).then(
|
|
25
|
+
() => { throw new Error('Expected failed setup'); }, error => (error as Error).message.should.include('EventScenario given setup failed'));
|
|
26
|
+
scenario.appendedEvents.should.deep.equal(before);
|
|
27
|
+
scenario.results.should.have.lengthOf(0);
|
|
28
|
+
(await scenario.when.forEventSource('C', route).event(new Claim('Temporary'))).sequenceNumber.value.should.equal(2n);
|
|
29
|
+
(await scenario.when.forEventSource('D').event(new Claim('DefaultKey'))).isSuccess.should.be.false;
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('when acting with routed builders', () => {
|
|
34
|
+
it('should preserve append metadata identically to direct atomic batches', async () => {
|
|
35
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
36
|
+
vi.setSystemTime(new Date('2026-01-02T03:04:05.000Z'));
|
|
37
|
+
try {
|
|
38
|
+
const scenario = createEvents();
|
|
39
|
+
const direct = createEvents();
|
|
40
|
+
const subject = 'CustomerSubject';
|
|
41
|
+
const correlationId = '11111111-2222-3333-4444-555555555555';
|
|
42
|
+
const occurred = new Date('2025-12-01T12:34:56.000Z');
|
|
43
|
+
const tags = ['Priority', 'Routed'];
|
|
44
|
+
await scenario.when.forEventSource('A', { ...route, subject, correlationId, occurred, tags }).events(new Claim('Alpha'), new Claim('Beta'));
|
|
45
|
+
await direct.appendMany('A', [new Claim('Alpha'), new Claim('Beta')], { ...route, subject, correlationId, occurred, tags });
|
|
46
|
+
scenario.appendedEvents.map(event => event.context).should.deep.equal(direct.appendedEvents.map(event => event.context));
|
|
47
|
+
} finally { vi.useRealTimers(); }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should use the route for single actions and atomic batches', async () => {
|
|
51
|
+
const scenario = createEvents();
|
|
52
|
+
(await scenario.when.forEventSource('A', route).event(new Claim('Alpha'))).isSuccess.should.be.true;
|
|
53
|
+
const results = await scenario.when.forEventSource('B', { ...route, streamId: 'East' }).events(new Claim('Alpha'), new Claim('Beta'));
|
|
54
|
+
results.every(result => result.isSuccess).should.be.true;
|
|
55
|
+
scenario.appendedEvents.map(event => routing(event.context)).should.deep.equal([route,
|
|
56
|
+
{ ...route, streamId: 'East' }, { ...route, streamId: 'East' }]);
|
|
57
|
+
scenario.results.should.have.lengthOf(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should honor every scoped dimension and reject only the matching route', async () => {
|
|
61
|
+
const scenario = createEvents();
|
|
62
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Taken'));
|
|
63
|
+
for (const dimension of ['sourceType', 'streamType', 'streamId'] as const) {
|
|
64
|
+
(await scenario.when.forEventSource(`B-${dimension}`, { ...route, [dimension]: 'Other' }).event(new Claim('Taken'))).isSuccess.should.be.true;
|
|
65
|
+
}
|
|
66
|
+
const before = scenario.appendedEvents;
|
|
67
|
+
const results = await scenario.when.forEventSource('C', route).events(new Claim('Temporary'), new Claim('Taken'));
|
|
68
|
+
results.every(result => !result.isSuccess).should.be.true;
|
|
69
|
+
scenario.appendedEvents.should.deep.equal(before);
|
|
70
|
+
(await scenario.when.forEventSource('D', route).event(new Claim('Temporary'))).sequenceNumber.value.should.equal(4n);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should retain omitted routing and resolve empty strings to exact defaults', async () => {
|
|
74
|
+
const scenario = createEvents();
|
|
75
|
+
await scenario.given.forEventSource('A').events(new Claim('Given'));
|
|
76
|
+
await scenario.given.forEventSource('B', { sourceType: '', streamType: '', streamId: '' }).events(new Claim('Empty'));
|
|
77
|
+
await scenario.when.forEventSource('C').event(new Claim('Single'));
|
|
78
|
+
await scenario.when.forEventSource('D', {}).events(new Claim('Batch'));
|
|
79
|
+
scenario.appendedEvents.map(event => routing(event.context)).should.deep.equal(Array(4).fill({ sourceType: 'Default', streamType: 'All', streamId: 'Default' }));
|
|
80
|
+
(await scenario.when.forEventSource('E', { streamId: '' }).event(new Claim('Given'))).isSuccess.should.be.false;
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('when routed builders receive unsupported options', () => {
|
|
85
|
+
it('should reject identically to their append path without changing history, results or notifications', async () => {
|
|
86
|
+
const scenario = createEvents();
|
|
87
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Taken'));
|
|
88
|
+
const before = scenario.appendedEvents;
|
|
89
|
+
const notifications = scenario.eventLog.appendOperations[Symbol.asyncIterator]();
|
|
90
|
+
const pending = notifications.next();
|
|
91
|
+
try {
|
|
92
|
+
const invalid: unknown[] = [null, [], { concurrencyScopes: {} }, { arbitrary: true }];
|
|
93
|
+
for (const dimension of ['sourceType', 'streamType', 'streamId'] as const) {
|
|
94
|
+
for (const value of [' ', 'a|b', 'é', '*', 'a:b', 'a\u0000b', 1, null]) invalid.push({ [dimension]: value });
|
|
95
|
+
}
|
|
96
|
+
for (const value of invalid) {
|
|
97
|
+
const options = value as AppendOptions;
|
|
98
|
+
const single = await rejection(() => scenario.append('B', new Claim('Other'), options));
|
|
99
|
+
(await rejection(() => scenario.given.forEventSource('B', options).events(new Claim('Other')))).message.should.equal(single.message);
|
|
100
|
+
(await rejection(() => scenario.when.forEventSource('B', options).event(new Claim('Other')))).message.should.equal(single.message);
|
|
101
|
+
const batch = await rejection(() => scenario.appendMany('B', [new Claim('Other')], options));
|
|
102
|
+
(await rejection(() => scenario.when.forEventSource('B', options).events(new Claim('Other')))).message.should.equal(batch.message);
|
|
103
|
+
}
|
|
104
|
+
const metadata = { tags: [] };
|
|
105
|
+
const single = await rejection(() => scenario.append('B', new Claim('Other'), metadata));
|
|
106
|
+
(await rejection(() => scenario.given.forEventSource('B', metadata).events(new Claim('Other')))).message.should.equal(single.message);
|
|
107
|
+
(await rejection(() => scenario.when.forEventSource('B', metadata).event(new Claim('Other')))).message.should.equal(single.message);
|
|
108
|
+
scenario.appendedEvents.should.deep.equal(before);
|
|
109
|
+
scenario.results.should.have.lengthOf(0);
|
|
110
|
+
(await scenario.eventLog.getNextSequenceNumber()).value.should.equal(1n);
|
|
111
|
+
const rejected = await scenario.when.forEventSource('B', route).event(new Claim('Taken'));
|
|
112
|
+
rejected.isSuccess.should.be.false;
|
|
113
|
+
(await pending).value![0].result.should.equal(rejected);
|
|
114
|
+
} finally { await notifications.return?.(); }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { chai, describe, it } from 'vitest';
|
|
5
|
+
import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
|
|
6
|
+
import { Claim, Effect, createEvents, createReactor, rejection, route, routing } from '../../for_EventScenario/given/routed_scenario.fixture.js';
|
|
7
|
+
|
|
8
|
+
chai.should();
|
|
9
|
+
describe('when delivering routed setup and action events', () => {
|
|
10
|
+
it('should preserve append metadata in handled and triggering contexts', async () => {
|
|
11
|
+
const scenario = createReactor();
|
|
12
|
+
const subject = 'CustomerSubject';
|
|
13
|
+
const correlationId = '11111111-2222-3333-4444-555555555555';
|
|
14
|
+
const occurred = new Date('2025-12-01T12:34:56.000Z');
|
|
15
|
+
const tags = ['Priority', 'Routed'];
|
|
16
|
+
await scenario.when.forEventSource('A', { ...route, subject, correlationId, occurred, tags }).events(new Claim('Alpha'), new Claim('Beta'));
|
|
17
|
+
const contexts = [...scenario.results.flatMap(result => result.handled), ...scenario.sideEffects.map(effect => effect.triggeringContext)];
|
|
18
|
+
contexts.map(context => ({ subject: context.subject, correlationId: context.correlationId, occurred: context.occurred,
|
|
19
|
+
tags: context.tags.map(tag => tag.value) })).should.deep.equal(Array(4).fill({ subject, correlationId, occurred, tags }));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should expose the recorded route in handled and triggering contexts', async () => {
|
|
23
|
+
const scenario = createReactor();
|
|
24
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Given'));
|
|
25
|
+
await scenario.when.forEventSource('B', { ...route, streamId: 'East' }).events(new Claim('First'), new Claim('Second'));
|
|
26
|
+
const expected = [route, { ...route, streamId: 'East' }, { ...route, streamId: 'East' }];
|
|
27
|
+
scenario.appendedEvents.map(event => routing(event.context)).should.deep.equal(expected);
|
|
28
|
+
scenario.results.flatMap(result => result.handled.map(routing)).should.deep.equal(expected);
|
|
29
|
+
scenario.sideEffects.map(effect => routing(effect.triggeringContext)).should.deep.equal(expected);
|
|
30
|
+
scenario.sideEffects.map(effect => effect.triggeringContext).should.deep.equal(scenario.appendedEvents.map(event => event.context));
|
|
31
|
+
scenario.sideEffects.map(effect => effect.target.eventStreamId).should.deep.equal(['West', 'East', 'East']);
|
|
32
|
+
scenario.produced.map(effect => (effect as Effect).key).should.deep.equal([
|
|
33
|
+
'Given:Customer/Orders/West', 'First:Customer/Orders/East', 'Second:Customer/Orders/East']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should enforce scoped constraints before delivering any rejected action batch', async () => {
|
|
37
|
+
const scenario = createReactor();
|
|
38
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Taken'));
|
|
39
|
+
await scenario.when.forEventSource('B', { ...route, streamId: 'East' }).events(new Claim('Taken'));
|
|
40
|
+
const before = scenario.appendedEvents;
|
|
41
|
+
await scenario.when.forEventSource('C', route).events(new Claim('Temporary'), new Claim('Taken')).then(
|
|
42
|
+
() => { throw new Error('Expected constraint rejection'); }, error => (error as Error).message.should.include('ReactorScenario action append failed'));
|
|
43
|
+
scenario.appendedEvents.should.deep.equal(before);
|
|
44
|
+
scenario.produced.should.have.lengthOf(2);
|
|
45
|
+
scenario.results.should.have.lengthOf(2);
|
|
46
|
+
await scenario.when.forEventSource('D', route).events(new Claim('Temporary'));
|
|
47
|
+
scenario.appendedEvents[2].context.sequenceNumber.should.equal(2n);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should roll back rejected routed setup before recording effects or delivery outcomes', async () => {
|
|
51
|
+
const scenario = createReactor();
|
|
52
|
+
await scenario.given.forEventSource('A', route).events(new Claim('Taken'));
|
|
53
|
+
await scenario.given.forEventSource('B', route).events(new Claim('Temporary'), new Claim('Taken')).then(
|
|
54
|
+
() => { throw new Error('Expected failed setup'); }, error => (error as Error).message.should.include('EventScenario given setup failed'));
|
|
55
|
+
scenario.appendedEvents.should.have.lengthOf(1);
|
|
56
|
+
scenario.produced.should.have.lengthOf(1);
|
|
57
|
+
scenario.results.should.have.lengthOf(1);
|
|
58
|
+
await scenario.when.forEventSource('C', route).events(new Claim('Temporary'));
|
|
59
|
+
scenario.appendedEvents[1].context.sequenceNumber.should.equal(1n);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should keep default routing for omitted and empty route options', async () => {
|
|
63
|
+
const scenario = createReactor();
|
|
64
|
+
await scenario.given.forEventSource('A').events(new Claim('Given'));
|
|
65
|
+
await scenario.when.forEventSource('B', { sourceType: '', streamType: '', streamId: '' }).events(new Claim('Action'));
|
|
66
|
+
scenario.results.flatMap(result => result.handled.map(routing)).should.deep.equal(Array(2).fill({ sourceType: 'Default', streamType: 'All', streamId: 'Default' }));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('when reactor builders receive unsupported routing', () => {
|
|
71
|
+
it('should reject through append validation without delivering or poisoning the next action', async () => {
|
|
72
|
+
const scenario = createReactor();
|
|
73
|
+
const direct = createEvents();
|
|
74
|
+
for (const value of [null, [], { sourceType: 1 }, { streamType: 'a|b' }, { streamId: ' ' }, { arbitrary: true }]) {
|
|
75
|
+
const options = value as AppendOptions;
|
|
76
|
+
const single = await rejection(() => direct.append('A', new Claim('Other'), options));
|
|
77
|
+
(await rejection(() => scenario.given.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(single.message);
|
|
78
|
+
const batch = await rejection(() => direct.appendMany('A', [new Claim('Other')], options));
|
|
79
|
+
(await rejection(() => scenario.when.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(batch.message);
|
|
80
|
+
}
|
|
81
|
+
scenario.appendedEvents.should.have.lengthOf(0);
|
|
82
|
+
scenario.results.should.have.lengthOf(0);
|
|
83
|
+
scenario.sideEffects.should.have.lengthOf(0);
|
|
84
|
+
await scenario.when.forEventSource('A', route).events(new Claim('Accepted'));
|
|
85
|
+
scenario.appendedEvents[0].context.sequenceNumber.should.equal(0n);
|
|
86
|
+
scenario.results[0].completed.should.be.true;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { chai, describe, it } from 'vitest';
|
|
5
|
+
import type { AppendOptions } from '../../../eventSequences/AppendOptions.js';
|
|
6
|
+
import { reducer } from '../../../reducers/reducer.js';
|
|
7
|
+
import { ReadModelScenario, UnsupportedProjectionOperation } from '../../index.js';
|
|
8
|
+
import { Claim, createEvents, rejection, route } from '../../for_EventScenario/given/routed_scenario.fixture.js';
|
|
9
|
+
|
|
10
|
+
chai.should();
|
|
11
|
+
class Count { claimed = 0; }
|
|
12
|
+
class Counter { claim(_event: Claim, current: Count | undefined): Count { return { claimed: (current?.claimed ?? 0) + 1 }; } }
|
|
13
|
+
reducer('BuilderRouteCounter', undefined, Count)(Counter);
|
|
14
|
+
const create = () => new ReadModelScenario(Count, { eventTypes: [Claim], reducers: [Counter], projections: [] });
|
|
15
|
+
|
|
16
|
+
describe('when read-model setup receives route options', () => {
|
|
17
|
+
it('should preserve default setup with omitted, explicit or empty defaults', async () => {
|
|
18
|
+
const scenario = create();
|
|
19
|
+
scenario.given.forEventSource('A').events(new Claim('First'));
|
|
20
|
+
scenario.given.forEventSource('A', { sourceType: '', streamType: '', streamId: '' }).events(new Claim('Second'));
|
|
21
|
+
scenario.given.forEventSource('A', { sourceType: 'Default', streamType: 'All', streamId: 'Default' }).events(new Claim('Third'));
|
|
22
|
+
(await scenario.instance)!.claimed.should.equal(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should reuse single-append rejection messages without collecting unsupported input', async () => {
|
|
26
|
+
const scenario = create();
|
|
27
|
+
const events = createEvents();
|
|
28
|
+
for (const value of [{ sourceType: ' ' }, { streamType: null }, { streamId: 1 }, null, []]) {
|
|
29
|
+
const options = value as AppendOptions;
|
|
30
|
+
const error = await rejection(() => events.append('A', new Claim('Other'), options));
|
|
31
|
+
(await rejection(() => scenario.given.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(error.message);
|
|
32
|
+
}
|
|
33
|
+
((await scenario.instance) === null).should.be.true;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should list only supported seed options without collecting unsupported input', async () => {
|
|
37
|
+
const scenario = create();
|
|
38
|
+
for (const value of [{ tags: [] }, { arbitrary: true }, { subject: 'person-a' }]) {
|
|
39
|
+
const options = value as AppendOptions;
|
|
40
|
+
(await rejection(() => scenario.given.forEventSource('A', options).events(new Claim('Other')))).message.should.equal(
|
|
41
|
+
'UnsupportedEventSequenceOperation: append.options (Claim): Only sourceType, streamType, streamId options are supported for single append. Use a kernel-backed test.');
|
|
42
|
+
}
|
|
43
|
+
((await scenario.instance) === null).should.be.true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should retain the unproven routed read-model boundary instead of simulating projection routing', async () => {
|
|
47
|
+
const scenario = create();
|
|
48
|
+
(() => scenario.given.forEventSource('A', route).events(new Claim('Other'))).should.throw(UnsupportedProjectionOperation, 'routed events');
|
|
49
|
+
scenario.given.forEventSource('A').events(new Claim('Accepted'));
|
|
50
|
+
(await scenario.instance)!.claimed.should.equal(1);
|
|
51
|
+
});
|
|
52
|
+
});
|