@cratis/chronicle 6.40.0 → 6.41.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.
Files changed (64) hide show
  1. package/dist/testing/EventScenario.js +1 -1
  2. package/dist/testing/EventScenario.js.map +1 -1
  3. package/dist/testing/EventScenarioGiven.d.ts +3 -1
  4. package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
  5. package/dist/testing/EventScenarioGiven.js +4 -2
  6. package/dist/testing/EventScenarioGiven.js.map +1 -1
  7. package/dist/testing/EventScenarioGivenBuilder.d.ts +2 -1
  8. package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -1
  9. package/dist/testing/EventScenarioGivenBuilder.js +1 -1
  10. package/dist/testing/EventScenarioGivenBuilder.js.map +1 -1
  11. package/dist/testing/EventScenarioSeed.d.ts +3 -2
  12. package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
  13. package/dist/testing/EventScenarioSeed.js +2 -2
  14. package/dist/testing/EventScenarioSeed.js.map +1 -1
  15. package/dist/testing/EventScenarioWhen.d.ts +3 -1
  16. package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
  17. package/dist/testing/EventScenarioWhen.js +5 -3
  18. package/dist/testing/EventScenarioWhen.js.map +1 -1
  19. package/dist/testing/EventScenarioWhenBuilder.d.ts +2 -1
  20. package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -1
  21. package/dist/testing/EventScenarioWhenBuilder.js +1 -1
  22. package/dist/testing/EventScenarioWhenBuilder.js.map +1 -1
  23. package/dist/testing/InProcessEventSequence.d.ts +1 -2
  24. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  25. package/dist/testing/InProcessEventSequence.js +4 -22
  26. package/dist/testing/InProcessEventSequence.js.map +1 -1
  27. package/dist/testing/ReactorScenario.d.ts +3 -2
  28. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  29. package/dist/testing/ReactorScenario.js +5 -5
  30. package/dist/testing/ReactorScenario.js.map +1 -1
  31. package/dist/testing/ReadModelScenario.d.ts +2 -1
  32. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  33. package/dist/testing/ReadModelScenario.js +6 -1
  34. package/dist/testing/ReadModelScenario.js.map +1 -1
  35. package/dist/testing/ReadModelScenarioGiven.d.ts +3 -1
  36. package/dist/testing/ReadModelScenarioGiven.d.ts.map +1 -1
  37. package/dist/testing/ReadModelScenarioGiven.js +4 -2
  38. package/dist/testing/ReadModelScenarioGiven.js.map +1 -1
  39. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts +2 -1
  40. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts.map +1 -1
  41. package/dist/testing/ReadModelScenarioGivenBuilder.js +2 -2
  42. package/dist/testing/ReadModelScenarioGivenBuilder.js.map +1 -1
  43. package/dist/testing/singleAppendRoute.d.ts +9 -0
  44. package/dist/testing/singleAppendRoute.d.ts.map +1 -0
  45. package/dist/testing/singleAppendRoute.js +24 -0
  46. package/dist/testing/singleAppendRoute.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/testing/EventScenario.ts +1 -1
  50. package/testing/EventScenarioGiven.ts +3 -2
  51. package/testing/EventScenarioGivenBuilder.ts +2 -1
  52. package/testing/EventScenarioSeed.ts +5 -4
  53. package/testing/EventScenarioWhen.ts +4 -3
  54. package/testing/EventScenarioWhenBuilder.ts +2 -1
  55. package/testing/InProcessEventSequence.ts +4 -23
  56. package/testing/ReactorScenario.ts +7 -7
  57. package/testing/ReadModelScenario.ts +8 -1
  58. package/testing/ReadModelScenarioGiven.ts +4 -2
  59. package/testing/ReadModelScenarioGivenBuilder.ts +3 -2
  60. package/testing/for_EventScenario/given/routed_scenario.fixture.ts +39 -0
  61. package/testing/for_EventScenario/when_using_routed_builders/with_routing_options.spec.ts +116 -0
  62. package/testing/for_ReactorScenario/when_using_routed_builders/with_routing_options.spec.ts +88 -0
  63. package/testing/for_ReadModelScenario/when_seeding_with_options/with_route_validation.spec.ts +42 -0
  64. package/testing/singleAppendRoute.ts +28 -0
@@ -1,17 +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 { EventScenario } from './EventScenario.js';
5
6
 
