@cratis/chronicle 6.20.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 (44) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +6 -100
  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/testing/EventScenario.d.ts +1 -1
  13. package/dist/testing/EventScenario.d.ts.map +1 -1
  14. package/dist/testing/EventScenario.js +7 -4
  15. package/dist/testing/EventScenario.js.map +1 -1
  16. package/dist/testing/EventScenarioGiven.d.ts +1 -1
  17. package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
  18. package/dist/testing/EventScenarioGiven.js +2 -5
  19. package/dist/testing/EventScenarioGiven.js.map +1 -1
  20. package/dist/testing/EventScenarioSeed.d.ts +2 -2
  21. package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
  22. package/dist/testing/EventScenarioSeed.js +2 -2
  23. package/dist/testing/EventScenarioSeed.js.map +1 -1
  24. package/dist/testing/EventScenarioWhen.d.ts +1 -1
  25. package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
  26. package/dist/testing/EventScenarioWhen.js +2 -3
  27. package/dist/testing/EventScenarioWhen.js.map +1 -1
  28. package/dist/testing/InProcessEventSequence.d.ts +18 -7
  29. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  30. package/dist/testing/InProcessEventSequence.js +239 -42
  31. package/dist/testing/InProcessEventSequence.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/eventSequences/EventSequence.ts +10 -107
  34. package/eventSequences/createAppendNotification.ts +35 -0
  35. package/eventSequences/prepareBatchAppend.ts +63 -0
  36. package/package.json +1 -1
  37. package/testing/EventScenario.batches.spec.ts +537 -0
  38. package/testing/EventScenario.fixtures.spec.ts +2 -13
  39. package/testing/EventScenario.rejections.spec.ts +5 -39
  40. package/testing/EventScenario.ts +7 -5
  41. package/testing/EventScenarioGiven.ts +2 -5
  42. package/testing/EventScenarioSeed.ts +4 -4
  43. package/testing/EventScenarioWhen.ts +2 -4
  44. package/testing/InProcessEventSequence.ts +223 -38
