@cratis/chronicle 6.39.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 (99) hide show
  1. package/ChronicleClient.ts +2 -3
  2. package/ChronicleOptions.ts +5 -1
  3. package/EventStore.ts +1 -2
  4. package/WellKnownTelemetryNames.ts +21 -0
  5. package/dist/ChronicleClient.d.ts.map +1 -1
  6. package/dist/ChronicleClient.js +2 -3
  7. package/dist/ChronicleClient.js.map +1 -1
  8. package/dist/ChronicleOptions.d.ts +1 -1
  9. package/dist/ChronicleOptions.d.ts.map +1 -1
  10. package/dist/ChronicleOptions.js +5 -1
  11. package/dist/ChronicleOptions.js.map +1 -1
  12. package/dist/EventStore.d.ts.map +1 -1
  13. package/dist/EventStore.js +1 -2
  14. package/dist/EventStore.js.map +1 -1
  15. package/dist/WellKnownTelemetryNames.d.ts +21 -0
  16. package/dist/WellKnownTelemetryNames.d.ts.map +1 -1
  17. package/dist/WellKnownTelemetryNames.js +21 -0
  18. package/dist/WellKnownTelemetryNames.js.map +1 -1
  19. package/dist/eventSequences/EventSequence.js +9 -9
  20. package/dist/eventSequences/EventSequence.js.map +1 -1
  21. package/dist/telemetry/ChronicleTelemetryOptions.d.ts +3 -1
  22. package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -1
  23. package/dist/telemetry/observeOperation.d.ts +3 -1
  24. package/dist/telemetry/observeOperation.d.ts.map +1 -1
  25. package/dist/telemetry/observeOperation.js +3 -1
  26. package/dist/telemetry/observeOperation.js.map +1 -1
  27. package/dist/testing/EventScenario.js +1 -1
  28. package/dist/testing/EventScenario.js.map +1 -1
  29. package/dist/testing/EventScenarioGiven.d.ts +3 -1
  30. package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
  31. package/dist/testing/EventScenarioGiven.js +4 -2
  32. package/dist/testing/EventScenarioGiven.js.map +1 -1
  33. package/dist/testing/EventScenarioGivenBuilder.d.ts +2 -1
  34. package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -1
  35. package/dist/testing/EventScenarioGivenBuilder.js +1 -1
  36. package/dist/testing/EventScenarioGivenBuilder.js.map +1 -1
  37. package/dist/testing/EventScenarioSeed.d.ts +3 -2
  38. package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
  39. package/dist/testing/EventScenarioSeed.js +2 -2
  40. package/dist/testing/EventScenarioSeed.js.map +1 -1
  41. package/dist/testing/EventScenarioWhen.d.ts +3 -1
  42. package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
  43. package/dist/testing/EventScenarioWhen.js +5 -3
  44. package/dist/testing/EventScenarioWhen.js.map +1 -1
  45. package/dist/testing/EventScenarioWhenBuilder.d.ts +2 -1
  46. package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -1
  47. package/dist/testing/EventScenarioWhenBuilder.js +1 -1
  48. package/dist/testing/EventScenarioWhenBuilder.js.map +1 -1
  49. package/dist/testing/InProcessEventSequence.d.ts +1 -2
  50. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  51. package/dist/testing/InProcessEventSequence.js +4 -22
  52. package/dist/testing/InProcessEventSequence.js.map +1 -1
  53. package/dist/testing/ReactorScenario.d.ts +3 -2
  54. package/dist/testing/ReactorScenario.d.ts.map +1 -1
  55. package/dist/testing/ReactorScenario.js +5 -5
  56. package/dist/testing/ReactorScenario.js.map +1 -1
  57. package/dist/testing/ReadModelScenario.d.ts +2 -1
  58. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  59. package/dist/testing/ReadModelScenario.js +6 -1
  60. package/dist/testing/ReadModelScenario.js.map +1 -1
  61. package/dist/testing/ReadModelScenarioGiven.d.ts +3 -1
  62. package/dist/testing/ReadModelScenarioGiven.d.ts.map +1 -1
  63. package/dist/testing/ReadModelScenarioGiven.js +4 -2
  64. package/dist/testing/ReadModelScenarioGiven.js.map +1 -1
  65. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts +2 -1
  66. package/dist/testing/ReadModelScenarioGivenBuilder.d.ts.map +1 -1
  67. package/dist/testing/ReadModelScenarioGivenBuilder.js +2 -2
  68. package/dist/testing/ReadModelScenarioGivenBuilder.js.map +1 -1
  69. package/dist/testing/singleAppendRoute.d.ts +9 -0
  70. package/dist/testing/singleAppendRoute.d.ts.map +1 -0
  71. package/dist/testing/singleAppendRoute.js +24 -0
  72. package/dist/testing/singleAppendRoute.js.map +1 -0
  73. package/dist/tsconfig.tsbuildinfo +1 -1
  74. package/eventSequences/EventSequence.ts +9 -9
  75. package/for_ChronicleOptions/when_configuring_telemetry/with_span_names.spec.ts +32 -0
  76. package/package.json +1 -1
  77. package/telemetry/ChronicleTelemetryOptions.ts +4 -1
  78. package/telemetry/for_ChronicleTelemetry/given/a_client.fixture.ts +47 -0
  79. package/telemetry/for_ChronicleTelemetry/given/a_span_name_registry.fixture.ts +33 -0
  80. package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts +44 -0
  81. package/telemetry/for_ChronicleTelemetry/when_naming_spans/with_per_client_modes.spec.ts +138 -0
  82. package/telemetry/for_ChronicleTelemetry/when_recording_product_attributes/with_both_naming_modes.spec.ts +52 -0
  83. package/telemetry/observeOperation.ts +9 -1
  84. package/testing/EventScenario.ts +1 -1
  85. package/testing/EventScenarioGiven.ts +3 -2
  86. package/testing/EventScenarioGivenBuilder.ts +2 -1
  87. package/testing/EventScenarioSeed.ts +5 -4
  88. package/testing/EventScenarioWhen.ts +4 -3
  89. package/testing/EventScenarioWhenBuilder.ts +2 -1
  90. package/testing/InProcessEventSequence.ts +4 -23
  91. package/testing/ReactorScenario.ts +7 -7
  92. package/testing/ReadModelScenario.ts +8 -1
  93. package/testing/ReadModelScenarioGiven.ts +4 -2
  94. package/testing/ReadModelScenarioGivenBuilder.ts +3 -2
  95. package/testing/for_EventScenario/given/routed_scenario.fixture.ts +39 -0
  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 +42 -0
  99. package/testing/singleAppendRoute.ts +28 -0
