@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.
Files changed (100) hide show
  1. package/compliance/for_PIIManager/against_a_kernel/when_erasing_a_subject.integration.spec.ts +4 -4
  2. package/compliance/resolveEventSubject.ts +19 -0
  3. package/compliance/subject.ts +2 -2
  4. package/dist/compliance/resolveEventSubject.d.ts +3 -0
  5. package/dist/compliance/resolveEventSubject.d.ts.map +1 -0
  6. package/dist/compliance/resolveEventSubject.js +21 -0
  7. package/dist/compliance/resolveEventSubject.js.map +1 -0
  8. package/dist/compliance/subject.d.ts +2 -2
  9. package/dist/compliance/subject.js +2 -2
  10. package/dist/eventSequences/AppendOptions.d.ts +1 -1
  11. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  12. package/dist/eventSequences/EventForEventSourceId.d.ts +1 -1
  13. package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
  14. package/dist/eventSequences/EventSequence.js +2 -2
  15. package/dist/eventSequences/EventSequence.js.map +1 -1
  16. package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -1
  17. package/dist/eventSequences/prepareBatchAppend.js +2 -1
  18. package/dist/eventSequences/prepareBatchAppend.js.map +1 -1
  19. package/dist/eventSequences/prepareSingleAppend.d.ts +1 -0
  20. package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -1
  21. package/dist/eventSequences/prepareSingleAppend.js +3 -1
  22. package/dist/eventSequences/prepareSingleAppend.js.map +1 -1
  23. package/dist/testing/EventScenario.js +1 -1
  24. package/dist/testing/EventScenario.js.map +1 -1
  25. package/dist/testing/EventScenarioGiven.d.ts +3 -1
  26. package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
  27. package/dist/testing/EventScenarioGiven.js +4 -2
  28. package/dist/testing/EventScenarioGiven.js.map +1 -1
  29. package/dist/testing/EventScenarioGivenBuilder.d.ts +2 -1
  30. package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -1
  31. package/dist/testing/EventScenarioGivenBuilder.js +1 -1
  32. package/dist/testing/EventScenarioGivenBuilder.js.map +1 -1
  33. package/dist/testing/EventScenarioSeed.d.ts +3 -2
  34. package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
  35. package/dist/testing/EventScenarioSeed.js +2 -2
  36. package/dist/testing/EventScenarioSeed.js.map +1 -1
  37. package/dist/testing/EventScenarioWhen.d.ts +3 -1
  38. package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
  39. package/dist/testing/EventScenarioWhen.js +5 -3
  40. package/dist/testing/EventScenarioWhen.js.map +1 -1
  41. package/dist/testing/EventScenarioWhenBuilder.d.ts +2 -1
  42. package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -1
  43. package/dist/testing/EventScenarioWhenBuilder.js +1 -1
  44. package/dist/testing/EventScenarioWhenBuilder.js.map +1 -1
  45. package/dist/testing/InProcessEventSequence.d.ts +1 -2
  46. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  47. package/dist/testing/InProcessEventSequence.js +19 -27
  48. package/dist/testing/InProcessEventSequence.js.map +1 -1
  49. package/dist/testing/ReactorScenario.d.ts +3 -2
  50. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  51. package/dist/testing/ReactorScenario.js +5 -5
  52. package/dist/testing/ReactorScenario.js.map +1 -1
  53. package/dist/testing/ReadModelScenario.d.ts +2 -1
  54. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  55. package/dist/testing/ReadModelScenario.js +10 -1
  56. package/dist/testing/ReadModelScenario.js.map +1 -1
  57. package/dist/testing/ReadModelScenarioGiven.d.ts +3 -1
  58. package/dist/testing/ReadModelScenarioGiven.d.ts.map +1 -1
  59. package/dist/testing/ReadModelScenarioGiven.js +4 -2
  60. package/dist/testing/ReadModelScenarioGiven.js.map +1 -1
  61. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts +2 -1
  62. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts.map +1 -1
  63. package/dist/testing/ReadModelScenarioGivenBuilder.js +2 -2
  64. package/dist/testing/ReadModelScenarioGivenBuilder.js.map +1 -1
  65. package/dist/testing/singleAppendRoute.d.ts +9 -0
  66. package/dist/testing/singleAppendRoute.d.ts.map +1 -0
  67. package/dist/testing/singleAppendRoute.js +28 -0
  68. package/dist/testing/singleAppendRoute.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/eventSequences/AppendOptions.ts +1 -1
  71. package/eventSequences/EventForEventSourceId.ts +1 -1
  72. package/eventSequences/EventSequence.ts +2 -2
  73. package/eventSequences/for_EventSequence/when_appending/subject_behaviors.fixture.ts +239 -0
  74. package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.spec.ts +34 -0
  75. package/eventSequences/for_EventSequence/when_appending/with_subject_annotations.standard.spec.ts +38 -0
  76. package/eventSequences/prepareBatchAppend.ts +2 -1
  77. package/eventSequences/prepareSingleAppend.ts +3 -1
  78. package/package.json +2 -2
  79. package/testing/EventScenario.batches.spec.ts +2 -2
  80. package/testing/EventScenario.fixtures.spec.ts +2 -2
  81. package/testing/EventScenario.rejections.spec.ts +2 -2
  82. package/testing/EventScenario.ts +1 -1
  83. package/testing/EventScenarioGiven.ts +3 -2
  84. package/testing/EventScenarioGivenBuilder.ts +2 -1
  85. package/testing/EventScenarioSeed.ts +5 -4
  86. package/testing/EventScenarioWhen.ts +4 -3
  87. package/testing/EventScenarioWhenBuilder.ts +2 -1
  88. package/testing/InProcessEventSequence.ts +20 -28
  89. package/testing/ReactorScenario.ts +7 -7
  90. package/testing/ReadModelScenario.spec.ts +31 -0
  91. package/testing/ReadModelScenario.ts +12 -1
  92. package/testing/ReadModelScenarioGiven.ts +4 -2
  93. package/testing/ReadModelScenarioGivenBuilder.ts +3 -2
  94. package/testing/for_EventScenario/given/routed_scenario.fixture.ts +39 -0
  95. package/testing/for_EventScenario/when_appending_scoped_constraints/with_unproven_operations.spec.ts +1 -1
  96. package/testing/for_EventScenario/when_using_routed_builders/with_routing_options.spec.ts +116 -0
  97. package/testing/for_ReactorScenario/when_using_routed_builders/with_routing_options.spec.ts +88 -0
  98. package/testing/for_ReadModelScenario/when_seeding_with_options/with_route_validation.spec.ts +52 -0
  99. package/testing/singleAppendRoute.spec.ts +19 -0
  100. 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 = this.singleAppendRoute(options, event.constructor.name);
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, this._correlationId ? { correlationId: this._correlationId() } : undefined);
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, subject: wire.Subject })) {
291
- const identifiers = name === 'subject' ? /^[A-Za-z0-9_-]+$/ : /^[A-Za-z0-9_-]*$/;
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
+ }
@@ -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: [] }, { subject: '' }, { occurred: new Date() }, { correlationId: '00000000-0000-0000-0000-000000000001' },
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
+ });