@@ -0,0 +1,537 @@
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 } from 'node:fs';
6
+ import { chai, describe, it } from 'vitest';
7
+ import { field } from '@cratis/fundamentals';
8
+ import { causationManager, CausationType } from '../auditing/index.js';
9
+ import { Identity, identityProvider } from '../identity/index.js';
10
+ import type { AppendOptions } from '../eventSequences/AppendOptions.js';
11
+ import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
12
+ import { eventType } from '../events/eventTypeDecorator.js';
13
+ import { Tag } from '../events/Tag.js';
14
+ import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
15
+ import { EventScenario, UnsupportedEventSequenceOperation } from './index.js';
16
+
17
+ chai.should();
18
+ class OracleEventRecorded {
19
+ @field(String) name: string;
20
+ @field(Boolean) active: boolean;
21
+ constructor(name: string, active: boolean) { this.name = name; this.active = active; }
22
+ }
23
+ eventType('OracleEventRecorded')(OracleEventRecorded);
24
+ class AlternateRecorded {
25
+ @field(String) label: string;
26
+ constructor(label: string) { this.label = label; }
27
+ }
28
+ eventType('AlternateRecorded')(AlternateRecorded);
29
+ class UnregisteredRecorded {
30
+ @field(String) name = 'unregistered';
31
+ }
32
+ eventType('UnregisteredRecorded')(UnregisteredRecorded);
33
+
34
+ const fixedCorrelationId = '11111111-2222-3333-4444-555555555555';
35
+ const explicitCorrelationId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
36
+
37
+ type Entry = { source: string; type?: string; name?: string; active?: boolean; label?: string;
38
+ sourceType?: string; streamType?: string; streamId?: string; subject?: string; occurred?: string; tags?: string[] };
39
+ type Operation = { overload: string; events: Entry[]; options?: {
40
+ sourceType?: string; streamType?: string; streamId?: string; subject?: string; occurred?: string; correlationId?: string; tags?: string[] } };
41
+ type History = { sequence: string; source: string; sourceType: string; streamType: string; streamId: string;
42
+ store: string; namespace: string; observationState: number; causedByName: string; generation: number;
43
+ causation: Array<{ type: string; properties: Record<string, string> }>;
44
+ subject: string; occurredValid: boolean; explicitOccurred: string | null; correlationValid: boolean;
45
+ explicitCorrelation: string | null; tags: string[]; hash: string; type: string; content: object };
46
+ type Fixture = { operations: Operation[]; expected: { outcomes: Array<{ success?: boolean; sequences?: string[];
47
+ violations?: number; errors?: number; concurrencyViolation?: boolean; rejection?: string; given?: number;
48
+ sequentialSuccess?: boolean; lastSequence?: string }>;
49
+ history: History[]; notifications: Array<Array<{ sequence: string; source: string; type: string; success: boolean }>>;
50
+ notificationHistoryLengths: number[];
51
+ next: string; tail: string; tailA: string; tailByType: string;
52
+ fromOneByType: string[]; byRoute: string[]; byDefaultRoute: string[]; byAllStreamType: string[];
53
+ tailByAll: string; byCustomRoute: string[]; tailByRoute: string;
54
+ byStreamTypeOnly: string[]; tailByStreamTypeOnly: string;
55
+ byStreamIdOnly: string[]; tailByStreamIdOnly: string; bySourceTypeOnly: string[]; tailBySourceTypeOnly: string;
56
+ byMixedRoute: string[]; tailByMixedRoute: string } };
57
+ const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), 'utf8')) as Fixture;
58
+ const create = () => new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded, AlternateRecorded] }, constraints: 'disabled' });
59
+ const value = (entry: Entry) => entry.type === 'alternate' ? new AlternateRecorded(entry.label!) : new OracleEventRecorded(entry.name!, entry.active!);
60
+ const entries = (operation: Operation) => operation.events.map(entry => ({ eventSourceId: entry.source, event: value(entry),
61
+ eventSourceType: entry.sourceType, eventStreamType: entry.streamType, eventStreamId: entry.streamId,
62
+ subject: entry.subject, occurred: entry.occurred ? new Date(entry.occurred) : undefined, tags: entry.tags }));
63
+ const options = (operation: Operation) => operation.options && {
64
+ sourceType: operation.options.sourceType, streamType: operation.options.streamType, streamId: operation.options.streamId,
65
+ subject: operation.options.subject, occurred: operation.options.occurred ? new Date(operation.options.occurred) : undefined,
66
+ correlationId: operation.options.correlationId, tags: operation.options.tags
67
+ };
68
+
69
+ async function compareReads(scenario: EventScenario, expected: Fixture['expected']): Promise<void> {
70
+ (await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(expected.next);
71
+ (await scenario.eventSequence.getTailSequenceNumber()).value.toString().should.equal(expected.tail);
72
+ (await scenario.eventSequence.getTailSequenceNumber('A')).value.toString().should.equal(expected.tailA);
73
+ (await scenario.eventSequence.getTailSequenceNumber(undefined, undefined, undefined, undefined, [AlternateRecorded]))
74
+ .value.toString().should.equal(expected.tailByType);
75
+ (await scenario.eventSequence.getFromSequenceNumber(new EventSequenceNumber(1n), undefined, [AlternateRecorded]))
76
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.fromOneByType);
77
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [OracleEventRecorded], 'All', 'Default', 'Default'))
78
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byRoute);
79
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('B', [OracleEventRecorded], 'All', 'Default', 'Default'))
80
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byDefaultRoute);
81
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [AlternateRecorded], 'All'))
82
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byAllStreamType);
83
+ (await scenario.eventSequence.getTailSequenceNumber('A', undefined, 'All', undefined, [AlternateRecorded]))
84
+ .value.toString().should.equal(expected.tailByAll);
85
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('B', [OracleEventRecorded], 'Other', 'stream2', 'Custom'))
86
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byCustomRoute);
87
+ (await scenario.eventSequence.getTailSequenceNumber('B', 'Custom', 'Other', 'stream2', [OracleEventRecorded]))
88
+ .value.toString().should.equal(expected.tailByRoute);
89
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [AlternateRecorded], 'Archive'))
90
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byStreamTypeOnly);
91
+ (await scenario.eventSequence.getTailSequenceNumber('A', undefined, 'Archive'))
92
+ .value.toString().should.equal(expected.tailByStreamTypeOnly);
93
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [AlternateRecorded], undefined, 'stream1'))
94
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byStreamIdOnly);
95
+ (await scenario.eventSequence.getTailSequenceNumber('A', undefined, undefined, 'stream1'))
96
+ .value.toString().should.equal(expected.tailByStreamIdOnly);
97
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('B', [OracleEventRecorded], undefined, undefined, 'Custom'))
98
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.bySourceTypeOnly);
99
+ (await scenario.eventSequence.getTailSequenceNumber('B', 'Custom'))
100
+ .value.toString().should.equal(expected.tailBySourceTypeOnly);
101
+ (await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [AlternateRecorded], 'All', 'stream1'))
102
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(expected.byMixedRoute);
103
+ (await scenario.eventSequence.getTailSequenceNumber('A', undefined, 'All', 'stream1'))
104
+ .value.toString().should.equal(expected.tailByMixedRoute);
105
+ }
106
+
107
+ function compareHistory(scenario: EventScenario, expected: History[]): void {
108
+ scenario.appendedEvents.length.should.equal(expected.length);
109
+ scenario.appendedEvents.forEach((event, index) => {
110
+ const entry = expected[index];
111
+ ({ sequence: event.context.sequenceNumber.toString(), source: event.context.eventSourceId,
112
+ store: event.context.eventStore, namespace: event.context.namespace,
113
+ observationState: event.context.observationState, causedByName: event.context.causedBy?.name,
114
+ generation: event.eventType.generation.value,
115
+ sourceType: event.context.eventSourceType, streamType: event.context.eventStreamType,
116
+ streamId: event.context.eventStreamId, subject: event.context.subject,
117
+ hash: event.context.hash, type: event.eventType.id.value, content: event.content,
118
+ tags: event.context.tags.map(tag => tag.value) }).should.deep.equal({ sequence: entry.sequence,
119
+ source: entry.source, sourceType: entry.sourceType, streamType: entry.streamType, streamId: entry.streamId,
120
+ store: entry.store, namespace: entry.namespace, observationState: entry.observationState,
121
+ causedByName: entry.causedByName, generation: entry.generation,
122
+ subject: entry.subject, hash: entry.hash, type: entry.type, content: entry.content, tags: entry.tags });
123
+ (!Number.isNaN(event.context.occurred.getTime()) && event.context.occurred.getUTCFullYear() > 2020)
124
+ .should.equal(entry.occurredValid);
125
+ /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(event.context.correlationId)
126
+ .should.equal(entry.correlationValid);
127
+ if (entry.explicitOccurred) event.context.occurred.toISOString().should.equal(entry.explicitOccurred);
128
+ if (entry.explicitCorrelation) event.context.correlationId.should.equal(entry.explicitCorrelation);
129
+ const causation = event.context.causation.map(item => ({ type: item.type, properties: item.properties }));
130
+ if (entry.causation[0].type === 'Root') causation.should.deep.equal(entry.causation);
131
+ });
132
+ }
133
+
134
+ describe('when appending batches against committed kernel fixtures', () => {
135
+ it('should resolve omitted routes like the pinned kernel batch path', async () => {
136
+ const { routeCases, expected } = JSON.parse(readFileSync(new URL('./fixtures/batch-omitted-routes.json', import.meta.url), 'utf8')) as {
137
+ routeCases: Array<{ source: string; name: string; active: boolean; sourceType?: string; streamType?: string; streamId?: string }>;
138
+ expected: { sequences: string[]; routes: Array<{ source: string; sourceType: string; streamType: string; streamId: string }> };
139
+ };
140
+ const scenario = create();
141
+ // Explicit empty route values are outside the scenario's domain; omit only those fields here.
142
+ const supported = routeCases.filter(item => item.sourceType !== '' && item.streamType !== '' && item.streamId !== '');
143
+ const results = await scenario.appendMany(supported.map(item => ({ eventSourceId: item.source,
144
+ event: new OracleEventRecorded(item.name, item.active), eventSourceType: item.sourceType,
145
+ eventStreamType: item.streamType, eventStreamId: item.streamId })));
146
+ results.map(result => result.sequenceNumber.value.toString()).should.deep.equal(expected.sequences.slice(0, supported.length));
147
+ scenario.appendedEvents.map(item => ({ source: item.context.eventSourceId,
148
+ sourceType: item.context.eventSourceType, streamType: item.context.eventStreamType,
149
+ streamId: item.context.eventStreamId })).should.deep.equal(
150
+ expected.routes.filter(item => supported.some(entry => entry.source === item.source)));
151
+ expected.routes.length.should.equal(routeCases.length);
152
+ });
153
+
154
+ it('should match both overloads, entry-over-shared metadata, empty rejection and remaining reads', async () => {
155
+ const { operations, expected } = fixture('batches');
156
+ const scenario = create();
157
+ const iterator = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
158
+ let notificationIndex = 0;
159
+ for (const [index, operation] of operations.entries()) {
160
+ if (expected.outcomes[index].rejection) {
161
+ await scenario.appendMany('A', []).then(
162
+ () => { throw new Error('Expected the kernel-proven empty batch rejection'); },
163
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
164
+ );
165
+ continue;
166
+ }
167
+ const next = iterator.next();
168
+ const results = operation.overload === 'same'
169
+ ? await scenario.appendMany(operation.events[0].source, operation.events.map(value), options(operation))
170
+ : await scenario.eventSequence.appendMany(entries(operation), options(operation));
171
+ const notified = (await next).value!;
172
+ scenario.appendedEvents.length.should.equal(expected.notificationHistoryLengths[notificationIndex]);
173
+ notified.every(item => item.event.context.occurred === notified[0].event.context.occurred).should.be.true;
174
+ notified.map(item => ({ sequence: item.result.sequenceNumber.value.toString(),
175
+ source: item.event.context.eventSourceId, type: item.event.eventType.id.value,
176
+ success: item.result.isSuccess })).should.deep.equal(expected.notifications[notificationIndex++]);
177
+ results.map(result => result.sequenceNumber.value.toString()).should.deep.equal(expected.outcomes[index].sequences);
178
+ results.map(result => ({ success: result.isSuccess, violations: result.constraintViolations.length,
179
+ errors: result.errors.length, concurrencyViolation: result.concurrencyViolation !== undefined }))
180
+ .should.deep.equal(results.map(() => ({ success: expected.outcomes[index].success,
181
+ violations: expected.outcomes[index].violations, errors: expected.outcomes[index].errors,
182
+ concurrencyViolation: expected.outcomes[index].concurrencyViolation })));
183
+ for (const result of results) {
184
+ await result.waitForCompletion().then(
185
+ () => { throw new Error('Observers did not run but completion succeeded'); },
186
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; });
187
+ }
188
+ }
189
+ notificationIndex.should.equal(expected.notifications.length);
190
+ await iterator.return?.();
191
+ scenario.results.map(result => result.sequenceNumber.value.toString()).should.deep.equal(['0', '1', '2', '3', '4']);
192
+ scenario.then.results.should.deep.equal(scenario.results);
193
+ compareHistory(scenario, expected.history);
194
+ await compareReads(scenario, expected);
195
+ });
196
+
197
+ it('should seed multiple given events sequentially; TypeScript plural when remains an atomic batch', async () => {
198
+ const { operations, expected } = fixture('builders');
199
+ const scenario = create();
200
+ const seedCount = expected.outcomes.reduce((count, outcome) => count + (outcome.given ?? 0), 0);
201
+ const historyAtNotification: number[] = [];
202
+ const observing = (async () => {
203
+ for await (const notification of scenario.eventSequence.appendOperations) {
204
+ const index = historyAtNotification.length;
205
+ historyAtNotification.push(scenario.appendedEvents.length);
206
+ notification.map(item => ({ sequence: item.result.sequenceNumber.value.toString(),
207
+ source: item.event.context.eventSourceId, type: item.event.eventType.id.value,
208
+ success: item.result.isSuccess })).should.deep.equal(expected.notifications[index]);
209
+ if (historyAtNotification.length === seedCount) break;
210
+ }
211
+ })();
212
+ for (const [index, operation] of operations.entries()) {
213
+ if (operation.overload === 'given') {
214
+ await scenario.given.forEventSource('A').events(...operation.events.map(value));
215
+ scenario.appendedEvents.length.should.equal(expected.outcomes[index].given);
216
+ } else {
217
+ const result = await scenario.when.forEventSource('A').events(...operation.events.map(value));
218
+ const last = BigInt(expected.outcomes[index].lastSequence!);
219
+ result.map(item => item.sequenceNumber.value.toString()).should.deep.equal(
220
+ operation.events.map((_, offset) => (last - BigInt(operation.events.length - offset - 1)).toString()));
221
+ result.every(item => item.isSuccess).should.equal(expected.outcomes[index].sequentialSuccess);
222
+ }
223
+ }
224
+ await observing;
225
+ historyAtNotification.should.deep.equal(expected.notificationHistoryLengths.slice(0, seedCount));
226
+ scenario.results.length.should.equal(2);
227
+ compareHistory(scenario, expected.history);
228
+ scenario.appendedEvents.forEach((event, index) => {
229
+ const type = index < seedCount ? 'TypeScriptClient.Append' : 'TypeScriptClient.AppendMany';
230
+ const properties = index < seedCount ? { eventType: expected.history[index].type }
231
+ : { count: String(operations.find(operation => operation.overload === 'whenSequential')!.events.length) };
232
+ event.context.causation.map(item => ({ type: item.type, properties: item.properties }))
233
+ .should.deep.equal([{ type: 'Root', properties: {} }, { type, properties }]);
234
+ });
235
+ await compareReads(scenario, expected);
236
+ });
237
+
238
+ it('should discard blank tags through production preparation on both batch overloads', async () => {
239
+ const scenario = create();
240
+ const blanks = ['', ' ', '\u00a0'];
241
+ await scenario.appendMany('A', [new OracleEventRecorded('same', true)], { tags: [...blanks, 'shared'] });
242
+ await scenario.appendMany([{ eventSourceId: 'B', event: new OracleEventRecorded('mixed', true),
243
+ tags: [...blanks, 'local'] }], { tags: [...blanks, 'shared'] });
244
+ scenario.appendedEvents.map(item => item.context.tags.map(tag => tag.value))
245
+ .should.deep.equal([['shared'], ['local', 'shared']]);
246
+ });
247
+
248
+ it('should reject concurrency scopes before committing a batch', async () => {
249
+ const scenario = create();
250
+ // A concurrency scope has no in-process kernel validation and must be rejected.
251
+ await scenario.appendMany('A', [new OracleEventRecorded('would rollback', true)],
252
+ { concurrencyScopes: { A: {} as never } }).then(
253
+ () => { throw new Error('Unproven constraint scope accepted'); },
254
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
255
+ );
256
+ scenario.appendedEvents.length.should.equal(0);
257
+ scenario.results.length.should.equal(0);
258
+ });
259
+
260
+ it('should reject overlapping batches and unsupported metadata before mutation', async () => {
261
+ const scenario = create();
262
+ const first = scenario.appendMany('A', [new OracleEventRecorded('first', true)]);
263
+ await scenario.appendMany('B', [new OracleEventRecorded('second', false)]).then(
264
+ () => { throw new Error('Overlapping batch accepted'); },
265
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
266
+ );
267
+ await first;
268
+ for (const options of [{ subject: 'bad subject' }, { tags: ['invalid|tag'] },
269
+ { concurrencyScope: {} as never }]) {
270
+ await scenario.appendMany('B', [new OracleEventRecorded('second', false)], options).then(
271
+ () => { throw new Error('Unproven metadata accepted'); },
272
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
273
+ );
274
+ }
275
+ scenario.appendedEvents.length.should.equal(1);
276
+ scenario.results.length.should.equal(1);
277
+ });
278
+
279
+ it('should reject a malformed entry before committing any of the batch or recording results', async () => {
280
+ const scenario = create();
281
+ await scenario.appendMany('A', [new OracleEventRecorded('valid', true), new OracleEventRecorded('"', true)]).then(
282
+ () => { throw new Error('Unsupported content was accepted'); },
283
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; (error as Error).message.should.include('appendMany.content'); }
284
+ );
285
+ scenario.appendedEvents.length.should.equal(0);
286
+ scenario.results.length.should.equal(0);
287
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(0n);
288
+ });
289
+
290
+ it('should use batch semantics for a one-event plural action', async () => {
291
+ const scenario = create();
292
+ const result = await scenario.when.forEventSource('A').events(new OracleEventRecorded('one', true));
293
+ result.map(item => item.sequenceNumber.value.toString()).should.deep.equal(['0']);
294
+ scenario.results.length.should.equal(1);
295
+ });
296
+
297
+ it('should honor the scenario correlation hook for single and plural actions and both batch overloads', async () => {
298
+ const scenario = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded, AlternateRecorded] },
299
+ constraints: 'disabled', correlationId: () => fixedCorrelationId });
300
+ await scenario.when.forEventSource('A').event(new OracleEventRecorded('single', true));
301
+ await scenario.when.forEventSource('A').events(new OracleEventRecorded('plural', true));
302
+ await scenario.appendMany('A', [new OracleEventRecorded('same source', true)]);
303
+ await scenario.appendMany([{ eventSourceId: 'B', event: new AlternateRecorded('separate source') }]);
304
+ scenario.appendedEvents.map(item => item.context.correlationId).should.deep.equal(Array(4).fill(fixedCorrelationId));
305
+ await scenario.appendMany('A', [new OracleEventRecorded('explicit same', true)], { correlationId: explicitCorrelationId });
306
+ await scenario.appendMany([{ eventSourceId: 'B', event: new AlternateRecorded('explicit entries') }],
307
+ { correlationId: explicitCorrelationId });
308
+ scenario.appendedEvents.slice(4).map(item => item.context.correlationId)
309
+ .should.deep.equal([explicitCorrelationId, explicitCorrelationId]);
310
+ });
311
+
312
+ it('should retain getter and non-enumerable shared options with the correlation hook on both batch overloads', async () => {
313
+ for (const overload of ['same', 'mixed'] as const) {
314
+ for (const shape of ['getter', 'non-enumerable'] as const) {
315
+ const scenario = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded] },
316
+ constraints: 'disabled', correlationId: () => fixedCorrelationId });
317
+ const metadata: AppendOptions = { sourceType: 'Group', streamType: 'Archive', streamId: 'stream1',
318
+ subject: 'shared', occurred: new Date('2025-01-02T03:04:05.000Z'), tags: ['shared'] };
319
+ const shared: AppendOptions = shape === 'getter'
320
+ ? Object.create(Object.defineProperties({}, Object.fromEntries(Object.entries(metadata).map(([key, value]) =>
321
+ [key, { get: () => value, configurable: true }])))) as AppendOptions
322
+ : Object.defineProperties({}, Object.fromEntries(Object.entries(metadata).map(([key, value]) =>
323
+ [key, { value, configurable: true, writable: true, enumerable: false }]))) as AppendOptions;
324
+ const event = new OracleEventRecorded(`${overload}-${shape}`, true);
325
+ const results = overload === 'same' ? await scenario.appendMany('A', [event], shared)
326
+ : await scenario.appendMany([{ eventSourceId: 'A', event }], shared);
327
+ results[0].isSuccess.should.be.true;
328
+ const context = scenario.appendedEvents[0].context;
329
+ context.eventSourceType.should.equal('Group');
330
+ context.eventStreamType.should.equal('Archive');
331
+ context.eventStreamId.should.equal('stream1');
332
+ context.subject.should.equal('shared');
333
+ context.occurred.toISOString().should.equal('2025-01-02T03:04:05.000Z');
334
+ context.tags.map(tag => tag.value).should.deep.equal(['shared']);
335
+ context.correlationId.should.equal(fixedCorrelationId);
336
+ }
337
+ }
338
+ });
339
+
340
+ it('should wrap a throwing correlation hook for single, both batch overloads and plural actions', async () => {
341
+ const scenario = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded] },
342
+ constraints: 'disabled', correlationId: () => { throw new Error('hook boom'); } });
343
+ for (const [operation, action] of [
344
+ ['append.serialization', () => scenario.append('A', new OracleEventRecorded('single', true))],
345
+ ['appendMany.serialization', () => scenario.appendMany('A', [new OracleEventRecorded('same', true)])],
346
+ ['appendMany.serialization', () => scenario.appendMany([
347
+ { eventSourceId: 'A', event: new OracleEventRecorded('mixed', true) }])],
348
+ ['appendMany.serialization', () => scenario.when.forEventSource('A').events(new OracleEventRecorded('plural', true))]
349
+ ] as const) {
350
+ await action().then(() => { throw new Error('Throwing hook accepted'); }, error => {
351
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
352
+ (error as Error).message.should.include(operation);
353
+ (error as Error).message.should.include('hook boom');
354
+ });
355
+ }
356
+ scenario.appendedEvents.length.should.equal(0);
357
+ scenario.results.length.should.equal(0);
358
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(0n);
359
+ await scenario.appendMany('A', [new OracleEventRecorded('override', true)], { correlationId: explicitCorrelationId });
360
+ scenario.appendedEvents[0].context.correlationId.should.equal(explicitCorrelationId);
361
+ });
362
+
363
+ it('should reject an invalid correlation hook without changing batch history', async () => {
364
+ const scenario = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded] },
365
+ constraints: 'disabled', correlationId: () => 'not-a-guid' });
366
+ for (const action of [() => scenario.appendMany('A', [new OracleEventRecorded('same', true)]),
367
+ () => scenario.appendMany([{ eventSourceId: 'A', event: new OracleEventRecorded('entries', true) }]),
368
+ () => scenario.when.forEventSource('A').events(new OracleEventRecorded('plural', true))]) {
369
+ await action().then(() => { throw new Error('Invalid hook accepted'); }, error => {
370
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
371
+ (error as Error).message.should.include('appendMany.correlationId');
372
+ });
373
+ }
374
+ scenario.appendedEvents.length.should.equal(0);
375
+ scenario.results.length.should.equal(0);
376
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(0n);
377
+ await scenario.appendMany('A', [new OracleEventRecorded('override', true)], { correlationId: explicitCorrelationId });
378
+ scenario.appendedEvents[0].context.correlationId.should.equal(explicitCorrelationId);
379
+ });
380
+
381
+ it('should reject an unregistered later given event without partially committing or notifying', async () => {
382
+ const scenario = create();
383
+ const iterator = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
384
+ const notification = iterator.next();
385
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('first', true),
386
+ new OracleEventRecorded('second', true), new UnregisteredRecorded()).then(
387
+ () => { throw new Error('Unregistered seed accepted'); }, error => {
388
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
389
+ (error as Error).message.should.include('append.event');
390
+ });
391
+ scenario.appendedEvents.length.should.equal(0);
392
+ scenario.results.length.should.equal(0);
393
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(0n);
394
+ await iterator.return?.();
395
+ (await notification).done.should.be.true;
396
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('recovered', true));
397
+ scenario.appendedEvents[0].context.sequenceNumber.should.equal(0n);
398
+ });
399
+
400
+ it('should expose only committed seed history to each setup notification', async () => {
401
+ const scenario = create();
402
+ const lengths: number[] = [];
403
+ const observed = (async () => {
404
+ for await (const notification of scenario.eventSequence.appendOperations) {
405
+ lengths.push(scenario.appendedEvents.length);
406
+ notification[0].event.context.sequenceNumber.should.equal(BigInt(lengths.length - 1));
407
+ if (lengths.length === 2) break;
408
+ }
409
+ })();
410
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('first', true),
411
+ new AlternateRecorded('second'));
412
+ await observed;
413
+ lengths.should.deep.equal([1, 2]);
414
+ });
415
+
416
+ it('should hold setup ownership between seeds against act and competing setup calls', async () => {
417
+ const scenario = create();
418
+ const setup = scenario.given.forEventSource('A').events(new OracleEventRecorded('first', true),
419
+ new OracleEventRecorded('second', true), new OracleEventRecorded('third', true));
420
+ await Promise.resolve();
421
+ await Promise.resolve();
422
+ const attempts = [
423
+ [scenario.appendMany('B', [new AlternateRecorded('act')]), 'appendMany'],
424
+ [scenario.append('B', new AlternateRecorded('act')), 'append'],
425
+ [scenario.given.forEventSource('B').events(new AlternateRecorded('setup')), 'given.events']
426
+ ] as const;
427
+ for (const [attempt, operation] of attempts) {
428
+ await attempt.then(() => { throw new Error('Overlapping operation accepted'); }, error => {
429
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
430
+ (error as Error).message.should.include(operation);
431
+ });
432
+ }
433
+ await setup;
434
+ scenario.appendedEvents.map(item => item.content).should.deep.equal([
435
+ { name: 'first', active: true }, { name: 'second', active: true }, { name: 'third', active: true }]);
436
+ scenario.results.length.should.equal(0);
437
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(3n);
438
+ });
439
+
440
+ it('should reject every unproven batch input before changing history, results or the next sequence', async () => {
441
+ const scenario = create();
442
+ await scenario.append('A', new OracleEventRecorded('baseline', true));
443
+ const event = () => new OracleEventRecorded('valid', true);
444
+ const entry = (extra: object): EventForEventSourceId => ({ eventSourceId: 'B', event: event(), ...extra });
445
+ const invalid = (extra: object): AppendOptions => extra as AppendOptions;
446
+ const cases: Array<[string, () => Promise<unknown>]> = [
447
+ ['appendMany.arguments', () => scenario.appendMany('B', undefined as unknown as object[])],
448
+ ['appendMany.occurred', () => scenario.appendMany('B', [event()], { occurred: new Date(NaN) })],
449
+ ['appendMany.occurred', () => scenario.appendMany([entry({ occurred: new Date('+010000-01-01T00:00:00.000Z') })])],
450
+ ['appendMany.correlationId', () => scenario.appendMany('B', [event()], { correlationId: 'bad-guid' })],
451
+ ['appendMany.entry', () => scenario.appendMany([entry({ unknown: 'value' })])],
452
+ ['appendMany.sourceType', () => scenario.appendMany([entry({ eventSourceType: 'bad source' })])],
453
+ ['appendMany.sourceType', () => scenario.appendMany([entry({ eventSourceType: '' })], { sourceType: 'Group' })],
454
+ ['appendMany.streamType', () => scenario.appendMany('B', [event()], { streamType: '' })],
455
+ ['appendMany.streamId', () => scenario.appendMany([entry({ eventStreamId: '' })], { streamId: 'stream1' })],
456
+ ['appendMany.subject', () => scenario.appendMany([entry({ subject: '' })], { subject: 'shared' })],
457
+ ['appendMany.streamType', () => scenario.appendMany([entry({ eventStreamType: 'bad stream' })])],
458
+ ['appendMany.streamId', () => scenario.appendMany([entry({ eventStreamId: 'bad stream' })])],
459
+ ['appendMany.event', () => scenario.appendMany('B', [new UnregisteredRecorded()])],
460
+ ['appendMany.source', () => scenario.appendMany([entry({ eventSourceId: 'bad source' })])],
461
+ ['appendMany.tags', () => scenario.appendMany('B', [event()], { tags: [new Tag('tag')] })],
462
+ ['appendMany.tags', () => scenario.appendMany([entry({ tags: [new Tag('tag')] })])],
463
+ ['appendMany.options', () => scenario.appendMany('B', [event()], invalid({ concurrencyScopes: { B: {} } }))]
464
+ ];
465
+ const history = scenario.appendedEvents;
466
+ const results = scenario.results;
467
+ const next = (await scenario.eventSequence.getNextSequenceNumber()).value;
468
+ for (const [operation, action] of cases) {
469
+ await action().then(() => { throw new Error(`Expected ${operation} rejection`); }, error => {
470
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
471
+ (error as Error).message.should.include(operation);
472
+ });
473
+ scenario.appendedEvents.should.deep.equal(history);
474
+ scenario.results.should.deep.equal(results);
475
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(next);
476
+ }
477
+ for (const context of [
478
+ () => identityProvider.run(new Identity('user', 'User'), () => scenario.appendMany('B', [event()])),
479
+ () => causationManager.run(new CausationType('Command'), { value: 'test' }, () => scenario.appendMany('B', [event()]))
480
+ ]) {
481
+ await context().then(() => { throw new Error('Ambient context accepted'); }, error => {
482
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
483
+ (error as Error).message.should.include('appendMany.context');
484
+ });
485
+ scenario.appendedEvents.should.deep.equal(history);
486
+ scenario.results.should.deep.equal(results);
487
+ (await scenario.eventSequence.getNextSequenceNumber()).value.should.equal(next);
488
+ }
489
+ });
490
+
491
+ it('should sample the clock once per defaulted batch entry and stage failures atomically', async () => {
492
+ let ticks = 0;
493
+ const scenario = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded] }, constraints: 'disabled',
494
+ clock: () => new Date(Date.UTC(2025, 0, 1, 0, 0, ++ticks)) });
495
+ await scenario.appendMany('A', [new OracleEventRecorded('first', true), new OracleEventRecorded('second', true)]);
496
+ await scenario.appendMany([
497
+ { eventSourceId: 'A', event: new OracleEventRecorded('third', true), occurred: new Date('2025-04-01T00:00:00.000Z') },
498
+ { eventSourceId: 'A', event: new OracleEventRecorded('fourth', true) }
499
+ ]);
500
+ scenario.appendedEvents.map(item => item.context.occurred.toISOString()).should.deep.equal([
501
+ '2025-01-01T00:00:01.000Z', '2025-01-01T00:00:02.000Z',
502
+ '2025-04-01T00:00:00.000Z', '2025-01-01T00:00:03.000Z']);
503
+ ticks.should.equal(3);
504
+ let invalidTicks = 0;
505
+ const invalidClock = new EventScenario({ artifacts: { eventTypes: [OracleEventRecorded] }, constraints: 'disabled',
506
+ clock: () => ++invalidTicks === 2 ? new Date(NaN) : new Date('2025-01-01T00:00:00.000Z') });
507
+ await invalidClock.appendMany('A', [new OracleEventRecorded('valid', true),
508
+ new OracleEventRecorded('invalid clock', true)]).then(() => { throw new Error('Invalid clock accepted'); }, error => {
509
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
510
+ (error as Error).message.should.include('appendMany.clock');
511
+ });
512
+ invalidClock.appendedEvents.length.should.equal(0);
513
+ invalidClock.results.length.should.equal(0);
514
+ });
515
+
516
+ it('should expose hot append notifications with the production client shape', async () => {
517
+ const scenario = create();
518
+ const iterator = scenario.eventSequence.appendOperations[Symbol.asyncIterator]();
519
+ const first = iterator.next();
520
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('seed', true));
521
+ const setup = (await first).value!;
522
+ setup.length.should.equal(1);
523
+ setup[0].event.content.should.be.instanceOf(OracleEventRecorded);
524
+ setup[0].event.context.eventSourceId.should.equal('A');
525
+ const next = iterator.next();
526
+ const results = await scenario.when.forEventSource('B').events(new OracleEventRecorded('act', true), new AlternateRecorded('second'));
527
+ const batch = (await next).value!;
528
+ batch.length.should.equal(2);
529
+ batch.map(item => item.result).should.deep.equal(results);
530
+ batch.map(item => item.event.context.sequenceNumber).should.deep.equal([1n, 2n]);
531
+ batch.map(item => item.event.context.eventSourceId).should.deep.equal(['B', 'B']);
532
+ // Production EventSequence.appendMany also uses one occurredAt for its entire notification array.
533
+ (batch[0].event.context.occurred === batch[1].event.context.occurred).should.be.true;
534
+ await iterator.return?.();
535
+ scenario.results.length.should.equal(2);
536
+ });
537
+ });
@@ -55,7 +55,8 @@ interface Fixture {
55
55
  };