@@ -0,0 +1,138 @@
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 { afterEach, beforeEach, chai, describe, it } from 'vitest';
5
+ import { SpanKind } from '@opentelemetry/api';
6
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
7
+ import type { ChronicleClient } from '../../../ChronicleClient.js';
8
+ import type { IEventSequence } from '../../../eventSequences/IEventSequence.js';
9
+ import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
10
+ import { EventSequenceNumber } from '../../../eventSequences/EventSequenceNumber.js';
11
+ import { clientVersion } from '../../../connection/clientVersion.js';
12
+ import { client } from '../given/a_client.fixture.js';
13
+ import { Recorded } from '../given/an_event_sequence.fixture.js';
14
+ import { telemetrySession } from '../given/a_telemetry_session.fixture.js';
15
+ import { legacySpans, conventionSpans } from '../given/a_span_name_registry.fixture.js';
16
+
17
+ const should = chai.should();
18
+ const telemetry = telemetrySession();
19
+ const sequenceNumber = new EventSequenceNumber(42n);
20
+ const operations = {
21
+ append: (sequence: IEventSequence) => sequence.append('source', new Recorded()),
22
+ appendMany: (sequence: IEventSequence) => sequence.appendMany('source', [new Recorded(), new Recorded()]),
23
+ getTailSequenceNumber: (sequence: IEventSequence) => sequence.getTailSequenceNumber(),
24
+ hasEventsFor: (sequence: IEventSequence) => sequence.hasEventsFor('source'),
25
+ getForEventSourceIdAndEventTypes: (sequence: IEventSequence) => sequence.getForEventSourceIdAndEventTypes('source', [Recorded]),
26
+ getFromSequenceNumber: (sequence: IEventSequence) => sequence.getFromSequenceNumber(sequenceNumber),
27
+ redact: (sequence: IEventSequence) => sequence.redact(sequenceNumber, 'reason'),
28
+ redactForEventSource: (sequence: IEventSequence) => sequence.redactForEventSource('source', 'reason'),
29
+ completeStream: (sequence: IEventSequence) => sequence.completeStream('stream-type', 'stream-id')
30
+ };
31
+
32
+ for (const spanNames of [undefined, 'legacy', 'convention'] as const) {
33
+ const expected = spanNames === 'convention' ? conventionSpans : legacySpans;
34
+ for (const sequenceId of [EventSequenceId.eventLog, new EventSequenceId('dynamic')]) {
35
+ for (const operation of Object.keys(operations) as (keyof typeof operations)[]) {
36
+ describe(`when naming ${operation} on ${sequenceId.value} with ${spanNames ?? 'default'} names`, () => {
37
+ let instance: ChronicleClient;
38
+ let span: ReadableSpan;
39
+ beforeEach(async () => {
40
+ instance = client(spanNames === undefined ? undefined : { spanNames });
41
+ const store = await instance.getEventStore('store', 'namespace');
42
+ const sequence = sequenceId === EventSequenceId.eventLog ? store.eventLog : store.getEventSequence(sequenceId);
43
+ telemetry.spans.reset();
44
+ await operations[operation](sequence);
45
+ span = telemetry.spans.getFinishedSpans()[0];
46
+ });
47
+ afterEach(() => instance.dispose());
48
+ it('should emit exactly one client span with the selected name', () => {
49
+ telemetry.spans.getFinishedSpans().should.have.lengthOf(1);
50
+ span.name.should.equal(expected[operation]);
51
+ span.kind.should.equal(SpanKind.CLIENT);
52
+ });
53
+ it('should retain the versioned shared instrumentation scope', () => {
54
+ span.instrumentationScope.name.should.equal('Cratis.Chronicle.Client');
55
+ should.equal(span.instrumentationScope.version, clientVersion);
56
+ });
57
+ it('should retain both attribute families', () => {
58
+ span.attributes.should.include({
59
+ 'chronicle.event_store': 'store', 'cratis.event_store.name': 'store',
60
+ 'chronicle.namespace': 'namespace', 'cratis.event_store.namespace': 'namespace',
61
+ 'chronicle.event_sequence_id': sequenceId.value, 'cratis.event_sequence.id': sequenceId.value
62
+ });
63
+ });
64
+ });
65
+ }
66
+ }
67
+
68
+ for (const operation of ['getEventStore', 'getEventStores', 'getNamespaces'] as const) {
69
+ describe(`when naming ${operation} with ${spanNames ?? 'default'} names`, () => {
70
+ let instance: ChronicleClient;
71
+ let span: ReadableSpan;
72
+ beforeEach(async () => {
73
+ instance = client(spanNames === undefined ? undefined : { spanNames });
74
+ if (operation === 'getNamespaces') {
75
+ const store = await instance.getEventStore('store', 'namespace');
76
+ telemetry.spans.reset();
77
+ await store.getNamespaces();
78
+ } else if (operation === 'getEventStore') {
79
+ await instance.getEventStore('store', 'namespace');
80
+ } else {
81
+ await instance.getEventStores();
82
+ }
83
+ span = telemetry.spans.getFinishedSpans()[0];
84
+ });
85
+ afterEach(() => instance.dispose());
86
+ it('should emit exactly one client span with the selected name and shared scope', () => {
87
+ telemetry.spans.getFinishedSpans().should.have.lengthOf(1);
88
+ span.name.should.equal(expected[operation]);
89
+ span.kind.should.equal(SpanKind.CLIENT);
90
+ span.instrumentationScope.name.should.equal('Cratis.Chronicle.Client');
91
+ should.equal(span.instrumentationScope.version, clientVersion);
92
+ });
93
+ if (operation !== 'getEventStores') {
94
+ it('should retain both event store attributes', () => {
95
+ span.attributes.should.include({ 'chronicle.event_store': 'store', 'cratis.event_store.name': 'store' });
96
+ });
97
+ }
98
+ });
99
+ }
100
+ }
101
+
102
+ describe('when interleaving two clients with different span naming modes', () => {
103
+ let legacy: ChronicleClient;
104
+ let convention: ChronicleClient;
105
+ let spans: ReadableSpan[];
106
+ beforeEach(async () => {
107
+ legacy = client({ spanNames: 'legacy' });
108
+ convention = client({ spanNames: 'convention' });
109
+ const [legacyStore, conventionStore] = await Promise.all([
110
+ legacy.getEventStore('legacy-store'), convention.getEventStore('convention-store')
111
+ ]);
112
+ await Promise.all([convention.getEventStores(), legacy.getEventStores()]);
113
+ await Promise.all([legacyStore.getNamespaces(), conventionStore.getNamespaces()]);
114
+ await conventionStore.eventLog.append('source', new Recorded());
115
+ await legacyStore.eventLog.append('source', new Recorded());
116
+ const id = new EventSequenceId('dynamic');
117
+ await legacyStore.getEventSequence(id).append('source', new Recorded());
118
+ await conventionStore.getEventSequence(id).append('source', new Recorded());
119
+ await legacy.getEventStore('legacy-store');
120
+ await convention.getEventStore('convention-store');
121
+ spans = telemetry.spans.getFinishedSpans();
122
+ });
123
+ afterEach(() => { legacy.dispose(); convention.dispose(); });
124
+ it('should keep each client and its cached stores in its own mode', () => {
125
+ spans.should.have.lengthOf(12);
126
+ spans.filter(span => span.attributes['cratis.event_store.name'] === 'legacy-store').map(span => span.name).should.deep.equal([
127
+ legacySpans.getEventStore, legacySpans.getNamespaces, legacySpans.append, legacySpans.append, legacySpans.getEventStore
128
+ ]);
129
+ spans.filter(span => span.attributes['cratis.event_store.name'] === 'convention-store').map(span => span.name).should.deep.equal([
130
+ conventionSpans.getEventStore, conventionSpans.getNamespaces, conventionSpans.append, conventionSpans.append, conventionSpans.getEventStore
131
+ ]);
132
+ spans.map(span => span.name).should.include(legacySpans.getEventStores).and.include(conventionSpans.getEventStores);
133
+ });
134
+ it('should use the same tracer scope in both modes', () => {
135
+ new Set(spans.map(span => `${span.instrumentationScope.name}/${span.instrumentationScope.version}`)).should.deep.equal(
136
+ new Set([`Cratis.Chronicle.Client/${clientVersion}`]));
137
+ });
138
+ });
@@ -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 { afterEach, beforeEach, chai, describe, it } from 'vitest';
5
+ import type { Attributes } from '@opentelemetry/api';
6
+ import type { ChronicleClient } from '../../../ChronicleClient.js';
7
+ import { client } from '../given/a_client.fixture.js';
8
+ import { telemetrySession } from '../given/a_telemetry_session.fixture.js';
9
+
10
+ chai.should();
11
+ const telemetry = telemetrySession();
12
+
13
+ for (const spanNames of ['legacy', 'convention'] as const) {
14
+ for (const hasEvents of [true, false]) {
15
+ describe(`when recording event presence ${hasEvents} with ${spanNames} span names`, () => {
16
+ let instance: ChronicleClient;
17
+ let attributes: Attributes;
18
+ beforeEach(async () => {
19
+ instance = client({ spanNames });
20
+ const store = await instance.getEventStore('store');
21
+ telemetry.spans.reset();
22
+ await store.eventLog.hasEventsFor(hasEvents ? 'source' : 'empty');
23
+ attributes = telemetry.spans.getFinishedSpans()[0].attributes;
24
+ });
25
+ afterEach(() => instance.dispose());
26
+ it('should record the boolean under both the legacy and product-only names', () => {
27
+ attributes.should.include({
28
+ 'chronicle.has_events': hasEvents,
29
+ 'cratis.chronicle.event_sequence.has_events': hasEvents
30
+ });
31
+ });
32
+ });
33
+ }
34
+ describe(`when completing a stream with ${spanNames} span names`, () => {
35
+ let instance: ChronicleClient;
36
+ let attributes: Attributes;
37
+ beforeEach(async () => {
38
+ instance = client({ spanNames });
39
+ const store = await instance.getEventStore('store');
40
+ telemetry.spans.reset();
41
+ await store.eventLog.completeStream('orders', 'stream-1');
42
+ attributes = telemetry.spans.getFinishedSpans()[0].attributes;
43
+ });
44
+ afterEach(() => instance.dispose());
45
+ it('should record the stream type under both names', () => {
46
+ attributes.should.include({ 'chronicle.event_stream_type': 'orders', 'cratis.chronicle.event_stream.type': 'orders' });
47
+ });
48
+ it('should record the stream identifier under both names', () => {
49
+ attributes.should.include({ 'chronicle.event_stream_id': 'stream-1', 'cratis.chronicle.event_stream.id': 'stream-1' });
50
+ });
51
+ });
52
+ }
@@ -6,9 +6,17 @@ import { ChronicleTracer } from '../Tracing.js';
6
6
  import { correlationIdManager } from '../correlation/index.js';
