@cratis/chronicle 6.19.0 → 6.21.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 (74) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +8 -109
  3. package/dist/eventSequences/EventSequence.js.map +1 -1
  4. package/dist/eventSequences/createAppendNotification.d.ts +15 -0
  5. package/dist/eventSequences/createAppendNotification.d.ts.map +1 -0
  6. package/dist/eventSequences/createAppendNotification.js +25 -0
  7. package/dist/eventSequences/createAppendNotification.js.map +1 -0
  8. package/dist/eventSequences/prepareBatchAppend.d.ts +31 -0
  9. package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -0
  10. package/dist/eventSequences/prepareBatchAppend.js +54 -0
  11. package/dist/eventSequences/prepareBatchAppend.js.map +1 -0
  12. package/dist/eventSequences/prepareSingleAppend.d.ts +12 -0
  13. package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -0
  14. package/dist/eventSequences/prepareSingleAppend.js +22 -0
  15. package/dist/eventSequences/prepareSingleAppend.js.map +1 -0
  16. package/dist/testing/EventScenario.d.ts +29 -0
  17. package/dist/testing/EventScenario.d.ts.map +1 -0
  18. package/dist/testing/EventScenario.js +63 -0
  19. package/dist/testing/EventScenario.js.map +1 -0
  20. package/dist/testing/EventScenarioGiven.d.ts +9 -0
  21. package/dist/testing/EventScenarioGiven.d.ts.map +1 -0
  22. package/dist/testing/EventScenarioGiven.js +16 -0
  23. package/dist/testing/EventScenarioGiven.js.map +1 -0
  24. package/dist/testing/EventScenarioGivenBuilder.d.ts +9 -0
  25. package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -0
  26. package/dist/testing/EventScenarioGivenBuilder.js +12 -0
  27. package/dist/testing/EventScenarioGivenBuilder.js.map +1 -0
  28. package/dist/testing/EventScenarioOptions.d.ts +21 -0
  29. package/dist/testing/EventScenarioOptions.d.ts.map +1 -0
  30. package/dist/testing/EventScenarioOptions.js +4 -0
  31. package/dist/testing/EventScenarioOptions.js.map +1 -0
  32. package/dist/testing/EventScenarioSeed.d.ts +5 -0
  33. package/dist/testing/EventScenarioSeed.d.ts.map +1 -0
  34. package/dist/testing/EventScenarioSeed.js +14 -0
  35. package/dist/testing/EventScenarioSeed.js.map +1 -0
  36. package/dist/testing/EventScenarioWhen.d.ts +11 -0
  37. package/dist/testing/EventScenarioWhen.d.ts.map +1 -0
  38. package/dist/testing/EventScenarioWhen.js +16 -0
  39. package/dist/testing/EventScenarioWhen.js.map +1 -0
  40. package/dist/testing/EventScenarioWhenBuilder.d.ts +9 -0
  41. package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -0
  42. package/dist/testing/EventScenarioWhenBuilder.js +12 -0
  43. package/dist/testing/EventScenarioWhenBuilder.js.map +1 -0
  44. package/dist/testing/InProcessEventSequence.d.ts +60 -0
  45. package/dist/testing/InProcessEventSequence.d.ts.map +1 -0
  46. package/dist/testing/InProcessEventSequence.js +462 -0
  47. package/dist/testing/InProcessEventSequence.js.map +1 -0
  48. package/dist/testing/UnsupportedEventSequenceOperation.d.ts +5 -0
  49. package/dist/testing/UnsupportedEventSequenceOperation.d.ts.map +1 -0
  50. package/dist/testing/UnsupportedEventSequenceOperation.js +10 -0
  51. package/dist/testing/UnsupportedEventSequenceOperation.js.map +1 -0
  52. package/dist/testing/index.d.ts +7 -0
  53. package/dist/testing/index.d.ts.map +1 -1
  54. package/dist/testing/index.js +6 -0
  55. package/dist/testing/index.js.map +1 -1
  56. package/dist/tsconfig.tsbuildinfo +1 -1
  57. package/eventSequences/EventSequence.ts +12 -119
  58. package/eventSequences/createAppendNotification.ts +35 -0
  59. package/eventSequences/prepareBatchAppend.ts +63 -0
  60. package/eventSequences/prepareSingleAppend.ts +25 -0
  61. package/package.json +1 -1
  62. package/testing/EventScenario.batches.spec.ts +537 -0
  63. package/testing/EventScenario.fixtures.spec.ts +260 -0
  64. package/testing/EventScenario.rejections.spec.ts +273 -0
  65. package/testing/EventScenario.ts +79 -0
  66. package/testing/EventScenarioGiven.ts +13 -0
  67. package/testing/EventScenarioGivenBuilder.ts +11 -0
  68. package/testing/EventScenarioOptions.ts +20 -0
  69. package/testing/EventScenarioSeed.ts +17 -0
  70. package/testing/EventScenarioWhen.ts +14 -0
  71. package/testing/EventScenarioWhenBuilder.ts +11 -0
  72. package/testing/InProcessEventSequence.ts +460 -0
  73. package/testing/UnsupportedEventSequenceOperation.ts +10 -0
  74. package/testing/index.ts +7 -0