56
56
  }
57
57
  const directory = new URL('./fixtures/', import.meta.url);
58
- const fixtures = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => ({
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 => ({
59
60
  name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
60
61
  }));
61
62
  const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
@@ -186,18 +187,6 @@ describe('when appending against committed kernel event fixtures', () => {
186
187
  scenario.eventLog.should.equal(scenario.eventSequence);
187
188
  });
188
189
 
189
- it('should reject all batch entry points before mutation, including one-event plural actions', async () => {
190
- const scenario = makeScenario();
191
- await unsupportedAsync(() => scenario.eventSequence.appendMany('A', [new OracleEventRecorded('one', true)]), 'appendMany');
192
- await unsupportedAsync(() => scenario.eventSequence.appendMany([{ eventSourceId: 'A', event: new OracleEventRecorded('one', true) }]), 'appendMany');
193
- await unsupportedAsync(() => scenario.appendMany('A', [new OracleEventRecorded('one', true)]), 'appendMany');
194
- await unsupportedAsync(() => scenario.appendMany([{ eventSourceId: 'A', event: new OracleEventRecorded('one', true) }]), 'appendMany');
195
- await unsupportedAsync(() => scenario.when.forEventSource('A').events(new OracleEventRecorded('one', true)), 'when.events');
196
- await unsupportedAsync(() => scenario.when.forEventSource('A').events(), 'when.events');
197
- await unsupportedAsync(() => scenario.given.forEventSource('A').events(new OracleEventRecorded('one', true), new OracleEventRecorded('two', false)), 'given.events');
198
- await unsupportedAsync(() => scenario.given.forEventSource('A').events(), 'given.events');
199
- scenario.appendedEvents.length.should.equal(0);
200
- });
201
190
 
202
191
  it('should reject unproven schemas, event registrations and constraints before appending', async () => {
203
192
  unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenNumber] }, constraints: 'disabled' }), 'schema');