7
7
  import { CorrelationId } from '../correlation/CorrelationId.js';
8
8
  import { WellKnownTelemetryNames } from '../WellKnownTelemetryNames.js';
9
+ import type { ChronicleTelemetryOptions } from './ChronicleTelemetryOptions.js';
9
10
 
10
11
  /** Opens one client span, scoping the resolved business correlation without generating a new one. */
11
- export function observeOperation<T>(name: string, action: (span: Span) => Promise<T>, correlationId?: { toString(): string }): Promise<T> {
12
+ export function observeOperation<T>(
13
+ operation: keyof typeof WellKnownTelemetryNames.conventionSpans,
14
+ telemetry: ChronicleTelemetryOptions | undefined,
15
+ action: (span: Span) => Promise<T>,
16
+ correlationId?: { toString(): string }
17
+ ): Promise<T> {
18
+ const names = telemetry?.spanNames === 'convention' ? WellKnownTelemetryNames.conventionSpans : WellKnownTelemetryNames.spans;
19
+ const name = names[operation];
12
20
  const resolved = correlationId ? new CorrelationId(correlationId.toString()) : correlationIdManager.scoped;
13
21
  const run = () => {
14
22
  const active = context.active();
@@ -149,7 +149,7 @@ export class EventScenario {
149
149
  }
150
150
  const sequence = new InProcessEventSequence(options, eventTypes, constraints);
151
151
  this.eventSequence = sequence;
152
- registerScenarioSeed(this, (source, events) => sequence.seed(source, events));
152
+ registerScenarioSeed(this, (source, events, options) => sequence.seed(source, events, options));
153
153
  this.given = new EventScenarioGivenBuilder(this);
154
154
  this.when = new EventScenarioWhenBuilder(this);
155
155
  }
@@ -1,13 +1,14 @@
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 { seedScenario } from './EventScenarioSeed.js';
6
7
 
7
8
  /** Seeds events in order through individual single appends, as in the .NET given builder. */
8
9
  export class EventScenarioGiven {
9
- constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
10
+ constructor(private readonly _scenario: EventScenario, private readonly _id: string, private readonly _options?: AppendOptions) {}
10
11
  async events(...events: object[]): Promise<void> {
11
- await seedScenario(this._scenario, this._id, events);
12
+ await seedScenario(this._scenario, this._id, events, this._options);
12
13
  }
13
14
  }
@@ -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 { EventScenarioGiven } from './EventScenarioGiven.js';
6
7
 
7
8
  /** Selects the source for setup events. */
8
9
  export class EventScenarioGivenBuilder {
9
10
  constructor(private readonly _scenario: EventScenario) {}
10
- forEventSource(id: string): EventScenarioGiven { return new EventScenarioGiven(this._scenario, id); }
11
+ forEventSource(id: string, options?: AppendOptions): EventScenarioGiven { return new EventScenarioGiven(this._scenario, id, options); }
11
12
  }
@@ -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
+ }