@cratis/chronicle 6.20.0 → 6.22.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 (91) 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/reactors/ReactorDispatcher.d.ts +20 -0
  13. package/dist/reactors/ReactorDispatcher.d.ts.map +1 -0
  14. package/dist/reactors/ReactorDispatcher.js +76 -0
  15. package/dist/reactors/ReactorDispatcher.js.map +1 -0
  16. package/dist/reactors/ReactorSideEffects.d.ts +3 -0
  17. package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
  18. package/dist/reactors/ReactorSideEffects.js +16 -11
  19. package/dist/reactors/ReactorSideEffects.js.map +1 -1
  20. package/dist/reactors/Reactors.d.ts +0 -1
  21. package/dist/reactors/Reactors.d.ts.map +1 -1
  22. package/dist/reactors/Reactors.js +5 -71
  23. package/dist/reactors/Reactors.js.map +1 -1
  24. package/dist/testing/EventScenario.d.ts +1 -1
  25. package/dist/testing/EventScenario.d.ts.map +1 -1
  26. package/dist/testing/EventScenario.js +7 -4
  27. package/dist/testing/EventScenario.js.map +1 -1
  28. package/dist/testing/EventScenarioGiven.d.ts +1 -1
  29. package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
  30. package/dist/testing/EventScenarioGiven.js +2 -5
  31. package/dist/testing/EventScenarioGiven.js.map +1 -1
  32. package/dist/testing/EventScenarioSeed.d.ts +2 -2
  33. package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
  34. package/dist/testing/EventScenarioSeed.js +2 -2
  35. package/dist/testing/EventScenarioSeed.js.map +1 -1
  36. package/dist/testing/EventScenarioWhen.d.ts +1 -1
  37. package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
  38. package/dist/testing/EventScenarioWhen.js +2 -3
  39. package/dist/testing/EventScenarioWhen.js.map +1 -1
  40. package/dist/testing/InProcessEventSequence.d.ts +26 -7
  41. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  42. package/dist/testing/InProcessEventSequence.js +253 -43
  43. package/dist/testing/InProcessEventSequence.js.map +1 -1
  44. package/dist/testing/ReactorDeliveryResult.d.ts +10 -0
  45. package/dist/testing/ReactorDeliveryResult.d.ts.map +1 -0
  46. package/dist/testing/ReactorDeliveryResult.js +4 -0
  47. package/dist/testing/ReactorDeliveryResult.js.map +1 -0
  48. package/dist/testing/ReactorScenario.d.ts +55 -0
  49. package/dist/testing/ReactorScenario.d.ts.map +1 -0
  50. package/dist/testing/ReactorScenario.js +275 -0
  51. package/dist/testing/ReactorScenario.js.map +1 -0
  52. package/dist/testing/ReactorScenarioOptions.d.ts +17 -0
  53. package/dist/testing/ReactorScenarioOptions.d.ts.map +1 -0
  54. package/dist/testing/ReactorScenarioOptions.js +4 -0
  55. package/dist/testing/ReactorScenarioOptions.js.map +1 -0
  56. package/dist/testing/RecordedReactorSideEffect.d.ts +13 -0
  57. package/dist/testing/RecordedReactorSideEffect.d.ts.map +1 -0
  58. package/dist/testing/RecordedReactorSideEffect.js +4 -0
  59. package/dist/testing/RecordedReactorSideEffect.js.map +1 -0
  60. package/dist/testing/UnsupportedReactorOperation.d.ts +7 -0
  61. package/dist/testing/UnsupportedReactorOperation.d.ts.map +1 -0
  62. package/dist/testing/UnsupportedReactorOperation.js +14 -0
  63. package/dist/testing/UnsupportedReactorOperation.js.map +1 -0
  64. package/dist/testing/index.d.ts +5 -0
  65. package/dist/testing/index.d.ts.map +1 -1
  66. package/dist/testing/index.js +2 -0
  67. package/dist/testing/index.js.map +1 -1
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/eventSequences/EventSequence.ts +10 -107
  70. package/eventSequences/createAppendNotification.ts +35 -0
  71. package/eventSequences/prepareBatchAppend.ts +63 -0
  72. package/package.json +1 -1
  73. package/reactors/ReactorDispatcher.ts +95 -0
  74. package/reactors/ReactorSideEffects.ts +21 -13
  75. package/reactors/Reactors.ts +11 -83
  76. package/testing/EventScenario.batches.spec.ts +537 -0
  77. package/testing/EventScenario.fixtures.spec.ts +2 -13
  78. package/testing/EventScenario.rejections.spec.ts +5 -39
  79. package/testing/EventScenario.ts +7 -5
  80. package/testing/EventScenarioGiven.ts +2 -5
  81. package/testing/EventScenarioSeed.ts +4 -4
  82. package/testing/EventScenarioWhen.ts +2 -4
  83. package/testing/InProcessEventSequence.ts +239 -39
  84. package/testing/ReactorDeliveryResult.ts +13 -0
  85. package/testing/ReactorScenario.rejections.spec.ts +100 -0
  86. package/testing/ReactorScenario.spec.ts +611 -0
  87. package/testing/ReactorScenario.ts +279 -0
  88. package/testing/ReactorScenarioOptions.ts +20 -0
  89. package/testing/RecordedReactorSideEffect.ts +16 -0
  90. package/testing/UnsupportedReactorOperation.ts +10 -0
  91. package/testing/index.ts +5 -0
