@cratis/chronicle 6.19.0 → 6.20.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 (63) hide show
  1. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  2. package/dist/eventSequences/EventSequence.js +2 -9
  3. package/dist/eventSequences/EventSequence.js.map +1 -1
  4. package/dist/eventSequences/prepareSingleAppend.d.ts +12 -0
  5. package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -0
  6. package/dist/eventSequences/prepareSingleAppend.js +22 -0
  7. package/dist/eventSequences/prepareSingleAppend.js.map +1 -0
  8. package/dist/testing/EventScenario.d.ts +29 -0
  9. package/dist/testing/EventScenario.d.ts.map +1 -0
  10. package/dist/testing/EventScenario.js +60 -0
  11. package/dist/testing/EventScenario.js.map +1 -0
  12. package/dist/testing/EventScenarioGiven.d.ts +9 -0
  13. package/dist/testing/EventScenarioGiven.d.ts.map +1 -0
  14. package/dist/testing/EventScenarioGiven.js +19 -0
  15. package/dist/testing/EventScenarioGiven.js.map +1 -0
  16. package/dist/testing/EventScenarioGivenBuilder.d.ts +9 -0
  17. package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -0
  18. package/dist/testing/EventScenarioGivenBuilder.js +12 -0
  19. package/dist/testing/EventScenarioGivenBuilder.js.map +1 -0
  20. package/dist/testing/EventScenarioOptions.d.ts +21 -0
  21. package/dist/testing/EventScenarioOptions.d.ts.map +1 -0
  22. package/dist/testing/EventScenarioOptions.js +4 -0
  23. package/dist/testing/EventScenarioOptions.js.map +1 -0
  24. package/dist/testing/EventScenarioSeed.d.ts +5 -0
  25. package/dist/testing/EventScenarioSeed.d.ts.map +1 -0
  26. package/dist/testing/EventScenarioSeed.js +14 -0
  27. package/dist/testing/EventScenarioSeed.js.map +1 -0
  28. package/dist/testing/EventScenarioWhen.d.ts +11 -0
  29. package/dist/testing/EventScenarioWhen.d.ts.map +1 -0
  30. package/dist/testing/EventScenarioWhen.js +17 -0
  31. package/dist/testing/EventScenarioWhen.js.map +1 -0
  32. package/dist/testing/EventScenarioWhenBuilder.d.ts +9 -0
  33. package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -0
  34. package/dist/testing/EventScenarioWhenBuilder.js +12 -0
  35. package/dist/testing/EventScenarioWhenBuilder.js.map +1 -0
  36. package/dist/testing/InProcessEventSequence.d.ts +49 -0
  37. package/dist/testing/InProcessEventSequence.d.ts.map +1 -0
  38. package/dist/testing/InProcessEventSequence.js +265 -0
  39. package/dist/testing/InProcessEventSequence.js.map +1 -0
  40. package/dist/testing/UnsupportedEventSequenceOperation.d.ts +5 -0
  41. package/dist/testing/UnsupportedEventSequenceOperation.d.ts.map +1 -0
  42. package/dist/testing/UnsupportedEventSequenceOperation.js +10 -0
  43. package/dist/testing/UnsupportedEventSequenceOperation.js.map +1 -0
  44. package/dist/testing/index.d.ts +7 -0
  45. package/dist/testing/index.d.ts.map +1 -1
  46. package/dist/testing/index.js +6 -0
  47. package/dist/testing/index.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/eventSequences/EventSequence.ts +2 -12
  50. package/eventSequences/prepareSingleAppend.ts +25 -0
  51. package/package.json +1 -1
  52. package/testing/EventScenario.fixtures.spec.ts +271 -0
  53. package/testing/EventScenario.rejections.spec.ts +307 -0
  54. package/testing/EventScenario.ts +77 -0
  55. package/testing/EventScenarioGiven.ts +16 -0
  56. package/testing/EventScenarioGivenBuilder.ts +11 -0
  57. package/testing/EventScenarioOptions.ts +20 -0
  58. package/testing/EventScenarioSeed.ts +17 -0
  59. package/testing/EventScenarioWhen.ts +16 -0
  60. package/testing/EventScenarioWhenBuilder.ts +11 -0
  61. package/testing/InProcessEventSequence.ts +275 -0
  62. package/testing/UnsupportedEventSequenceOperation.ts +10 -0
  63. package/testing/index.ts +7 -0