6
- const seeders = new WeakMap<EventScenario, (source: string, events: object[]) => Promise<void>>();
7
+ const seeders = new WeakMap<EventScenario, (source: string, events: object[], options?: AppendOptions) => Promise<void>>();
7
8
 
8
9
  /** Internal builder wiring; not exported by the testing package. */
9
- export function registerScenarioSeed(scenario: EventScenario, seed: (source: string, events: object[]) => Promise<void>): void {
10
+ export function registerScenarioSeed(scenario: EventScenario, seed: (source: string, events: object[], options?: AppendOptions) => Promise<void>): void {
10
11
  seeders.set(scenario, seed);
11
12
  }
12
13
 
13
- export function seedScenario(scenario: EventScenario, source: string, events: object[]): Promise<void> {
14
+ export function seedScenario(scenario: EventScenario, source: string, events: object[], options?: AppendOptions): Promise<void> {
14
15
  const seed = seeders.get(scenario);
15
16
  if (!seed) throw new Error('EventScenario setup requires a constructed scenario.');
16
- return seed(source, events);
17
+ return seed(source, events, options);
17
18
  }
@@ -1,14 +1,15 @@
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 { AppendResult } from '../eventSequences/AppendResult.js';
5
6
  import type { EventScenario } from './EventScenario.js';
6
7
 
7
8
  /** A single act-phase append; the plural form is reserved for atomic batches. */
8
9
  export class EventScenarioWhen {
9
- constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
10
- async event(event: object): Promise<AppendResult> { return this._scenario.append(this._id, event); }
10
+ constructor(private readonly _scenario: EventScenario, private readonly _id: string, private readonly _options?: AppendOptions) {}
11
+ async event(event: object): Promise<AppendResult> { return this._scenario.append(this._id, event, this._options); }
11
12
  async events(...events: object[]): Promise<readonly AppendResult[]> {
12
- return this._scenario.appendMany(this._id, events);
13
+ return this._scenario.appendMany(this._id, events, this._options);
13
14
  }
14
15
  }
@@ -1,11 +1,12 @@
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 { EventScenario } from './EventScenario.js';
5
6
  import { EventScenarioWhen } from './EventScenarioWhen.js';
6
7
 
7
8
  /** Selects the source for an act-phase append. */
8
9
  export class EventScenarioWhenBuilder {
9
10
  constructor(private readonly _scenario: EventScenario) {}
10
- forEventSource(id: string): EventScenarioWhen { return new EventScenarioWhen(this._scenario, id); }
11
+ forEventSource(id: string, options?: AppendOptions): EventScenarioWhen { return new EventScenarioWhen(this._scenario, id, options); }
11
12
  }
@@ -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';
@@ -129,7 +130,7 @@ export class InProcessEventSequence implements IEventSequence {
129
130
  get appendedEvents(): readonly AppendedEvent[] { return Object.freeze(this._history.map(event => this.snapshot(event))); }
130
131
 
131
132
  /** Validate and stage all setup appends, then commit and notify in single-append order. */
132
- async seed(source: string, events: object[]): Promise<void> {
133
+ async seed(source: string, events: object[], options?: AppendOptions): Promise<void> {
133
134
  if (this._setup || this._busy) throw this.unsupported('given.events', this.id.value, 'Overlapping setup calls are not fixture-backed.');
134
135
  this._setup = true;
135
136
  this._stagedSeeds = [];
@@ -138,7 +139,7 @@ export class InProcessEventSequence implements IEventSequence {
138
139
  for (const event of events) {
139
140
  this._allowSeedAppend = true;
140
141
  let pending: Promise<AppendResult>;
141
- try { pending = this.append(source, event); }
142
+ try { pending = this.append(source, event, options); }
142
143
  finally { this._allowSeedAppend = false; }
143
144
  const result = await pending;
144
145
  if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result, (_, value) =>
@@ -167,7 +168,7 @@ export class InProcessEventSequence implements IEventSequence {
167
168
  try {
168
169
  if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.');
169
170
  if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.');
170
- const route = this.singleAppendRoute(options, event.constructor.name);
171
+ const route = singleAppendRoute(options, event.constructor.name, (operation, artifact, reason) => this.unsupported(operation, artifact, reason));
171
172
  const metadata = this._catalog.get(event.constructor);
172
173
  if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.');
173
174
  let prepared: ReturnType<typeof prepareSingleAppend>;
@@ -397,26 +398,6 @@ export class InProcessEventSequence implements IEventSequence {
397
398
  }
398
399
  }
399
400
 
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
401
  private validateBatchOptions(options?: AppendOptions): void {
421
402
  if (options === undefined) return;
422
403
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
@@ -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)}`);
@@ -24,6 +24,8 @@ import type { ReadModelState } from './ReadModelState.js';
24
24
  import { ReducerReadModelProcessor } from './ReducerReadModelProcessor.js';
25
25
  import type { ScenarioEvent } from './ScenarioEvent.js';
26
26
  import type { EventScenario } from './EventScenario.js';
27
+ import type { AppendOptions } from '../eventSequences/AppendOptions.js';
28
+ import { singleAppendRoute } from './singleAppendRoute.js';
27
29
  import type { ReactorScenario } from './ReactorScenario.js';
28
30
  import { ProjectionReadModelProcessor } from './projections/ProjectionReadModelProcessor.js';
29
31
  import { UnsupportedProjectionOperation } from './projections/UnsupportedProjectionOperation.js';
@@ -124,8 +126,13 @@ export class ReadModelScenario<TReadModel extends object> {
124
126
  }
125
127
 
126
128
  /** Collects events for a source; subsequent reads replay the complete seeded history. */
127
- collectEventsFor(id: string, events: readonly object[]): void {
129
+ collectEventsFor(id: string, events: readonly object[], options?: AppendOptions): void {
128
130
  if (this._observed) throw this.mixedHistory();
131
+ const route = singleAppendRoute(options, events[0]?.constructor.name ?? this._modelName);
132
+ if (route.sourceType !== 'Default' || route.streamType !== 'All' || route.streamId !== 'Default') {
133
+ throw new UnsupportedProjectionOperation(this._modelName, 'given.events', `${route.sourceType}/${route.streamType}/${route.streamId}`,
134
+ 'seeded events must use the default event source type and stream; routed events require a kernel-backed test');
135
+ }
129
136
  for (const event of events) {
130
137
  const metadata = getEventTypeMetadata(event.constructor);
131
138
  if (!metadata) throw new Error(`Event '${event.constructor.name}' has no @eventType metadata.`);
@@ -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
+ }
@@ -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,42 @@
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 }, { tags: [] }, { arbitrary: true }, 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 retain the unproven routed read-model boundary instead of simulating projection routing', async () => {
37
+ const scenario = create();
38
+ (() => scenario.given.forEventSource('A', route).events(new Claim('Other'))).should.throw(UnsupportedProjectionOperation, 'routed events');
39
+ scenario.given.forEventSource('A').events(new Claim('Accepted'));
40
+ (await scenario.instance)!.claimed.should.equal(1);
41
+ });
42
+ });
@@ -0,0 +1,28 @@
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 { AppendOptions } from '../eventSequences/AppendOptions.js';
5
+ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
6
+
7
+ /** The fixture-proven single-append route boundary, shared with read-model setup. */
8
+ export function singleAppendRoute(options: AppendOptions | undefined, artifact: string,
9
+ unsupported = (operation: string, artifact: string, reason: string) => new UnsupportedEventSequenceOperation(operation, artifact, reason)
10
+ ): { sourceType: string; streamType: string; streamId: string } {
11
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) {
12
+ throw unsupported('append.options', artifact, 'Append options must be an object.');
13
+ }
14
+ if (options && (Reflect.ownKeys(options).some(key => !['sourceType', 'streamType', 'streamId'].includes(String(key))) ||
15
+ options.correlationId !== undefined || options.subject !== undefined || options.occurred !== undefined ||
16
+ options.eventSourceId !== undefined || options.concurrencyScope !== undefined || options.tags !== undefined ||
17
+ options.concurrencyScopes !== undefined)) {
18
+ throw unsupported('append.options', artifact, 'Only sourceType, streamType and streamId routing options are fixture-backed for single append.');
19
+ }
20
+ const route = { sourceType: options?.sourceType, streamType: options?.streamType, streamId: options?.streamId };
21
+ for (const [name, value] of Object.entries(route)) {
22
+ if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value))) {
23
+ throw unsupported(`append.${name}`, String(value), 'Only simple routing identifiers or empty defaults are fixture-backed.');
24
+ }
25
+ }
26
+ // ts-proto omits empty strings; the append pipeline resolves omissions to these exact values.
27
+ return { sourceType: route.sourceType || 'Default', streamType: route.streamType || 'All', streamId: route.streamId || 'Default' };
28
+ }