@@ -19,7 +19,7 @@ import { InProcessEventSequence } from './InProcessEventSequence.js';
19
19
  import { registerScenarioSeed } from './EventScenarioSeed.js';
20
20
  import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
21
21
 
22
- /** A kernel-free, fixture-bounded single-event append scenario. No observer runs automatically. */
22
+ /** A kernel-free, fixture-bounded append scenario. No observer runs automatically. */
23
23
  export class EventScenario {
24
24
  readonly eventSequence: IEventSequence;
25
25
  readonly given: EventScenarioGivenBuilder;
@@ -46,7 +46,7 @@ export class EventScenario {
46
46
  }
47
47
  const sequence = new InProcessEventSequence(options, eventTypes);
48
48
  this.eventSequence = sequence;
49
- registerScenarioSeed(this, (source, event) => sequence.seed(source, event));
49
+ registerScenarioSeed(this, (source, events) => sequence.seed(source, events));
50
50
  this.given = new EventScenarioGivenBuilder(this);
51
51
  this.when = new EventScenarioWhenBuilder(this);
52
52
  }
@@ -63,9 +63,11 @@ export class EventScenario {
63
63
  }
64
64
  appendMany(source: string, events: object[], options?: AppendOptions): Promise<AppendResult[]>;
65
65
  appendMany(events: EventForEventSourceId[], options?: AppendOptions): Promise<AppendResult[]>;
66
- async appendMany(_sourceOrEvents: string | EventForEventSourceId[], _eventsOrOptions?: object[] | AppendOptions, _options?: AppendOptions): Promise<AppendResult[]> {
67
- throw new UnsupportedEventSequenceOperation('appendMany', this.eventSequence.id.value,
68
- 'Both batch overloads require kernel-backed atomicity fixtures.');
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);
69
71
  }
70
72
  get results(): readonly AppendResult[] { return (this.eventSequence as InProcessEventSequence).results; }
71
73
  get appendedEvents(): readonly AppendedEvent[] { return (this.eventSequence as InProcessEventSequence).appendedEvents; }
@@ -3,14 +3,11 @@
3
3
 
4
4
  import type { EventScenario } from './EventScenario.js';
5
5
  import { seedScenario } from './EventScenarioSeed.js';
6
- import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
7
6
 
8
- /** Seeds exactly one event through the ordinary single-append pipeline. */
7
+ /** Seeds events in order through individual single appends, as in the .NET given builder. */
9
8
  export class EventScenarioGiven {
10
9
  constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
11
10
  async events(...events: object[]): Promise<void> {
12
- if (events.length !== 1) throw new UnsupportedEventSequenceOperation('given.events', this._id,
13
- 'Zero or multiple setup events are not supported in this increment; call events once per event.');
14
- await seedScenario(this._scenario, this._id, events[0]);
11
+ await seedScenario(this._scenario, this._id, events);
15
12
  }
16
13
  }
@@ -3,15 +3,15 @@
3
3
 
4
4
  import type { EventScenario } from './EventScenario.js';
5
5
 
6
- const seeders = new WeakMap<EventScenario, (source: string, event: object) => Promise<void>>();
6
+ const seeders = new WeakMap<EventScenario, (source: string, events: object[]) => Promise<void>>();
7
7
 
8
8
  /** Internal builder wiring; not exported by the testing package. */