@@ -0,0 +1,260 @@
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 { readFileSync, readdirSync } from 'node:fs';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { field } from '@cratis/fundamentals';
8
+ import { eventType } from '../events/eventTypeDecorator.js';
9
+ import { unique } from '../events/constraints/unique.js';
10
+ import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
11
+ import { CorrelationId, correlationIdManager } from '../correlation/index.js';
12
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
13
+
14
+ chai.should();
15
+
16
+ class OracleEventRecorded {
17
+ @field(String) name: string;
18
+ @field(Boolean) active: boolean;
19
+ constructor(name: string, active: boolean) { this.name = name; this.active = active; }
20
+ }
21
+ eventType('OracleEventRecorded')(OracleEventRecorded);
22
+ class AlternateRecorded {
23
+ @field(String) label: string;
24
+ constructor(label: string) { this.label = label; }
25
+ }
26
+ eventType('AlternateRecorded')(AlternateRecorded);
27
+ class BoundaryRecorded {
28
+ @field(String) firstName: string;
29
+ @field(String) label: string;
30
+ constructor(firstName: string, label: string) { this.firstName = firstName; this.label = label; }
31
+ }
32
+ eventType('BoundaryRecorded')(BoundaryRecorded);
33
+ class UnknownEvent {}
34
+ class UnprovenNumber {
35
+ @field(Number) amount = 1;
36
+ }
37
+ eventType('UnprovenNumber')(UnprovenNumber);
38
+ class ConstrainedEvent {
39
+ @field(String) value = 'value';
40
+ }
41
+ eventType('ConstrainedEvent')(ConstrainedEvent);
42
+ unique()(ConstrainedEvent);
43
+
44
+ interface Fixture {
45
+ actions: Array<{ type?: string; source: string; name?: string; firstName?: string; active?: boolean; label?: string; occurred?: string; correlationId?: string; clientCausation?: boolean }>;
46
+ expected: {
47
+ results: Array<{ success: boolean; sequenceNumber: string; violations: number; errors: number;
48
+ concurrencyViolation: boolean; waitError: string }>;
49
+ history: Array<{ sequenceNumber: string; source: string; sourceType: string; streamType: string; streamId: string;
50
+ subject: string; store: string; namespace: string; eventType: string; generation: number; hash: string; content: object;
51
+ occurredValid: boolean; correlationValid: boolean; explicitOccurred: string | null; explicitCorrelation: string | null;
52
+ tagsCount: number; causedByName: string; observationState: number; causationCount: number;
53
+ causation: Array<{ type: string; properties: Record<string, string> }> }>;
54
+ next: string; tail: string; tailA: string; hasSourceA: boolean; sourceA: string[]; fromOne: string[]; byType: string[];
55
+ };
56
+ }
57
+ const directory = new URL('./fixtures/', import.meta.url);
58
+ const fixtures = readdirSync(directory).filter(name => name.endsWith('.json') &&
59
+ !['batches.json', 'batch-omitted-routes.json', 'batch-rollback.json', 'builders.json'].includes(name)).map(name => ({
60
+ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
61
+ }));
62
+ const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
63
+ const makeScenario = (action?: Fixture['actions'][number]) => new EventScenario({
64
+ artifacts, constraints: 'disabled',
65
+ clock: action?.occurred ? () => new Date(action.occurred!) : undefined,
66
+ correlationId: action?.correlationId ? () => action.correlationId! : undefined
67
+ });
68
+
69
+ async function unsupportedAsync(action: () => Promise<unknown>, operation: string): Promise<void> {
70
+ await action().then(
71
+ () => { throw new Error(`Expected ${operation} rejection`); },
72
+ error => {
73
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
74
+ (error as Error).message.should.include(operation);
75
+ (error as Error).message.should.include('Use a kernel-backed test.');
76
+ }
77
+ );
78
+ }
79
+
80
+ function unsupported(action: () => unknown, operation: string): void {
81
+ try { action(); throw new Error('Expected rejection'); }
82
+ catch (error) {
83
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
84
+ (error as Error).message.should.include(operation);
85
+ (error as Error).message.should.include('Use a kernel-backed test.');
86
+ }
87
+ }
88
+
89
+ describe('when appending against committed kernel event fixtures', () => {
90
+ it('should include a non-vacuous empty sequence, single append, alternate schema and interleaved sources', () => {
91
+ fixtures.map(item => item.name).should.deep.equal(['alternate.json', 'boundary.json', 'client-causation.json', 'empty.json', 'explicit-metadata.json', 'interleaved.json', 'single.json', 'source-tail-before-global.json']);
92
+ });
93
+
94
+ for (const { name, fixture } of fixtures) {
95
+ it(`should match the accepted event and essential read snapshots for ${name}`, async () => {
96
+ const scenario = makeScenario(fixture.actions[0]);
97
+ for (const action of fixture.actions) {
98
+ const event = action.type === 'alternate'
99
+ ? new AlternateRecorded(action.label!) : action.type === 'boundary'
100
+ ? new BoundaryRecorded(action.firstName!, action.label!) : new OracleEventRecorded(action.name!, action.active!);
101
+ const result = await scenario.append(action.source, event);
102
+ result.isSuccess.should.equal(true);
103
+ }
104
+ scenario.results.map(item => ({ success: item.isSuccess, sequenceNumber: item.sequenceNumber.value.toString(),
105
+ violations: item.constraintViolations.length, errors: item.errors.length,
106
+ concurrencyViolation: item.concurrencyViolation !== undefined }))
107
+ .should.deep.equal(fixture.expected.results.map(({ waitError: _waitError, ...result }) => result));
108
+ for (const [index, result] of scenario.results.entries()) {
109
+ fixture.expected.results[index].waitError.should.equal('CannotWaitForObserverCompletion');
110
+ await result.waitForCompletion().then(
111
+ () => { throw new Error('Observers did not run but completion succeeded'); },
112
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
113
+ );
114
+ }
115
+ const history = scenario.appendedEvents;
116
+ history.length.should.equal(fixture.expected.history.length);
117
+ for (let index = 0; index < history.length; index++) {
118
+ const actual = history[index];
119
+ const expected = fixture.expected.history[index];
120
+ ({ sequenceNumber: actual.context.sequenceNumber.toString(), source: actual.context.eventSourceId,
121
+ sourceType: actual.context.eventSourceType, streamType: actual.context.eventStreamType,
122
+ streamId: actual.context.eventStreamId, subject: actual.context.subject,
123
+ store: actual.context.eventStore, namespace: actual.context.namespace,
124
+ eventType: actual.eventType.id.value, generation: actual.eventType.generation.value,
125
+ hash: actual.context.hash, content: actual.content }).should.deep.equal({
126
+ sequenceNumber: expected.sequenceNumber, source: expected.source,
127
+ sourceType: expected.sourceType, streamType: expected.streamType, streamId: expected.streamId,
128
+ subject: expected.subject, store: expected.store, namespace: expected.namespace,
129
+ eventType: expected.eventType, generation: expected.generation, hash: expected.hash, content: expected.content
130
+ });
131
+ (actual.context.occurred instanceof Date && !Number.isNaN(actual.context.occurred.getTime())).should.equal(expected.occurredValid);
132
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(actual.context.correlationId)
133
+ .should.equal(expected.correlationValid);
134
+ actual.context.tags.length.should.equal(expected.tagsCount);
135
+ actual.context.causedBy!.name.should.equal(expected.causedByName);
136
+ actual.context.observationState!.should.equal(expected.observationState);
137
+ const causation = actual.context.causation.map(item => ({ type: item.type, properties: item.properties }));
138
+ if (fixture.actions[index].clientCausation) {
139
+ causation.should.deep.equal(expected.causation);
140
+ causation.length.should.equal(expected.causationCount);
141
+ } else {
142
+ // The .NET client's default root is Unknown; the TS client prepares Root + Append.
143
+ expected.causation.should.deep.equal([{ type: 'Unknown', properties: {} }]);
144
+ causation.should.deep.equal([
145
+ { type: 'Root', properties: {} },
146
+ { type: 'TypeScriptClient.Append', properties: { eventType: expected.eventType } }
147
+ ]);
148
+ }
149
+ if (expected.explicitOccurred) {
150
+ actual.context.occurred.toISOString().slice(0, 19).should.equal(expected.explicitOccurred.slice(0, 19));
151
+ actual.context.correlationId.should.equal(expected.explicitCorrelation);
152
+ }
153
+ }
154
+ (await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(fixture.expected.next);
155
+ (await scenario.eventSequence.getTailSequenceNumber()).value.toString().should.equal(fixture.expected.tail);
156
+ (await scenario.eventSequence.getTailSequenceNumber('A')).value.toString().should.equal(fixture.expected.tailA);
157
+ if (name === 'source-tail-before-global.json') {
158
+ fixture.expected.tailA.should.not.equal(fixture.expected.tail);
159
+ }
160
+ (await scenario.eventSequence.hasEventsFor('A')).should.equal(fixture.expected.hasSourceA);
161
+ const sourceEvents = await scenario.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, 'A');
162
+ sourceEvents.map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.sourceA);
163
+ sourceEvents.map(item => item.context.observationState).should.deep.equal(
164
+ fixture.expected.history.filter(item => item.source === 'A').map(item => item.observationState));
165
+ (await scenario.eventSequence.getFromSequenceNumber(new EventSequenceNumber(1n)))
166
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.fromOne);
167
+ const byType = await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [OracleEventRecorded]);
168
+ byType.map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.byType);
169
+ byType.map(item => item.context.observationState).should.deep.equal(
170
+ fixture.expected.history.filter(item => item.source === 'A' && item.eventType === 'OracleEventRecorded')
171
+ .map(item => item.observationState));
172
+ });
173
+ }
174
+
175
+ it('should keep setup out of results, protect history, and allow a single when action', async () => {
176
+ const scenario = makeScenario();
177
+ const seed = new OracleEventRecorded('seed', true);
178
+ await scenario.given.forEventSource('A').events(seed);
179
+ seed.name = 'changed';
180
+ (await scenario.when.forEventSource('B').event(new AlternateRecorded('act'))).isSuccess.should.be.true;
181
+ scenario.results.length.should.equal(1);
182
+ scenario.then.results.should.deep.equal(scenario.results);
183
+ scenario.appendedEvents[0].content.should.deep.equal({ name: 'seed', active: true });
184
+ (scenario.appendedEvents[0].content as { name: string }).name = 'changed again';
185
+ scenario.appendedEvents[0].content.should.deep.equal({ name: 'seed', active: true });
186
+ (await scenario).should.equal(scenario);
187
+ scenario.eventLog.should.equal(scenario.eventSequence);
188
+ });
189
+
190
+
191
+ it('should reject unproven schemas, event registrations and constraints before appending', async () => {
192
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenNumber] }, constraints: 'disabled' }), 'schema');
193
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } }), 'constraints');
194
+ unsupported(() => new EventScenario({ artifacts }), 'empty constraint catalog');
195
+ unsupported(() => new EventScenario({ artifacts, constraints: 'disabled', eventStore: 'other-store' }), 'eventStore');
196
+ const scenario = makeScenario();
197
+ try { await scenario.append('A', new UnknownEvent()); throw new Error('Expected rejection'); }
198
+ catch (error) { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
199
+ scenario.appendedEvents.length.should.equal(0);
200
+ });
201
+
202
+ it('should reject an act that overlaps unfinished setup without losing its result', async () => {
203
+ const scenario = makeScenario();
204
+ const pending = scenario.given.forEventSource('A').events(new OracleEventRecorded('seed', true));
205
+ await scenario.append('B', new AlternateRecorded('act')).then(
206
+ () => { throw new Error('Overlapping append succeeded'); },
207
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
208
+ );
209
+ await pending;
210
+ scenario.results.length.should.equal(0);
211
+ scenario.appendedEvents.length.should.equal(1);
212
+ });
213
+
214
+ it('should reject prototype getters and non-enumerable append metadata without mutation', async () => {
215
+ const scenario = makeScenario();
216
+ class OptionsWithGetters {
217
+ get subject() { return 'different-subject'; }
218
+ get streamId() { return 'different-stream'; }
219
+ }
220
+ await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), new OptionsWithGetters()), 'append.options');
221
+ const hidden = Object.defineProperty({}, 'subject', { value: 'different-subject', enumerable: false });
222
+ await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), hidden), 'append.options');
223
+ scenario.appendedEvents.length.should.equal(0);
224
+ scenario.results.length.should.equal(0);
225
+ });
226
+
227
+ it('should reject malformed hook and ambient correlation IDs before mutation', async () => {
228
+ const malformed = 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz';
229
+ const fromHook = new EventScenario({ artifacts, constraints: 'disabled', correlationId: () => malformed });
230
+ await unsupportedAsync(() => fromHook.append('A', new OracleEventRecorded('one', true)), 'append.correlationId');
231
+ fromHook.appendedEvents.length.should.equal(0);
232
+ fromHook.results.length.should.equal(0);
233
+ const fromAmbient = makeScenario();
234
+ await correlationIdManager.run(new CorrelationId(malformed), () =>
235
+ unsupportedAsync(() => fromAmbient.append('A', new OracleEventRecorded('one', true)), 'append.correlationId'));
236
+ fromAmbient.appendedEvents.length.should.equal(0);
237
+ fromAmbient.results.length.should.equal(0);
238
+ });
239
+
240
+ it('should reject unproven blank and padded read source filters', async () => {
241
+ const scenario = makeScenario();
242
+ await scenario.append('A', new OracleEventRecorded('one', true));
243
+ for (const source of ['', ' ', ' A ']) {
244
+ await unsupportedAsync(() => scenario.eventSequence.hasEventsFor(source), 'hasEventsFor.source');
245
+ await unsupportedAsync(() => scenario.eventSequence.getTailSequenceNumber(source), 'getTailSequenceNumber.source');
246
+ await unsupportedAsync(() => scenario.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, source), 'getFromSequenceNumber.source');
247
+ await unsupportedAsync(() => scenario.eventSequence.getForEventSourceIdAndEventTypes(source, [OracleEventRecorded]), 'getForEventSourceIdAndEventTypes.source');
248
+ }
249
+ scenario.appendedEvents.length.should.equal(1);
250
+ scenario.results.length.should.equal(1);
251
+ });
252
+
253
+ it('should record direct act-phase append calls without reporting setup results', async () => {
254
+ const scenario = makeScenario();
255
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('seed', true));
256
+ await scenario.eventSequence.append('B', new AlternateRecorded('act'));
257
+ scenario.results.length.should.equal(1);
258
+ scenario.appendedEvents.length.should.equal(2);
259
+ });
260
+ });
@@ -0,0 +1,273 @@
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 { chai, describe, it } from 'vitest';
6
+ import { field } from '@cratis/fundamentals';
7
+ import { causationManager, CausationType } from '../auditing/index.js';
8
+ import { identityProvider, Identity } from '../identity/index.js';
9
+ import { eventType } from '../events/eventTypeDecorator.js';
10
+ import { tag } from '../events/tagDecorator.js';
11
+ import { unique } from '../events/constraints/unique.js';
12
+ import { removeConstraint } from '../events/constraints/removeConstraint.js';
13
+ import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
14
+ import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
15
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
16
+
17
+ chai.should();
18
+
19
+ class Recorded {
20
+ @field(String) name = 'recorded';
21
+ }
22
+ eventType('Recorded')(Recorded);
23
+ class PropertyUnique {
24
+ @field(String) @unique() name = 'value';
25
+ }
26
+ eventType('PropertyUnique')(PropertyUnique);
27
+ class RemovesConstraint {
28
+ @field(String) name = 'value';
29
+ }
30
+ eventType('RemovesConstraint')(RemovesConstraint);
31
+ removeConstraint('name')(RemovesConstraint);
32
+ class Tagged {
33
+ @field(String) name = 'value';
34
+ }
35
+ eventType('Tagged')(Tagged);
36
+ tag('tagged')(Tagged);
37
+ class LaterGeneration {
38
+ @field(String) name = 'value';
39
+ }
40
+ eventType('LaterGeneration', 2)(LaterGeneration);
41
+ class Tombstone {
42
+ @field(String) name = 'value';
43
+ }
44
+ eventType('Tombstone', 1, true)(Tombstone);
45
+ class CommaId {
46
+ @field(String) name = 'value';
47
+ }
48
+ eventType('Odd,Type')(CommaId);
49
+ class PaddedId {
50
+ @field(String) name = 'value';
51
+ }
52
+ eventType(' PaddedId ')(PaddedId);
53
+ class NonAsciiPaddedId {
54
+ @field(String) name = 'value';
55
+ }
56
+ eventType('\u0085NonAsciiPaddedId\u0085')(NonAsciiPaddedId);
57
+ class DuplicateId {
58
+ @field(String) name = 'value';
59
+ }
60
+ eventType('Recorded')(DuplicateId);
61
+
62
+ const scenario = () => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled' });
63
+ const clean = (subject: EventScenario) => {
64
+ subject.results.length.should.equal(0);
65
+ subject.appendedEvents.length.should.equal(0);
66
+ };
67
+ function rejected(action: () => unknown, operation: string): void {
68
+ try { action(); throw new Error(`Expected ${operation} rejection`); }
69
+ catch (error) {
70
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
71
+ (error as Error).message.should.include(operation);
72
+ (error as Error).message.should.include('Use a kernel-backed test.');
73
+ }
74
+ }
75
+ async function rejects(action: () => Promise<unknown>, operation: string): Promise<void> {
76
+ await action().then(
77
+ () => { throw new Error(`Expected ${operation} rejection`); },
78
+ error => {
79
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
80
+ (error as Error).message.should.include(operation);
81
+ (error as Error).message.should.include('Use a kernel-backed test.');
82
+ }
83
+ );
84
+ }
85
+
86
+ describe('when the fixture-bounded event sequence encounters unproven operations', () => {
87
+ for (const [type, operation] of [
88
+ [PropertyUnique, 'artifacts.constraints'], [RemovesConstraint, 'artifacts.eventTypes.constraints'], [Tagged, 'tags'],
89
+ [LaterGeneration, 'artifacts.eventTypes'], [Tombstone, 'artifacts.eventTypes'],
90
+ [CommaId, 'artifacts.eventTypes.id'], [PaddedId, 'artifacts.eventTypes.id'],
91
+ [NonAsciiPaddedId, 'artifacts.eventTypes.id']
92
+ ] as const) {
93
+ it(`should reject ${type.name} during catalog validation`, () => {
94
+ const subject = scenario();
95
+ rejected(() => new EventScenario({ artifacts: { eventTypes: [type] },
96
+ ...(type === PropertyUnique || type === RemovesConstraint ? {} : { constraints: 'disabled' as const }) }), operation);
97
+ clean(subject);
98
+ });
99
+ }
100
+
101
+ it('should reject duplicate event IDs in the catalog', () => {
102
+ const subject = scenario();
103
+ rejected(() => new EventScenario({ artifacts: { eventTypes: [Recorded, DuplicateId] }, constraints: 'disabled' }), 'artifacts.eventTypes');
104
+ clean(subject);
105
+ });
106
+ it('should reject migrations', () => {
107
+ const subject = scenario();
108
+ rejected(() => new EventScenario({ artifacts: { eventTypes: [Recorded], eventTypeMigrations: [class Migration {}] }, constraints: 'disabled' }), 'artifacts.eventTypeMigrations');
109
+ clean(subject);
110
+ });
111
+ it('should reject custom namespace', () => {
112
+ const subject = scenario();
113
+ rejected(() => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', namespace: 'other' }), 'options.namespace');
114
+ clean(subject);
115
+ });
116
+ it('should reject custom sequence ID', () => {
117
+ const subject = scenario();
118
+ rejected(() => new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', eventSequenceId: new EventSequenceId('other') }), 'options.eventSequenceId');
119
+ clean(subject);
120
+ });
121
+ it('should reject transactional operations', () => {
122
+ const subject = scenario();
123
+ rejected(() => subject.eventSequence.transactional, 'transactional');
124
+ clean(subject);
125
+ });
126
+ it('should reject unproven route filters and unknown event types', async () => {
127
+ const subject = scenario();
128
+ await rejects(() => subject.eventSequence.getTailSequenceNumber('A', ' A '), 'getTailSequenceNumber.filters');
129
+ await rejects(() => subject.eventSequence.getTailSequenceNumber('A', undefined, undefined, undefined, [DuplicateId]), 'read.eventTypes');
130
+ await rejects(() => subject.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, 'A', [DuplicateId]), 'read.eventTypes');
131
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', [Recorded], ' A '), 'getForEventSourceIdAndEventTypes.filters');
132
+ clean(subject);
133
+ });
134
+ for (const value of [-1n, EventSequenceNumber.unset.value + 1n]) {
135
+ it(`should reject out-of-range sequence number ${value}`, async () => {
136
+ const subject = scenario();
137
+ await rejects(() => subject.eventSequence.getFromSequenceNumber(new EventSequenceNumber(value)), 'getFromSequenceNumber.sequenceNumber');
138
+ clean(subject);
139
+ });
140
+ }
141
+ it('should asynchronously reject redaction by sequence', async () => {
142
+ const subject = scenario();
143
+ await rejects(() => subject.eventSequence.redact(EventSequenceNumber.first, 'reason'), 'redact');
144
+ clean(subject);
145
+ });
146
+ it('should asynchronously reject redaction by source', async () => {
147
+ const subject = scenario();
148
+ await rejects(() => subject.eventSequence.redactForEventSource('A', 'reason'), 'redactForEventSource');
149
+ clean(subject);
150
+ });
151
+ it('should asynchronously reject stream completion', async () => {
152
+ const subject = scenario();
153
+ await rejects(() => subject.eventSequence.completeStream('All', 'Default'), 'completeStream');
154
+ clean(subject);
155
+ });
156
+ it('should asynchronously reject observer tail reads', async () => {
157
+ const subject = scenario();
158
+ await rejects(() => subject.eventSequence.getTailSequenceNumberForObserver(Recorded), 'getTailSequenceNumberForObserver');
159
+ clean(subject);
160
+ });
161
+ it('should reject ambient identity before append', async () => {
162
+ const subject = scenario();
163
+ await identityProvider.run(new Identity('user', 'User'), () => rejects(() => subject.append('A', new Recorded()), 'append.context'));
164
+ clean(subject);
165
+ });
166
+ it('should reject ambient causation before append', async () => {
167
+ const subject = scenario();
168
+ await causationManager.run(new CausationType('Command'), { value: 'test' }, () =>
169
+ rejects(() => subject.append('A', new Recorded()), 'append.context'));
170
+ clean(subject);
171
+ });
172
+ it('should reject an invalid scenario clock before append', async () => {
173
+ const subject = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', clock: () => new Date(NaN) });
174
+ await rejects(() => subject.append('A', new Recorded()), 'append.clock');
175
+ clean(subject);
176
+ });
177
+ for (const date of ['0000-12-31T23:59:59.999Z', '+010000-01-01T00:00:00.000Z']) {
178
+ it(`should reject out-of-range UTC occurrence ${date} without changing history or results`, async () => {
179
+ let clock = new Date('2024-01-01T00:00:00.000Z');
180
+ const subject = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', clock: () => clock });
181
+ await subject.append('A', new Recorded());
182
+ const history = subject.appendedEvents;
183
+ const results = subject.results;
184
+ clock = new Date(date);
185
+ await rejects(() => subject.append('B', new Recorded()), 'append.clock');
186
+ subject.appendedEvents.should.deep.equal(history);
187
+ subject.results.should.deep.equal(results);
188
+ });
189
+ }
190
+ for (const [name, value] of [
191
+ ['quote', '"'], ['backslash', '\\'], ['DEL', '\u007f'], ['uppercase accented letter', '\u00c9'],
192
+ ['decomposed accent', 'e\u0301'], ['Japanese', '日本'], ['tab', '\t']
193
+ ] as const) {
194
+ it(`should reject ${name} outside the fixture-backed content domain`, async () => {
195
+ const subject = scenario();
196
+ const event = new Recorded();
197
+ event.name = value;
198
+ await rejects(() => subject.append('A', event), 'append.content');
199
+ clean(subject);
200
+ });
201
+ }
202
+ for (const [name, value] of [['missing', undefined], ['numeric', 42]] as const) {
203
+ it(`should reject ${name} content`, async () => {
204
+ const subject = scenario();
205
+ const event = new Recorded();
206
+ Object.assign(event, { name: value });
207
+ await rejects(() => subject.append('A', event), 'append.content');
208
+ clean(subject);
209
+ });
210
+ }
211
+ it('should reject extra content fields', async () => {
212
+ const subject = scenario();
213
+ const event = Object.assign(new Recorded(), { extra: 'value' });
214
+ await rejects(() => subject.append('A', event), 'append.content');
215
+ clean(subject);
216
+ });
217
+ for (const source of ['a b', 'a|b', '\u00c9']) {
218
+ it(`should reject unsupported append source ${JSON.stringify(source)}`, async () => {
219
+ const subject = scenario();
220
+ await rejects(() => subject.append(source, new Recorded()), 'append.source');
221
+ clean(subject);
222
+ });
223
+ }
224
+ it('should reject plain correlation options', async () => {
225
+ const subject = scenario();
226
+ await rejects(() => subject.append('A', new Recorded(), { correlationId: '00000000-0000-0000-0000-000000000001' }), 'append.options');
227
+ clean(subject);
228
+ });
229
+ it('should reject plain subject options', async () => {
230
+ const subject = scenario();
231
+ await rejects(() => subject.append('A', new Recorded(), { subject: 'other' }), 'append.options');
232
+ clean(subject);
233
+ });
234
+ it('should reject empty source/type-read event types', async () => {
235
+ const subject = scenario();
236
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', []), 'getForEventSourceIdAndEventTypes.filters');
237
+ clean(subject);
238
+ });
239
+ it('should reject unregistered source/type-read event types', async () => {
240
+ const subject = scenario();
241
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', [DuplicateId]), 'read.eventTypes');
242
+ clean(subject);
243
+ });
244
+ it('should reject non-ASCII padded read sources before returning an incorrect result', async () => {
245
+ const subject = scenario();
246
+ await subject.append('A', new Recorded());
247
+ const history = subject.appendedEvents;
248
+ const results = subject.results;
249
+ for (const source of ['\u0085A\u0085', 'A\u00a0', 'A\u007f', 'A\u2000B']) {
250
+ await rejects(() => subject.eventSequence.hasEventsFor(source), 'hasEventsFor.source');
251
+ await rejects(() => subject.eventSequence.getTailSequenceNumber(source), 'getTailSequenceNumber.source');
252
+ await rejects(() => subject.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, source), 'getFromSequenceNumber.source');
253
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes(source, [Recorded]), 'getForEventSourceIdAndEventTypes.source');
254
+ }
255
+ subject.appendedEvents.should.deep.equal(history);
256
+ subject.results.should.deep.equal(results);
257
+ });
258
+ it('should reject overlapping act appends, including separate act entry points', async () => {
259
+ const subject = scenario();
260
+ const [first, second] = await Promise.allSettled([
261
+ subject.append('A', new Recorded()),
262
+ subject.eventLog.append('B', new Recorded())
263
+ ]);
264
+ first.status.should.equal('fulfilled');
265
+ second.status.should.equal('rejected');
266
+ if (second.status === 'rejected') {
267
+ (second.reason instanceof UnsupportedEventSequenceOperation).should.be.true;
268
+ (second.reason as Error).message.should.include('append');
269
+ }
270
+ subject.appendedEvents.length.should.equal(1);
271
+ subject.results.length.should.equal(1);
272
+ });
273
+ });
@@ -0,0 +1,79 @@
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 { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
5
+ import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
6
+ import type { AppendOptions } from '../eventSequences/AppendOptions.js';
7
+ import type { AppendResult } from '../eventSequences/AppendResult.js';
8
+ import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
9
+ import type { AppendedEvent } from '../events/AppendedEvent.js';
10
+ import type { IEventSequence } from '../eventSequences/IEventSequence.js';
11
+ import type { IEventLog } from '../eventSequences/IEventLog.js';
12
+ import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
13
+ import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
14
+ import { getUniqueEventMetadata, getUniquePropertyMetadata } from '../events/constraints/unique.js';
15
+ import type { EventScenarioOptions } from './EventScenarioOptions.js';
16
+ import { EventScenarioGivenBuilder } from './EventScenarioGivenBuilder.js';
17
+ import { EventScenarioWhenBuilder } from './EventScenarioWhenBuilder.js';
18
+ import { InProcessEventSequence } from './InProcessEventSequence.js';
19
+ import { registerScenarioSeed } from './EventScenarioSeed.js';
20
+ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
21
+
22
+ /** A kernel-free, fixture-bounded append scenario. No observer runs automatically. */
23
+ export class EventScenario {
24
+ readonly eventSequence: IEventSequence;
25
+ readonly given: EventScenarioGivenBuilder;
26
+ readonly when: EventScenarioWhenBuilder;
27
+
28
+ constructor(options: EventScenarioOptions = {}) {
29
+ const artifacts = options.artifacts ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default);
30
+ const eventTypes = [...artifacts.eventTypes];
31
+ if (options.artifacts?.constraints?.length === 0 && options.constraints !== 'disabled') {
32
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', 'empty constraint catalog',
33
+ 'An empty selected catalog cannot silently disable constraint discovery; set constraints: disabled explicitly.');
34
+ }
35
+ const discovered = options.artifacts?.constraints ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default).constraints;
36
+ if (options.constraints !== 'disabled' && (discovered.length || eventTypes.some(type => {
37
+ const metadata = getEventTypeMetadata(type);
38
+ return getUniqueEventMetadata(type) || [...(metadata?.members.keys() ?? [])].some(key => getUniquePropertyMetadata(type, key));
39
+ }))) {
40
+ throw new UnsupportedEventSequenceOperation('artifacts.constraints', discovered.map(type => type.name).join(', ') || 'event metadata',
41
+ 'Constraint definitions are rejected until kernel-backed constraint fixtures are available.');
42
+ }
43
+ if (artifacts.eventTypeMigrations?.length) {
44
+ throw new UnsupportedEventSequenceOperation('artifacts.eventTypeMigrations', artifacts.eventTypeMigrations.map(type => type.name).join(', '),
45
+ 'Migrations are not fixture-backed.');
46
+ }
47
+ const sequence = new InProcessEventSequence(options, eventTypes);
48
+ this.eventSequence = sequence;
49
+ registerScenarioSeed(this, (source, events) => sequence.seed(source, events));
50
+ this.given = new EventScenarioGivenBuilder(this);
51
+ this.when = new EventScenarioWhenBuilder(this);
52
+ }
53
+
54
+ /** Alias for the default event-log sequence only. */
55
+ get eventLog(): IEventLog {
56
+ if (this.eventSequence.id.value !== EventSequenceId.eventLog.value) {
57
+ throw new UnsupportedEventSequenceOperation('eventLog', this.eventSequence.id.value, 'Only the default sequence is an event log.');
58
+ }
59
+ return this.eventSequence;
60
+ }
61
+ async append(source: string, event: object, options?: AppendOptions): Promise<AppendResult> {
62
+ return this.eventSequence.append(source, event, options);
63
+ }
64
+ appendMany(source: string, events: object[], options?: AppendOptions): Promise<AppendResult[]>;
65
+ appendMany(events: EventForEventSourceId[], options?: AppendOptions): Promise<AppendResult[]>;
66
+ async appendMany(sourceOrEvents: string | EventForEventSourceId[], eventsOrOptions?: object[] | AppendOptions, options?: AppendOptions): Promise<AppendResult[]> {
67
+ if (typeof sourceOrEvents === 'string') {
68
+ return this.eventSequence.appendMany(sourceOrEvents, eventsOrOptions as object[], options);
69
+ }
70
+ return this.eventSequence.appendMany(sourceOrEvents, eventsOrOptions as AppendOptions | undefined);
71
+ }
72
+ get results(): readonly AppendResult[] { return (this.eventSequence as InProcessEventSequence).results; }
73
+ get appendedEvents(): readonly AppendedEvent[] { return (this.eventSequence as InProcessEventSequence).appendedEvents; }
74
+
75
+ /** Assertion view, not a callable Promise-like then method. */
76
+ get then(): { readonly results: readonly AppendResult[]; readonly appendedEvents: readonly AppendedEvent[] } {
77
+ return { results: this.results, appendedEvents: this.appendedEvents };
78
+ }
79
+ }
@@ -0,0 +1,13 @@
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 { EventScenario } from './EventScenario.js';
5
+ import { seedScenario } from './EventScenarioSeed.js';
6
+
7
+ /** Seeds events in order through individual single appends, as in the .NET given builder. */
8
+ export class EventScenarioGiven {
9
+ constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
10
+ async events(...events: object[]): Promise<void> {
11
+ await seedScenario(this._scenario, this._id, events);
12
+ }
13
+ }
@@ -0,0 +1,11 @@
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 { EventScenario } from './EventScenario.js';
5
+ import { EventScenarioGiven } from './EventScenarioGiven.js';
6
+
7
+ /** Selects the source for setup events. */
8
+ export class EventScenarioGivenBuilder {
9
+ constructor(private readonly _scenario: EventScenario) {}
10
+ forEventSource(id: string): EventScenarioGiven { return new EventScenarioGiven(this._scenario, id); }
11
+ }