@@ -0,0 +1,25 @@
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 { Guid, JsonSerializer } from '@cratis/fundamentals';
5
+ import { getEventTypeFor } from '../events/eventTypeDecorator.js';
6
+ import { getTagsFor } from '../events/tagDecorator.js';
7
+ import { mergeTags } from '../events/mergeTags.js';
8
+ import { identityProvider } from '../identity/index.js';
9
+ import { causationManager, CausationType } from '../auditing/index.js';
10
+ import { correlationIdManager } from '../correlation/index.js';
11
+ import type { AppendOptions } from './AppendOptions.js';
12
+
13
+ /** The production client and in-process scenario share the single-append serialization boundary. */
14
+ export function prepareSingleAppend(event: object, options?: AppendOptions) {
15
+ const eventType = getEventTypeFor(event.constructor as Function);
16
+ const correlationId = options?.correlationId === undefined
17
+ ? Guid.as(correlationIdManager.current.value)
18
+ : Guid.as(options.correlationId);
19
+ const content = JsonSerializer.serialize(event);
20
+ const tags = mergeTags(getTagsFor(event.constructor as Function), options?.tags);
21
+ const causationChain = causationManager.run(CausationType.appendEvent, { eventType: eventType.id.value },
22
+ () => causationManager.getCurrentChain());
23
+ const identity = identityProvider.getCurrent();
24
+ return { eventType, correlationId, content, tags, causationChain, identity };
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.19.0",
3
+ "version": "6.20.0",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -0,0 +1,271 @@
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')).map(name => ({
59
+ name, fixture: JSON.parse(readFileSync(new URL(name, directory), 'utf8')) as Fixture
60
+ }));
61
+ const artifacts = { eventTypes: [OracleEventRecorded, AlternateRecorded, BoundaryRecorded], constraints: [] };
62
+ const makeScenario = (action?: Fixture['actions'][number]) => new EventScenario({
63
+ artifacts, constraints: 'disabled',
64
+ clock: action?.occurred ? () => new Date(action.occurred!) : undefined,
65
+ correlationId: action?.correlationId ? () => action.correlationId! : undefined
66
+ });
67
+
68
+ async function unsupportedAsync(action: () => Promise<unknown>, operation: string): Promise<void> {
69
+ await action().then(
70
+ () => { throw new Error(`Expected ${operation} rejection`); },
71
+ error => {
72
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
73
+ (error as Error).message.should.include(operation);
74
+ (error as Error).message.should.include('Use a kernel-backed test.');
75
+ }
76
+ );
77
+ }
78
+
79
+ function unsupported(action: () => unknown, operation: string): void {
80
+ try { action(); throw new Error('Expected rejection'); }
81
+ catch (error) {
82
+ (error instanceof UnsupportedEventSequenceOperation).should.be.true;
83
+ (error as Error).message.should.include(operation);
84
+ (error as Error).message.should.include('Use a kernel-backed test.');
85
+ }
86
+ }
87
+
88
+ describe('when appending against committed kernel event fixtures', () => {
89
+ it('should include a non-vacuous empty sequence, single append, alternate schema and interleaved sources', () => {
90
+ 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']);
91
+ });
92
+
93
+ for (const { name, fixture } of fixtures) {
94
+ it(`should match the accepted event and essential read snapshots for ${name}`, async () => {
95
+ const scenario = makeScenario(fixture.actions[0]);
96
+ for (const action of fixture.actions) {
97
+ const event = action.type === 'alternate'
98
+ ? new AlternateRecorded(action.label!) : action.type === 'boundary'
99
+ ? new BoundaryRecorded(action.firstName!, action.label!) : new OracleEventRecorded(action.name!, action.active!);
100
+ const result = await scenario.append(action.source, event);
101
+ result.isSuccess.should.equal(true);
102
+ }
103
+ scenario.results.map(item => ({ success: item.isSuccess, sequenceNumber: item.sequenceNumber.value.toString(),
104
+ violations: item.constraintViolations.length, errors: item.errors.length,
105
+ concurrencyViolation: item.concurrencyViolation !== undefined }))
106
+ .should.deep.equal(fixture.expected.results.map(({ waitError: _waitError, ...result }) => result));
107
+ for (const [index, result] of scenario.results.entries()) {
108
+ fixture.expected.results[index].waitError.should.equal('CannotWaitForObserverCompletion');
109
+ await result.waitForCompletion().then(
110
+ () => { throw new Error('Observers did not run but completion succeeded'); },
111
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
112
+ );
113
+ }
114
+ const history = scenario.appendedEvents;
115
+ history.length.should.equal(fixture.expected.history.length);
116
+ for (let index = 0; index < history.length; index++) {
117
+ const actual = history[index];
118
+ const expected = fixture.expected.history[index];
119
+ ({ sequenceNumber: actual.context.sequenceNumber.toString(), source: actual.context.eventSourceId,
120
+ sourceType: actual.context.eventSourceType, streamType: actual.context.eventStreamType,
121
+ streamId: actual.context.eventStreamId, subject: actual.context.subject,
122
+ store: actual.context.eventStore, namespace: actual.context.namespace,
123
+ eventType: actual.eventType.id.value, generation: actual.eventType.generation.value,
124
+ hash: actual.context.hash, content: actual.content }).should.deep.equal({
125
+ sequenceNumber: expected.sequenceNumber, source: expected.source,
126
+ sourceType: expected.sourceType, streamType: expected.streamType, streamId: expected.streamId,
127
+ subject: expected.subject, store: expected.store, namespace: expected.namespace,
128
+ eventType: expected.eventType, generation: expected.generation, hash: expected.hash, content: expected.content
129
+ });
130
+ (actual.context.occurred instanceof Date && !Number.isNaN(actual.context.occurred.getTime())).should.equal(expected.occurredValid);
131
+ /^[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)
132
+ .should.equal(expected.correlationValid);
133
+ actual.context.tags.length.should.equal(expected.tagsCount);
134
+ actual.context.causedBy!.name.should.equal(expected.causedByName);
135
+ actual.context.observationState!.should.equal(expected.observationState);
136
+ const causation = actual.context.causation.map(item => ({ type: item.type, properties: item.properties }));
137
+ if (fixture.actions[index].clientCausation) {
138
+ causation.should.deep.equal(expected.causation);
139
+ causation.length.should.equal(expected.causationCount);
140
+ } else {
141
+ // The .NET client's default root is Unknown; the TS client prepares Root + Append.
142
+ expected.causation.should.deep.equal([{ type: 'Unknown', properties: {} }]);
143
+ causation.should.deep.equal([
144
+ { type: 'Root', properties: {} },
145
+ { type: 'TypeScriptClient.Append', properties: { eventType: expected.eventType } }
146
+ ]);
147
+ }
148
+ if (expected.explicitOccurred) {
149
+ actual.context.occurred.toISOString().slice(0, 19).should.equal(expected.explicitOccurred.slice(0, 19));
150
+ actual.context.correlationId.should.equal(expected.explicitCorrelation);
151
+ }
152
+ }
153
+ (await scenario.eventSequence.getNextSequenceNumber()).value.toString().should.equal(fixture.expected.next);
154
+ (await scenario.eventSequence.getTailSequenceNumber()).value.toString().should.equal(fixture.expected.tail);
155
+ (await scenario.eventSequence.getTailSequenceNumber('A')).value.toString().should.equal(fixture.expected.tailA);
156
+ if (name === 'source-tail-before-global.json') {
157
+ fixture.expected.tailA.should.not.equal(fixture.expected.tail);
158
+ }
159
+ (await scenario.eventSequence.hasEventsFor('A')).should.equal(fixture.expected.hasSourceA);
160
+ const sourceEvents = await scenario.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, 'A');
161
+ sourceEvents.map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.sourceA);
162
+ sourceEvents.map(item => item.context.observationState).should.deep.equal(
163
+ fixture.expected.history.filter(item => item.source === 'A').map(item => item.observationState));
164
+ (await scenario.eventSequence.getFromSequenceNumber(new EventSequenceNumber(1n)))
165
+ .map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.fromOne);
166
+ const byType = await scenario.eventSequence.getForEventSourceIdAndEventTypes('A', [OracleEventRecorded]);
167
+ byType.map(item => item.context.sequenceNumber.toString()).should.deep.equal(fixture.expected.byType);
168
+ byType.map(item => item.context.observationState).should.deep.equal(
169
+ fixture.expected.history.filter(item => item.source === 'A' && item.eventType === 'OracleEventRecorded')
170
+ .map(item => item.observationState));
171
+ });
172
+ }
173
+
174
+ it('should keep setup out of results, protect history, and allow a single when action', async () => {
175
+ const scenario = makeScenario();
176
+ const seed = new OracleEventRecorded('seed', true);
177
+ await scenario.given.forEventSource('A').events(seed);
178
+ seed.name = 'changed';
179
+ (await scenario.when.forEventSource('B').event(new AlternateRecorded('act'))).isSuccess.should.be.true;
180
+ scenario.results.length.should.equal(1);
181
+ scenario.then.results.should.deep.equal(scenario.results);
182
+ scenario.appendedEvents[0].content.should.deep.equal({ name: 'seed', active: true });
183
+ (scenario.appendedEvents[0].content as { name: string }).name = 'changed again';
184
+ scenario.appendedEvents[0].content.should.deep.equal({ name: 'seed', active: true });
185
+ (await scenario).should.equal(scenario);
186
+ scenario.eventLog.should.equal(scenario.eventSequence);
187
+ });
188
+
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
+
202
+ it('should reject unproven schemas, event registrations and constraints before appending', async () => {
203
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [UnprovenNumber] }, constraints: 'disabled' }), 'schema');
204
+ unsupported(() => new EventScenario({ artifacts: { eventTypes: [ConstrainedEvent] } }), 'constraints');
205
+ unsupported(() => new EventScenario({ artifacts }), 'empty constraint catalog');
206
+ unsupported(() => new EventScenario({ artifacts, constraints: 'disabled', eventStore: 'other-store' }), 'eventStore');
207
+ const scenario = makeScenario();
208
+ try { await scenario.append('A', new UnknownEvent()); throw new Error('Expected rejection'); }
209
+ catch (error) { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
210
+ scenario.appendedEvents.length.should.equal(0);
211
+ });
212
+
213
+ it('should reject an act that overlaps unfinished setup without losing its result', async () => {
214
+ const scenario = makeScenario();
215
+ const pending = scenario.given.forEventSource('A').events(new OracleEventRecorded('seed', true));
216
+ await scenario.append('B', new AlternateRecorded('act')).then(
217
+ () => { throw new Error('Overlapping append succeeded'); },
218
+ error => { (error instanceof UnsupportedEventSequenceOperation).should.be.true; }
219
+ );
220
+ await pending;
221
+ scenario.results.length.should.equal(0);
222
+ scenario.appendedEvents.length.should.equal(1);
223
+ });
224
+
225
+ it('should reject prototype getters and non-enumerable append metadata without mutation', async () => {
226
+ const scenario = makeScenario();
227
+ class OptionsWithGetters {
228
+ get subject() { return 'different-subject'; }
229
+ get streamId() { return 'different-stream'; }
230
+ }
231
+ await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), new OptionsWithGetters()), 'append.options');
232
+ const hidden = Object.defineProperty({}, 'subject', { value: 'different-subject', enumerable: false });
233
+ await unsupportedAsync(() => scenario.append('A', new OracleEventRecorded('one', true), hidden), 'append.options');
234
+ scenario.appendedEvents.length.should.equal(0);
235
+ scenario.results.length.should.equal(0);
236
+ });
237
+
238
+ it('should reject malformed hook and ambient correlation IDs before mutation', async () => {
239
+ const malformed = 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz';
240
+ const fromHook = new EventScenario({ artifacts, constraints: 'disabled', correlationId: () => malformed });
241
+ await unsupportedAsync(() => fromHook.append('A', new OracleEventRecorded('one', true)), 'append.correlationId');
242
+ fromHook.appendedEvents.length.should.equal(0);
243
+ fromHook.results.length.should.equal(0);
244
+ const fromAmbient = makeScenario();
245
+ await correlationIdManager.run(new CorrelationId(malformed), () =>
246
+ unsupportedAsync(() => fromAmbient.append('A', new OracleEventRecorded('one', true)), 'append.correlationId'));
247
+ fromAmbient.appendedEvents.length.should.equal(0);
248
+ fromAmbient.results.length.should.equal(0);
249
+ });
250
+
251
+ it('should reject unproven blank and padded read source filters', async () => {
252
+ const scenario = makeScenario();
253
+ await scenario.append('A', new OracleEventRecorded('one', true));
254
+ for (const source of ['', ' ', ' A ']) {
255
+ await unsupportedAsync(() => scenario.eventSequence.hasEventsFor(source), 'hasEventsFor.source');
256
+ await unsupportedAsync(() => scenario.eventSequence.getTailSequenceNumber(source), 'getTailSequenceNumber.source');
257
+ await unsupportedAsync(() => scenario.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, source), 'getFromSequenceNumber.source');
258
+ await unsupportedAsync(() => scenario.eventSequence.getForEventSourceIdAndEventTypes(source, [OracleEventRecorded]), 'getForEventSourceIdAndEventTypes.source');
259
+ }
260
+ scenario.appendedEvents.length.should.equal(1);
261
+ scenario.results.length.should.equal(1);
262
+ });
263
+
264
+ it('should record direct act-phase append calls without reporting setup results', async () => {
265
+ const scenario = makeScenario();
266
+ await scenario.given.forEventSource('A').events(new OracleEventRecorded('seed', true));
267
+ await scenario.eventSequence.append('B', new AlternateRecorded('act'));
268
+ scenario.results.length.should.equal(1);
269
+ scenario.appendedEvents.length.should.equal(2);
270
+ });
271
+ });
@@ -0,0 +1,307 @@
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 append notifications', () => {
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');
154
+ clean(subject);
155
+ });
156
+ for (const value of [-1n, EventSequenceNumber.unset.value + 1n]) {
157
+ it(`should reject out-of-range sequence number ${value}`, async () => {
158
+ const subject = scenario();
159
+ await rejects(() => subject.eventSequence.getFromSequenceNumber(new EventSequenceNumber(value)), 'getFromSequenceNumber.sequenceNumber');
160
+ clean(subject);
161
+ });
162
+ }
163
+ it('should asynchronously reject redaction by sequence', async () => {
164
+ const subject = scenario();
165
+ await rejects(() => subject.eventSequence.redact(EventSequenceNumber.first, 'reason'), 'redact');
166
+ clean(subject);
167
+ });
168
+ it('should asynchronously reject redaction by source', async () => {
169
+ const subject = scenario();
170
+ await rejects(() => subject.eventSequence.redactForEventSource('A', 'reason'), 'redactForEventSource');
171
+ clean(subject);
172
+ });
173
+ it('should asynchronously reject stream completion', async () => {
174
+ const subject = scenario();
175
+ await rejects(() => subject.eventSequence.completeStream('All', 'Default'), 'completeStream');
176
+ clean(subject);
177
+ });
178
+ it('should asynchronously reject observer tail reads', async () => {
179
+ const subject = scenario();
180
+ await rejects(() => subject.eventSequence.getTailSequenceNumberForObserver(Recorded), 'getTailSequenceNumberForObserver');
181
+ clean(subject);
182
+ });
183
+ it('should reject ambient identity before append', async () => {
184
+ const subject = scenario();
185
+ await identityProvider.run(new Identity('user', 'User'), () => rejects(() => subject.append('A', new Recorded()), 'append.context'));
186
+ clean(subject);
187
+ });
188
+ it('should reject ambient causation before append', async () => {
189
+ const subject = scenario();
190
+ await causationManager.run(new CausationType('Command'), { value: 'test' }, () =>
191
+ rejects(() => subject.append('A', new Recorded()), 'append.context'));
192
+ clean(subject);
193
+ });
194
+ it('should reject an invalid scenario clock before append', async () => {
195
+ const subject = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', clock: () => new Date(NaN) });
196
+ await rejects(() => subject.append('A', new Recorded()), 'append.clock');
197
+ clean(subject);
198
+ });
199
+ for (const date of ['0000-12-31T23:59:59.999Z', '+010000-01-01T00:00:00.000Z']) {
200
+ it(`should reject out-of-range UTC occurrence ${date} without changing history or results`, async () => {
201
+ let clock = new Date('2024-01-01T00:00:00.000Z');
202
+ const subject = new EventScenario({ artifacts: { eventTypes: [Recorded] }, constraints: 'disabled', clock: () => clock });
203
+ await subject.append('A', new Recorded());
204
+ const history = subject.appendedEvents;
205
+ const results = subject.results;
206
+ clock = new Date(date);
207
+ await rejects(() => subject.append('B', new Recorded()), 'append.clock');
208
+ subject.appendedEvents.should.deep.equal(history);
209
+ subject.results.should.deep.equal(results);
210
+ });
211
+ }
212
+ for (const [name, value] of [
213
+ ['quote', '"'], ['backslash', '\\'], ['DEL', '\u007f'], ['uppercase accented letter', '\u00c9'],
214
+ ['decomposed accent', 'e\u0301'], ['Japanese', '日本'], ['tab', '\t']
215
+ ] as const) {
216
+ it(`should reject ${name} outside the fixture-backed content domain`, async () => {
217
+ const subject = scenario();
218
+ const event = new Recorded();
219
+ event.name = value;
220
+ await rejects(() => subject.append('A', event), 'append.content');
221
+ clean(subject);
222
+ });
223
+ }
224
+ for (const [name, value] of [['missing', undefined], ['numeric', 42]] as const) {
225
+ it(`should reject ${name} content`, async () => {
226
+ const subject = scenario();
227
+ const event = new Recorded();
228
+ Object.assign(event, { name: value });
229
+ await rejects(() => subject.append('A', event), 'append.content');
230
+ clean(subject);
231
+ });
232
+ }
233
+ it('should reject extra content fields', async () => {
234
+ const subject = scenario();
235
+ const event = Object.assign(new Recorded(), { extra: 'value' });
236
+ await rejects(() => subject.append('A', event), 'append.content');
237
+ clean(subject);
238
+ });
239
+ for (const source of ['a b', 'a|b', '\u00c9']) {
240
+ it(`should reject unsupported append source ${JSON.stringify(source)}`, async () => {
241
+ const subject = scenario();
242
+ await rejects(() => subject.append(source, new Recorded()), 'append.source');
243
+ clean(subject);
244
+ });
245
+ }
246
+ it('should reject plain correlation options', async () => {
247
+ const subject = scenario();
248
+ await rejects(() => subject.append('A', new Recorded(), { correlationId: '00000000-0000-0000-0000-000000000001' }), 'append.options');
249
+ clean(subject);
250
+ });
251
+ it('should reject plain subject options', async () => {
252
+ const subject = scenario();
253
+ await rejects(() => subject.append('A', new Recorded(), { subject: 'other' }), 'append.options');
254
+ clean(subject);
255
+ });
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
+ it('should reject empty source/type-read event types', async () => {
269
+ const subject = scenario();
270
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', []), 'getForEventSourceIdAndEventTypes.filters');
271
+ clean(subject);
272
+ });
273
+ it('should reject unregistered source/type-read event types', async () => {
274
+ const subject = scenario();
275
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes('A', [DuplicateId]), 'read.eventTypes');
276
+ clean(subject);
277
+ });
278
+ it('should reject non-ASCII padded read sources before returning an incorrect result', async () => {
279
+ const subject = scenario();
280
+ await subject.append('A', new Recorded());
281
+ const history = subject.appendedEvents;
282
+ const results = subject.results;
283
+ for (const source of ['\u0085A\u0085', 'A\u00a0', 'A\u007f', 'A\u2000B']) {
284
+ await rejects(() => subject.eventSequence.hasEventsFor(source), 'hasEventsFor.source');
285
+ await rejects(() => subject.eventSequence.getTailSequenceNumber(source), 'getTailSequenceNumber.source');
286
+ await rejects(() => subject.eventSequence.getFromSequenceNumber(EventSequenceNumber.first, source), 'getFromSequenceNumber.source');
287
+ await rejects(() => subject.eventSequence.getForEventSourceIdAndEventTypes(source, [Recorded]), 'getForEventSourceIdAndEventTypes.source');
288
+ }
289
+ subject.appendedEvents.should.deep.equal(history);
290
+ subject.results.should.deep.equal(results);
291
+ });
292
+ it('should reject overlapping act appends, including separate act entry points', async () => {
293
+ const subject = scenario();
294
+ const [first, second] = await Promise.allSettled([
295
+ subject.append('A', new Recorded()),
296
+ subject.eventLog.append('B', new Recorded())
297
+ ]);
298
+ first.status.should.equal('fulfilled');
299
+ second.status.should.equal('rejected');
300
+ if (second.status === 'rejected') {
301
+ (second.reason instanceof UnsupportedEventSequenceOperation).should.be.true;
302
+ (second.reason as Error).message.should.include('append');
303
+ }
304
+ subject.appendedEvents.length.should.equal(1);
305
+ subject.results.length.should.equal(1);
306
+ });
307
+ });