@@ -123,34 +123,12 @@ describe('when the fixture-bounded event sequence encounters unproven operations
123
123
  rejected(() => subject.eventSequence.transactional, 'transactional');
124
124
  clean(subject);
125
125
  });
126
- it('should reject append notifications', () => {
126
+ it('should reject unproven route filters and unknown event types', async () => {
127
127
  const subject = scenario();
128
- rejected(() => subject.eventSequence.appendOperations, 'appendOperations');
129
- clean(subject);
130
- });
131
- it('should reject source-type tail filters', async () => {
132
- const subject = scenario();
133
- await rejects(() => subject.eventSequence.getTailSequenceNumber('A', 'Default'), 'getTailSequenceNumber.filters');
134
- clean(subject);
135
- });
136
- it('should reject stream-type tail filters', async () => {
137
- const subject = scenario();
138
- await rejects(() => subject.eventSequence.getTailSequenceNumber('A', undefined, 'All'), 'getTailSequenceNumber.filters');
139
- clean(subject);
140
- });
141
- it('should reject stream-ID tail filters', async () => {
142
- const subject = scenario();
143
- await rejects(() => subject.eventSequence.getTailSequenceNumber('A', undefined, undefined, 'Default'), 'getTailSequenceNumber.filters');
144
- clean(subject);
145
- });
146
- it('should reject event-type tail filters', async () => {
147
- const subject = scenario();
148
- await rejects(() => subject.eventSequence.getTailSequenceNumber('A', undefined, undefined, undefined, [Recorded]), 'getTailSequenceNumber.filters');
149
- clean(subject);
150
- });
151
- it('should reject event-type sequence filters', async () => {
152
- const subject = scenario();
153
- await rejects(() => subject.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, 'A', [Recorded]), 'getFromSequenceNumber.filterEventTypes');
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');
154
132
  clean(subject);
155
133
  });
156
134
  for (const value of [-1n, EventSequenceNumber.unset.value + 1n]) {
@@ -253,18 +231,6 @@ describe('when the fixture-bounded event sequence encounters unproven operations
253
231
  await rejects(() => subject.append('A', new Recorded(), { subject: 'other' }), 'append.options');
254
232
  clean(subject);
255
233
  });
256
- for (const [name, filters] of [
257
- ['stream type', ['All', undefined, undefined]],
258
- ['stream ID', [undefined, 'Default', undefined]],
259
- ['source type', [undefined, undefined, 'Default']]
260
- ] as const) {
261
- it(`should reject ${name} source/type-read filters`, async () => {
262
- const subject = scenario();
263
- await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', [Recorded], ...filters),
264
- 'getForEventSourceIdAndEventTypes.filters');
265
- clean(subject);
266
- });
267
- }
268
234
  it('should reject empty source/type-read event types', async () => {
269
235
  const subject = scenario();
270
236
  await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', []), 'getForEventSourceIdAndEventTypes.filters');