9
- export function registerScenarioSeed(scenario: EventScenario, seed: (source: string, event: object) => Promise<void>): void {
9
+ export function registerScenarioSeed(scenario: EventScenario, seed: (source: string, events: object[]) => Promise<void>): void {
10
10
  seeders.set(scenario, seed);
11
11
  }
12
12
 
13
- export function seedScenario(scenario: EventScenario, source: string, event: object): Promise<void> {
13
+ export function seedScenario(scenario: EventScenario, source: string, events: object[]): Promise<void> {
14
14
  const seed = seeders.get(scenario);
15
15
  if (!seed) throw new Error('EventScenario setup requires a constructed scenario.');
16
- return seed(source, event);
16
+ return seed(source, events);
17
17
  }
@@ -3,14 +3,12 @@
3
3
 
4
4
  import type { AppendResult } from '../eventSequences/AppendResult.js';
5
5
  import type { EventScenario } from './EventScenario.js';
6
- import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
7
6
 
8
7
  /** A single act-phase append; the plural form is reserved for atomic batches. */
9
8
  export class EventScenarioWhen {
10
9
  constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
11
10
  async event(event: object): Promise<AppendResult> { return this._scenario.append(this._id, event); }
12
- async events(..._events: object[]): Promise<readonly AppendResult[]> {
13
- throw new UnsupportedEventSequenceOperation('when.events/appendMany', this._id,
14
- 'Even a one-event plural action uses kernel batch semantics; batch fixtures are not included.');
11
+ async events(...events: object[]): Promise<readonly AppendResult[]> {
12
+ return this._scenario.appendMany(this._id, events);
15
13
  }
16
14
  }
@@ -3,8 +3,11 @@
3
3
 
4
4
  import { createHash } from 'node:crypto';
5
5
  import { EventObservationState } from '@cratis/chronicle.contracts';
6
- import type { Constructor } from '@cratis/fundamentals';
7
- import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
6
+ import { AppendOperationsBroadcaster } from '../eventSequences/AppendOperationsBroadcaster.js';
7
+ import { prepareBatchAppend } from '../eventSequences/prepareBatchAppend.js';
8
+ import { createAppendNotification, mapAppendNotificationCausation } from '../eventSequences/createAppendNotification.js';
9
+ import { Guid, type Constructor } from '@cratis/fundamentals';
10
+ import { getEventTypeMetadata, getEventTypeFor } from '../events/eventTypeDecorator.js';
8
11
  import { getTagsFor } from '../events/tagDecorator.js';
9
12
  import type { AppendedEvent } from '../events/AppendedEvent.js';
10
13
  import { Tag } from '../events/Tag.js';
@@ -31,7 +34,20 @@ import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOpe
31
34
  // JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls.
32
35
  const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u;
33
36
 
34
- /** Fixture-backed, scenario-local single-append sequence; no kernel or observer scheduler is started. */
37
+ // Observers ReactorScenario registers to latch unsupported operations, keyed by sequence.
38
+ const unsupportedObservers = new WeakMap<object, (error: UnsupportedEventSequenceOperation) => void>();
39
+
40
+ /**
41
+ * Observe every unsupported operation a scenario sequence reports, however the caller receives it.
42
+ * Internal to the testing harness; ReactorScenario uses it to fail deliveries that swallow such errors.
43
+ * @param sequence - The in-process sequence to observe.
44
+ * @param observer - Called with each unsupported-operation error the sequence creates.
45
+ */
46
+ export function observeUnsupportedOperations(sequence: object, observer: (error: UnsupportedEventSequenceOperation) => void): void {
47
+ unsupportedObservers.set(sequence, observer);
48
+ }
49
+
50
+ /** Fixture-backed, scenario-local append sequence; no kernel or observer scheduler is started. */
35
51
  export class InProcessEventSequence implements IEventSequence {
36
52
  readonly id: EventSequenceId;
37
53
  private readonly _catalog = new Map<Function, ReturnType<typeof getEventTypeMetadata>>();
@@ -42,8 +58,11 @@ export class InProcessEventSequence implements IEventSequence {
42
58
  private readonly _correlationId?: () => string;
43
59
  private _setup = false;
44
60
  private _allowSeedAppend = false;
61
+ private _stagedSeeds?: AppendedEvent[];
62
+ private _stagedNotifications?: AppendedEventWithResult[][];
45
63
  private _busy = false;
46
64
  private readonly _results: AppendResult[] = [];
65
+ readonly appendOperations = new AppendOperationsBroadcaster<AppendedEventWithResult[]>();
47
66
 
48
67
  constructor(options: EventScenarioOptions, eventTypes: Constructor[]) {
49
68
  if (options.eventSequenceId && options.eventSequenceId.value !== EventSequenceId.eventLog.value) {
@@ -100,24 +119,35 @@ export class InProcessEventSequence implements IEventSequence {
100
119
  get results(): readonly AppendResult[] { return Object.freeze([...this._results]); }
101
120
  get appendedEvents(): readonly AppendedEvent[] { return Object.freeze(this._history.map(event => this.snapshot(event))); }
102
121
 
103
- /** Seed through the same append path, without recording act-phase results. */
104
- async seed(source: string, event: object): Promise<void> {
105
- if (this._setup) throw this.unsupported('given.events', this.id.value, 'Overlapping setup calls are not fixture-backed.');
122
+ /** Validate and stage all setup appends, then commit and notify in single-append order. */
123
+ async seed(source: string, events: object[]): Promise<void> {
124
+ if (this._setup || this._busy) throw this.unsupported('given.events', this.id.value, 'Overlapping setup calls are not fixture-backed.');
106
125
  this._setup = true;
107
- this._allowSeedAppend = true;
108
- let pending: Promise<AppendResult>;
109
- try { pending = this.append(source, event); }
110
- finally { this._allowSeedAppend = false; }
126
+ this._stagedSeeds = [];
127
+ this._stagedNotifications = [];
111
128
  try {
112
- const result = await pending;
113
- if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
129
+ for (const event of events) {
130
+ this._allowSeedAppend = true;
131
+ let pending: Promise<AppendResult>;
132
+ try { pending = this.append(source, event); }
133
+ finally { this._allowSeedAppend = false; }
134
+ const result = await pending;
135
+ if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
136
+ }
137
+ for (const [index, stored] of this._stagedSeeds.entries()) {
138
+ this._history.push(stored);
139
+ this.appendOperations.publish(this._stagedNotifications[index]);
140
+ // Let an async subscriber observe this append before the next enters history.
141
+ await Promise.resolve();
142
+ }
114
143
  } finally {
144
+ this._stagedSeeds = undefined;
145
+ this._stagedNotifications = undefined;
115
146
  this._setup = false;
116
147
  }
117
148
  }
118
149
 
119
150
  get transactional(): ITransactionalEventSequence { throw this.unsupported('transactional', this.id.value, 'Transactions require a kernel.'); }
120
- get appendOperations(): AsyncIterable<AppendedEventWithResult[]> { throw this.unsupported('appendOperations', this.id.value, 'Append notifications are not fixture-backed.'); }
121
151
 
122
152
  async append(eventSourceId: string, event: object, options?: AppendOptions): Promise<AppendResult> {
123
153
  if (this._busy || (this._setup && !this._allowSeedAppend)) {
@@ -168,7 +198,7 @@ export class InProcessEventSequence implements IEventSequence {
168
198
  }
169
199
  const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
170
200
  const hash = createHash('sha256').update(`${prepared.eventType.id.value}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64');
171
- const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length));
201
+ const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + (this._stagedSeeds?.length ?? 0)));
172
202
  const stored: AppendedEvent = {
173
203
  eventType: prepared.eventType,
174
204
  content,
@@ -182,12 +212,16 @@ export class InProcessEventSequence implements IEventSequence {
182
212
  tags: prepared.tags.map(tag => new Tag(tag))
183
213
  }
184
214
  };
185
- this._history.push(this.snapshot(stored));
186
- const result: AppendResult = Object.freeze({
187
- sequenceNumber: Object.freeze(sequenceNumber), constraintViolations: Object.freeze([]), errors: Object.freeze([]), isSuccess: true,
188
- waitForCompletion: async () => { throw this.unsupported('waitForCompletion', event.constructor.name, 'No observers run in process.'); }
189
- });
215
+ if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored));
216
+ else this._history.push(this.snapshot(stored));
217
+ const result = this.success(sequenceNumber, event.constructor.name);
190
218
  if (!this._setup) this._results.push(result);
219
+ if (this._stagedNotifications || this.appendOperations.hasSubscribers) {
220
+ const notification = [createAppendNotification(eventSourceId, event, result,
221
+ prepared.correlationId.toString(), mapAppendNotificationCausation(prepared.causationChain), prepared.tags)];
222
+ if (this._stagedNotifications) this._stagedNotifications.push(notification);
223
+ else this.appendOperations.publish(notification);
224
+ }
191
225
  return result;
192
226
  } finally {
193
227
  // Keep the append in flight until its promise crosses an async boundary.
@@ -196,20 +230,114 @@ export class InProcessEventSequence implements IEventSequence {
196
230
  }
197
231
  }
198
232
 
199
- appendMany(_eventSourceId: string, _events: object[], _options?: AppendOptions): Promise<AppendResult[]>;
200
- appendMany(_events: EventForEventSourceId[], _options?: AppendOptions): Promise<AppendResult[]>;
201
- async appendMany(): Promise<AppendResult[]> {
202
- throw this.unsupported('appendMany', this.id.value, 'Batch atomicity and result mapping require kernel fixtures.');
233
+ appendMany(eventSourceId: string, events: object[], options?: AppendOptions): Promise<AppendResult[]>;
234
+ appendMany(events: EventForEventSourceId[], options?: AppendOptions): Promise<AppendResult[]>;
235
+ async appendMany(sourceOrEvents: string | EventForEventSourceId[], eventsOrOptions?: object[] | AppendOptions, options?: AppendOptions): Promise<AppendResult[]> {
236
+ if (this._busy || this._setup) throw this.unsupported('appendMany', this.id.value, 'Overlapping calls do not have proven ordering.');
237
+ this._busy = true;
238
+ try {
239
+ const entries = typeof sourceOrEvents === 'string' ? eventsOrOptions as object[] : sourceOrEvents;
240
+ const shared = typeof sourceOrEvents === 'string' ? options : eventsOrOptions as AppendOptions | undefined;
241
+ if (!Array.isArray(entries)) throw this.unsupported('appendMany.arguments', this.id.value, 'Events must be an array.');
242
+ if (!entries.length) throw this.unsupported('appendMany.empty', this.id.value, 'The kernel rejects an empty batch.');
243
+ this.validateBatchOptions(shared);
244
+ if (shared?.occurred !== undefined) this.checkedDate(shared.occurred, 'appendMany.occurred');
245
+ for (const entry of entries) {
246
+ if (typeof sourceOrEvents === 'string') break;
247
+ if (!entry || typeof entry !== 'object' || Reflect.ownKeys(entry).some(key =>
248
+ !['eventSourceId', 'event', 'eventSourceType', 'eventStreamType', 'eventStreamId', 'subject', 'occurred', 'tags'].includes(String(key)))) {
249
+ throw this.unsupported('appendMany.entry', this.id.value, 'Unrecognized per-entry metadata.');
250
+ }
251
+ const individual = entry as EventForEventSourceId;
252
+ if (individual.occurred !== undefined) this.checkedDate(individual.occurred, 'appendMany.occurred');
253
+ const tags = individual.tags;
254
+ if (tags && (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string'))) {
255
+ throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
256
+ }
257
+ }
258
+ let prepared: ReturnType<typeof prepareBatchAppend>;
259
+ try {
260
+ const batchOptions: AppendOptions | undefined = shared?.correlationId === undefined && this._correlationId
261
+ ? { correlationId: this._correlationId(), sourceType: shared?.sourceType, streamType: shared?.streamType,
262
+ streamId: shared?.streamId, subject: shared?.subject, occurred: shared?.occurred, tags: shared?.tags }
263
+ : shared;
264
+ if (batchOptions?.correlationId !== undefined) this.validateGuid(batchOptions.correlationId, 'appendMany.correlationId');
265
+ prepared = typeof sourceOrEvents === 'string'
266
+ ? prepareBatchAppend(sourceOrEvents, entries as object[], batchOptions)
267
+ : prepareBatchAppend(sourceOrEvents, batchOptions);
268
+ } catch (error) {
269
+ if (error instanceof UnsupportedEventSequenceOperation) throw error;
270
+ throw this.unsupported('appendMany.serialization', this.id.value, `Batch preparation failed: ${String(error)}.`);
271
+ }
272
+ const { eventsForEventSourceIds, eventsToAppend, correlationId, batchCausationChain, identity } = prepared;
273
+ this.validateGuid(correlationId, 'appendMany.correlationId');
274
+ if (identity !== Identity.system || batchCausationChain.length !== 2 ||
275
+ Object.keys(batchCausationChain[0].properties).length !== 0 ||
276
+ batchCausationChain[1].type.name !== 'TypeScriptClient.AppendMany' ||
277
+ batchCausationChain[1].properties.count !== String(entries.length)) {
278
+ throw this.unsupported('appendMany.context', this.id.value, 'Ambient identity or causation metadata is not fixture-backed.');
279
+ }
280
+ const staged = eventsForEventSourceIds.map(({ eventSourceId, event }, index) => {
281
+ if (!event || typeof event !== 'object' || !this._catalog.has(event.constructor)) {
282
+ throw this.unsupported('appendMany.event', event?.constructor?.name ?? this.id.value, 'Event is not in the selected catalog.');
283
+ }
284
+ this.validateSource(eventSourceId, 'appendMany.source');
285
+ const wire = eventsToAppend[index];
286
+ for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType,
287
+ streamId: wire.EventStreamId, subject: wire.Subject })) {
288
+ if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value))) {
289
+ throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.');
290
+ }
291
+ }
292
+ if (wire.Tags.some(tag => !/^[A-Za-z0-9_-]+$/.test(tag))) {
293
+ throw this.unsupported('appendMany.tags', event.constructor.name, 'Only simple string tags are fixture-backed.');
294
+ }
295
+ const occurred = wire.Occurred ? this.checkedDate(new Date(wire.Occurred.Value), 'appendMany.occurred')
296
+ : this.checkedDate(this._clock(), 'appendMany.clock');
297
+ const content = this.checkedContent(event, wire.Content, 'appendMany.content');
298
+ const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
299
+ const hash = createHash('sha256').update(`${wire.EventType.Id}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64');
300
+ const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + index));
301
+ const eventType = getEventTypeFor(event.constructor);
302
+ const stored: AppendedEvent = { eventType, content, context: {
303
+ eventStore: this._store, namespace: this._namespace, sequenceNumber: sequenceNumber.value, eventSourceId,
304
+ eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All',
305
+ eventStreamId: wire.EventStreamId || 'Default', subject: wire.Subject, hash, causedBy: identity,
306
+ observationState: EventObservationState.Initial, eventType, occurred, correlationId: correlationId.toString(),
307
+ causation: batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
308
+ tags: wire.Tags.map(tag => new Tag(tag))
309
+ } };
310
+ return { stored, result: this.success(sequenceNumber, event.constructor.name) };
311
+ });
312
+ // Stage and validate every entry before committing any event or result.
313
+ this._history.push(...staged.map(item => this.snapshot(item.stored)));
314
+ const results = staged.map(item => item.result);
315
+ this._results.push(...results);
316
+ if (this.appendOperations.hasSubscribers) {
317
+ const occurredAt = new Date();
318
+ const causationEntries = mapAppendNotificationCausation(batchCausationChain);
319
+ this.appendOperations.publish(staged.map(({ result }, index) =>
320
+ createAppendNotification(eventsForEventSourceIds[index].eventSourceId, eventsForEventSourceIds[index].event, result,
321
+ correlationId.toString(), causationEntries, eventsToAppend[index].Tags, occurredAt)));
322
+ }
323
+ return results;
324
+ } finally {
325
+ await Promise.resolve();
326
+ this._busy = false;
327
+ }
203
328
  }
204
329
 
205
330
  async getNextSequenceNumber(): Promise<EventSequenceNumber> { return new EventSequenceNumber(BigInt(this._history.length)); }
206
331
 
207
332
  async getTailSequenceNumber(source?: string, sourceType?: string, streamType?: string, streamId?: string, types?: Constructor[]): Promise<EventSequenceNumber> {
208
- if (sourceType !== undefined || streamType !== undefined || streamId !== undefined || types?.length) {
209
- throw this.unsupported('getTailSequenceNumber.filters', this.id.value, 'Route and event-type tail filters are not fixture-backed.');
210
- }
211
333
  if (source !== undefined) this.validateReadSource('getTailSequenceNumber.source', source);
212
- const last = source === undefined ? this._history.at(-1) : [...this._history].reverse().find(event => event.context.eventSourceId === source);
334
+ this.validateRouteFilters('getTailSequenceNumber.filters', sourceType, streamType, streamId);
335
+ const ids = this.filterIds(types);
336
+ const last = [...this._history].reverse().find(event => (source === undefined || event.context.eventSourceId === source) &&
337
+ (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) &&
338
+ (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) &&
339
+ (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId) &&
340
+ (!ids.length || ids.includes(event.eventType.id.value)));
213
341
  return last ? new EventSequenceNumber(last.context.sequenceNumber) : EventSequenceNumber.unset;
214
342
  }
215
343
 
@@ -223,32 +351,102 @@ export class InProcessEventSequence implements IEventSequence {
223
351
  }
224
352
 
225
353
  async getForEventSourceIdAndEventTypes(source: string, types: Constructor[], streamType?: string, streamId?: string, sourceType?: string): Promise<AppendedEvent[]> {
226
- if (streamType !== undefined || streamId !== undefined || sourceType !== undefined || !types.length) {
227
- throw this.unsupported('getForEventSourceIdAndEventTypes.filters', source, 'Only explicit event-type/source filtering is fixture-backed.');
228
- }
354
+ if (!types.length) throw this.unsupported('getForEventSourceIdAndEventTypes.filters', source, 'Explicit event types are required.');
229
355
  this.validateReadSource('getForEventSourceIdAndEventTypes.source', source);
230
- const ids = types.map(type => {
231
- const metadata = this._catalog.get(type);
232
- if (!metadata) throw this.unsupported('read.eventTypes', type.name, 'Event type is not in the selected catalog.');
233
- return metadata.eventType.id.value;
234
- });
235
- return this._history.filter(event => event.context.eventSourceId === source && ids.includes(event.eventType.id.value)).map(event => this.snapshot(event));
356
+ this.validateRouteFilters('getForEventSourceIdAndEventTypes.filters', sourceType, streamType, streamId);
357
+ const ids = this.filterIds(types);
358
+ return this._history.filter(event => event.context.eventSourceId === source && ids.includes(event.eventType.id.value) &&
359
+ (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) &&
360
+ (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) &&
361
+ (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId)).map(event => this.snapshot(event));
236
362
  }
237
363
 
238
364
  async getFromSequenceNumber(sequence: EventSequenceNumber, source?: string, types?: Constructor[]): Promise<AppendedEvent[]> {
239
365
  if (sequence.value < 0n || sequence.value > EventSequenceNumber.unset.value) {
240
366
  throw this.unsupported('getFromSequenceNumber.sequenceNumber', sequence.value.toString(), 'Sequence numbers must fit unsigned 64-bit wire values.');
241
367
  }
242
- if (types?.length) throw this.unsupported('getFromSequenceNumber.filterEventTypes', this.id.value, 'Event-type filtering on sequence reads is not fixture-backed.');
243
368
  if (source !== undefined) this.validateReadSource('getFromSequenceNumber.source', source);
369
+ const ids = this.filterIds(types);
244
370
  return this._history.filter(event => event.context.sequenceNumber >= sequence.value &&
245
- (source === undefined || event.context.eventSourceId === source)).map(event => this.snapshot(event));
371
+ (source === undefined || event.context.eventSourceId === source) &&
372
+ (!ids.length || ids.includes(event.eventType.id.value))).map(event => this.snapshot(event));
246
373
  }
247
374
 
248
375
  async redact(): Promise<void> { throw this.unsupported('redact', this.id.value, 'Redaction requires kernel storage.'); }
249
376
  async redactForEventSource(): Promise<void> { throw this.unsupported('redactForEventSource', this.id.value, 'Redaction requires kernel storage.'); }
250
377
  async completeStream(): Promise<CompleteStreamResult> { throw this.unsupported('completeStream', this.id.value, 'Stream completion requires a kernel.'); }
251
378
 
379
+ private filterIds(types?: Constructor[]): string[] {
380
+ return (types ?? []).map(type => {
381
+ const metadata = this._catalog.get(type);
382
+ if (!metadata) throw this.unsupported('read.eventTypes', type.name, 'Event type is not in the selected catalog.');
383
+ return metadata.eventType.id.value;
384
+ });
385
+ }
386
+
387
+ private validateRouteFilters(operation: string, ...filters: (string | undefined)[]): void {
388
+ for (const filter of filters) {
389
+ if (filter !== undefined && (typeof filter !== 'string' || !/^[A-Za-z0-9_-]+$/.test(filter))) {
390
+ throw this.unsupported(operation, String(filter), 'Only simple, nonblank route filters are fixture-backed.');
391
+ }
392
+ }
393
+ }
394
+
395
+ private validateBatchOptions(options?: AppendOptions): void {
396
+ if (options === undefined) return;
397
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
398
+ throw this.unsupported('appendMany.options', this.id.value, 'Append options must be an object.');
399
+ }
400
+ const allowed = ['correlationId', 'sourceType', 'streamType', 'streamId', 'subject', 'occurred', 'tags'];
401
+ if (Reflect.ownKeys(options).some(key => !allowed.includes(String(key))) ||
402
+ options.concurrencyScope !== undefined || options.concurrencyScopes !== undefined || options.eventSourceId !== undefined) {
403
+ throw this.unsupported('appendMany.options', this.id.value, 'Concurrency and unrecognized metadata are not fixture-backed.');
404
+ }
405
+ if (options.tags && (!Array.isArray(options.tags) || options.tags.some(tag => typeof tag !== 'string'))) {
406
+ throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
407
+ }
408
+ }
409
+
410
+ private validateGuid(value: string | Guid, operation: string): void {
411
+ try {
412
+ toContractsGuid(Guid.as(value));
413
+ if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value.toString())) throw new Error('Invalid GUID.');
414
+ } catch { throw this.unsupported(operation, this.id.value, 'Correlation ID must be a valid GUID.'); }
415
+ }
416
+
417
+ private checkedDate(value: Date, operation: string): Date {
418
+ if (!(value instanceof Date) || Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1 || value.getUTCFullYear() > 9999) {
419
+ throw this.unsupported(operation, this.id.value, 'Occurrence must be within UTC years 1–9999 at millisecond precision.');
420
+ }
421
+ return new Date(value);
422
+ }
423
+
424
+ private validateSource(source: string, operation: string): void {
425
+ if (typeof source !== 'string' || !/^[A-Za-z0-9_-]+$/.test(source)) {
426
+ throw this.unsupported(operation, String(source), 'Only simple source identifiers are fixture-backed.');
427
+ }
428
+ }
429
+
430
+ private checkedContent(event: object, serialized: string, operation: string): Record<string, unknown> {
431
+ let content: Record<string, unknown>;
432
+ try { content = JSON.parse(serialized) as Record<string, unknown>; }
433
+ catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
434
+ const properties = this._catalog.get(event.constructor)!.schema.properties!;
435
+ if (!content || typeof content !== 'object' || Array.isArray(content) ||
436
+ Object.keys(content).length !== Object.keys(properties).length ||
437
+ Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
438
+ (property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
439
+ throw this.unsupported(operation, event.constructor.name, 'Content differs from the fixture-backed scalar schema.');
440
+ }
441
+ return content;
442
+ }
443
+
444
+ private success(sequenceNumber: EventSequenceNumber, artifact: string): AppendResult {
445
+ return Object.freeze({ sequenceNumber: Object.freeze(sequenceNumber), constraintViolations: Object.freeze([]),
446
+ errors: Object.freeze([]), isSuccess: true,
447
+ waitForCompletion: async () => { throw this.unsupported('waitForCompletion', artifact, 'No observers run in process.'); } });
448
+ }
449
+
252
450
  private snapshot(event: AppendedEvent): AppendedEvent {
253
451
  const eventType = new EventType(new EventTypeId(event.eventType.id.value),
254
452
  new EventTypeGeneration(event.eventType.generation.value), event.eventType.tombstone);
@@ -270,6 +468,8 @@ export class InProcessEventSequence implements IEventSequence {
270
468
  }
271
469
 
272
470
  private unsupported(operation: string, artifact: string, reason: string): UnsupportedEventSequenceOperation {
273
- return new UnsupportedEventSequenceOperation(operation, artifact, reason);
471
+ const error = new UnsupportedEventSequenceOperation(operation, artifact, reason);
472
+ unsupportedObservers.get(this)?.(error);
473
+ return error;
274
474
  }
275
475
  }
@@ -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 { EventContext } from '../events/EventContext.js';
5
+
6
+ /** Observable outcome of one ordered source-partition delivery (not a kernel checkpoint). */
7
+ export interface ReactorDeliveryResult {
8
+ readonly sourceId: string;
9
+ readonly handled: readonly EventContext[];
10
+ readonly skipped: readonly EventContext[];
11
+ readonly completed: boolean;
12
+ readonly error?: unknown;
13
+ }
@@ -0,0 +1,100 @@
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 { field } from '@cratis/fundamentals';
5
+ import { chai, describe, it } from 'vitest';
6
+ import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
7
+ import { eventType } from '../events/eventTypeDecorator.js';
8
+ import { reactor } from '../reactors/reactor.js';
9
+ import { ReactorScenario } from './ReactorScenario.js';
10
+ import { UnsupportedReactorOperation } from './UnsupportedReactorOperation.js';
11
+
12
+ chai.should();
13
+ @eventType('rejection-event')
14
+ class Registered {
15
+ @field(String) name: string;
16
+ constructor(name: string) { this.name = name; }
17
+ }
18
+ class Unregistered { constructor(readonly name: string) {} }
19
+ const options = { artifacts: { eventTypes: [Registered] }, constraints: 'disabled' as const };
20
+
21
+ async function rejected(operation: string, call: () => Promise<unknown>): Promise<void> {
22
+ await call().then(() => { throw new Error('expected rejection'); }, error => {
23
+ (error instanceof UnsupportedReactorOperation).should.be.true;
24
+ error.operation.should.equal(operation);
25
+ error.message.should.contain('Use a kernel-backed test.');
26
+ });
27
+ }
28
+
29
+ describe('unsupported reactor operations', () => {
30
+ it('rejects missing registration, custom sequence and commands during configuration', () => {
31
+ class Plain {}
32
+ (() => new ReactorScenario(Plain, options)).should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
33
+ @reactor('custom-sequence', 'custom') class Custom { registered() {} }
34
+ (() => new ReactorScenario(Custom, options)).should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
35
+ @reactor('commands') class Commands { registered() {} }
36
+ (() => new ReactorScenario(Commands, { ...options, commandTypes: [Unregistered] }))
37
+ .should.throw(UnsupportedReactorOperation, 'Use a kernel-backed test.');
38
+ });
39
+
40
+ it('rejects replay and redelivery asynchronously without mutation', async () => {
41
+ @reactor('replay-rejection') class R { registered() {} }
42
+ const scenario = new ReactorScenario(R, options);
43
+ await rejected('replay', () => scenario.replay());
44
+ await rejected('redeliver', () => scenario.redeliver());
45
+ await rejected('when.events', () => scenario.when.forEventSource('A').events());
46
+ scenario.results.length.should.equal(0);
47
+ scenario.sideEffects.length.should.equal(0);
48
+ });
49
+
50
+ it('rejects unsupported returned values instead of silently dropping them', async () => {
51
+ for (const value of [{ command: true }, [new Registered('ok'), { command: true }], [undefined]]) {
52
+ @reactor('unknown-result') class R { registered() { return value; } }
53
+ const scenario = new ReactorScenario(R, options);
54
+ await rejected('handler.return', () => scenario.when.forEventSource('A').events(new Registered('one')));
55
+ scenario.sideEffects.length.should.equal(0);
56
+ scenario.results[0].completed.should.be.false;
57
+ }
58
+ });
59
+
60
+ it('rejects unsupported setup before invocation and without delivery results', async () => {
61
+ let called = 0;
62
+ @reactor('unsupported-input') class R { registered() { called++; } }
63
+ const scenario = new ReactorScenario(R, options);
64
+ await scenario.given.forEventSource('A').events(new Registered('ok'), new Unregistered('bad')).then(() => {
65
+ throw new Error('expected rejection');
66
+ }, error => { error.message.should.contain('Use a kernel-backed test.'); });
67
+ called.should.equal(0);
68
+ scenario.results.length.should.equal(0);
69
+ await scenario.when.forEventSource('A').events(new Registered('after'));
70
+ scenario.results[0].handled[0].sequenceNumber.should.equal(0n);
71
+ });
72
+
73
+ it('rejects missing read-model services instead of supplying fake data', async () => {
74
+ @reactor('read-model-rejection') class R {
75
+ registered(_event: Registered, _context: unknown, services: { readModels: { findInstanceById(): unknown } }) {
76
+ return services.readModels.findInstanceById();
77
+ }
78
+ }
79
+ const scenario = new ReactorScenario(R, options);
80
+ await rejected('services.readModels.findInstanceById', () => scenario.when.forEventSource('A').events(new Registered('one')));
81
+ });
82
+
83
+ it('preserves processing-only, completion-only and activation failures', async () => {
84
+ @reactor('failure-rejection') class R { registered() { throw new Error('handler'); } }
85
+ const processing = new ReactorScenario(R, { ...options, artifactActivator: type => ({ instance: new type() }) });
86
+ await processing.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
87
+ error => { String(error).should.contain('handler'); });
88
+ processing.results[0].completed.should.be.false;
89
+ @reactor('completion-rejection') class Complete { registered() {} }
90
+ const completion = new ReactorScenario(Complete, { ...options, artifactActivator: type => ({ instance: new type(),
91
+ complete: () => { throw new Error('completion'); } }) });
92
+ await completion.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
93
+ error => { (error instanceof ArtifactCompletionFailed).should.be.true; chai.expect(error.processingError).to.be.undefined; });
94
+ completion.results[0].completed.should.be.false;
95
+ const activation = new ReactorScenario(Complete, { ...options, artifactActivator: () => { throw new Error('activation'); } });
96
+ await activation.when.forEventSource('A').events(new Registered('one')).then(() => { throw new Error('expected rejection'); },
97
+ error => { String(error).should.contain('activation'); });
98
+ activation.results[0].handled.length.should.equal(0);
99
+ });
100
+ });