@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
@@ -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,7 @@ 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
+ /** Fixture-backed, scenario-local append sequence; no kernel or observer scheduler is started. */
35
38
  export class InProcessEventSequence implements IEventSequence {
36
39
  readonly id: EventSequenceId;
37
40
  private readonly _catalog = new Map<Function, ReturnType<typeof getEventTypeMetadata>>();
@@ -42,8 +45,11 @@ export class InProcessEventSequence implements IEventSequence {
42
45
  private readonly _correlationId?: () => string;
43
46
  private _setup = false;
44
47
  private _allowSeedAppend = false;
48
+ private _stagedSeeds?: AppendedEvent[];
49
+ private _stagedNotifications?: AppendedEventWithResult[][];
45
50
  private _busy = false;
46
51
  private readonly _results: AppendResult[] = [];
52
+ readonly appendOperations = new AppendOperationsBroadcaster<AppendedEventWithResult[]>();
47
53
 
48
54
  constructor(options: EventScenarioOptions, eventTypes: Constructor[]) {
49
55
  if (options.eventSequenceId && options.eventSequenceId.value !== EventSequenceId.eventLog.value) {
@@ -100,24 +106,35 @@ export class InProcessEventSequence implements IEventSequence {
100
106
  get results(): readonly AppendResult[] { return Object.freeze([...this._results]); }
101
107
  get appendedEvents(): readonly AppendedEvent[] { return Object.freeze(this._history.map(event => this.snapshot(event))); }
102
108
 
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.');
109
+ /** Validate and stage all setup appends, then commit and notify in single-append order. */
110
+ async seed(source: string, events: object[]): Promise<void> {
111
+ if (this._setup || this._busy) throw this.unsupported('given.events', this.id.value, 'Overlapping setup calls are not fixture-backed.');
106
112
  this._setup = true;
107
- this._allowSeedAppend = true;
108
- let pending: Promise<AppendResult>;
109
- try { pending = this.append(source, event); }
110
- finally { this._allowSeedAppend = false; }
113
+ this._stagedSeeds = [];
114
+ this._stagedNotifications = [];
111
115
  try {
112
- const result = await pending;
113
- if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
116
+ for (const event of events) {
117
+ this._allowSeedAppend = true;
118
+ let pending: Promise<AppendResult>;
119
+ try { pending = this.append(source, event); }
120
+ finally { this._allowSeedAppend = false; }
121
+ const result = await pending;
122
+ if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
123
+ }
124
+ for (const [index, stored] of this._stagedSeeds.entries()) {
125
+ this._history.push(stored);
126
+ this.appendOperations.publish(this._stagedNotifications[index]);
127
+ // Let an async subscriber observe this append before the next enters history.
128
+ await Promise.resolve();
129
+ }
114
130
  } finally {
131
+ this._stagedSeeds = undefined;
132
+ this._stagedNotifications = undefined;
115
133
  this._setup = false;
116
134
  }
117
135
  }
118
136
 
119
137
  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
138
 
122
139
  async append(eventSourceId: string, event: object, options?: AppendOptions): Promise<AppendResult> {
123
140
  if (this._busy || (this._setup && !this._allowSeedAppend)) {
@@ -168,7 +185,7 @@ export class InProcessEventSequence implements IEventSequence {
168
185
  }
169
186
  const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
170
187
  const hash = createHash('sha256').update(`${prepared.eventType.id.value}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64');
171
- const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length));
188
+ const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + (this._stagedSeeds?.length ?? 0)));
172
189
  const stored: AppendedEvent = {
173
190
  eventType: prepared.eventType,
174
191
  content,
@@ -182,12 +199,16 @@ export class InProcessEventSequence implements IEventSequence {
182
199
  tags: prepared.tags.map(tag => new Tag(tag))
183
200
  }
184
201
  };
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
- });
202
+ if (this._stagedSeeds) this._stagedSeeds.push(this.snapshot(stored));
203
+ else this._history.push(this.snapshot(stored));
204
+ const result = this.success(sequenceNumber, event.constructor.name);
190
205
  if (!this._setup) this._results.push(result);
206
+ if (this._stagedNotifications || this.appendOperations.hasSubscribers) {
207
+ const notification = [createAppendNotification(eventSourceId, event, result,
208
+ prepared.correlationId.toString(), mapAppendNotificationCausation(prepared.causationChain), prepared.tags)];
209
+ if (this._stagedNotifications) this._stagedNotifications.push(notification);
210
+ else this.appendOperations.publish(notification);
211
+ }
191
212
  return result;
192
213
  } finally {
193
214
  // Keep the append in flight until its promise crosses an async boundary.
@@ -196,20 +217,114 @@ export class InProcessEventSequence implements IEventSequence {
196
217
  }
197
218
  }
198
219
 
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.');
220
+ appendMany(eventSourceId: string, events: object[], options?: AppendOptions): Promise<AppendResult[]>;
221
+ appendMany(events: EventForEventSourceId[], options?: AppendOptions): Promise<AppendResult[]>;
222
+ async appendMany(sourceOrEvents: string | EventForEventSourceId[], eventsOrOptions?: object[] | AppendOptions, options?: AppendOptions): Promise<AppendResult[]> {
223
+ if (this._busy || this._setup) throw this.unsupported('appendMany', this.id.value, 'Overlapping calls do not have proven ordering.');
224
+ this._busy = true;
225
+ try {
226
+ const entries = typeof sourceOrEvents === 'string' ? eventsOrOptions as object[] : sourceOrEvents;
227
+ const shared = typeof sourceOrEvents === 'string' ? options : eventsOrOptions as AppendOptions | undefined;
228
+ if (!Array.isArray(entries)) throw this.unsupported('appendMany.arguments', this.id.value, 'Events must be an array.');
229
+ if (!entries.length) throw this.unsupported('appendMany.empty', this.id.value, 'The kernel rejects an empty batch.');
230
+ this.validateBatchOptions(shared);
231
+ if (shared?.occurred !== undefined) this.checkedDate(shared.occurred, 'appendMany.occurred');
232
+ for (const entry of entries) {
233
+ if (typeof sourceOrEvents === 'string') break;
234
+ if (!entry || typeof entry !== 'object' || Reflect.ownKeys(entry).some(key =>
235
+ !['eventSourceId', 'event', 'eventSourceType', 'eventStreamType', 'eventStreamId', 'subject', 'occurred', 'tags'].includes(String(key)))) {
236
+ throw this.unsupported('appendMany.entry', this.id.value, 'Unrecognized per-entry metadata.');
237
+ }
238
+ const individual = entry as EventForEventSourceId;
239
+ if (individual.occurred !== undefined) this.checkedDate(individual.occurred, 'appendMany.occurred');
240
+ const tags = individual.tags;
241
+ if (tags && (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string'))) {
242
+ throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
243
+ }
244
+ }
245
+ let prepared: ReturnType<typeof prepareBatchAppend>;
246
+ try {
247
+ const batchOptions: AppendOptions | undefined = shared?.correlationId === undefined && this._correlationId
248
+ ? { correlationId: this._correlationId(), sourceType: shared?.sourceType, streamType: shared?.streamType,
249
+ streamId: shared?.streamId, subject: shared?.subject, occurred: shared?.occurred, tags: shared?.tags }
250
+ : shared;
251
+ if (batchOptions?.correlationId !== undefined) this.validateGuid(batchOptions.correlationId, 'appendMany.correlationId');
252
+ prepared = typeof sourceOrEvents === 'string'
253
+ ? prepareBatchAppend(sourceOrEvents, entries as object[], batchOptions)
254
+ : prepareBatchAppend(sourceOrEvents, batchOptions);
255
+ } catch (error) {
256
+ if (error instanceof UnsupportedEventSequenceOperation) throw error;
257
+ throw this.unsupported('appendMany.serialization', this.id.value, `Batch preparation failed: ${String(error)}.`);
258
+ }
259
+ const { eventsForEventSourceIds, eventsToAppend, correlationId, batchCausationChain, identity } = prepared;
260
+ this.validateGuid(correlationId, 'appendMany.correlationId');
261
+ if (identity !== Identity.system || batchCausationChain.length !== 2 ||
262
+ Object.keys(batchCausationChain[0].properties).length !== 0 ||
263
+ batchCausationChain[1].type.name !== 'TypeScriptClient.AppendMany' ||
264
+ batchCausationChain[1].properties.count !== String(entries.length)) {
265
+ throw this.unsupported('appendMany.context', this.id.value, 'Ambient identity or causation metadata is not fixture-backed.');
266
+ }
267
+ const staged = eventsForEventSourceIds.map(({ eventSourceId, event }, index) => {
268
+ if (!event || typeof event !== 'object' || !this._catalog.has(event.constructor)) {
269
+ throw this.unsupported('appendMany.event', event?.constructor?.name ?? this.id.value, 'Event is not in the selected catalog.');
270
+ }
271
+ this.validateSource(eventSourceId, 'appendMany.source');
272
+ const wire = eventsToAppend[index];
273
+ for (const [name, value] of Object.entries({ sourceType: wire.EventSourceType, streamType: wire.EventStreamType,
274
+ streamId: wire.EventStreamId, subject: wire.Subject })) {
275
+ if (value !== undefined && (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value))) {
276
+ throw this.unsupported(`appendMany.${name}`, String(value), 'Only simple metadata identifiers are fixture-backed.');
277
+ }
278
+ }
279
+ if (wire.Tags.some(tag => !/^[A-Za-z0-9_-]+$/.test(tag))) {
280
+ throw this.unsupported('appendMany.tags', event.constructor.name, 'Only simple string tags are fixture-backed.');
281
+ }
282
+ const occurred = wire.Occurred ? this.checkedDate(new Date(wire.Occurred.Value), 'appendMany.occurred')
283
+ : this.checkedDate(this._clock(), 'appendMany.clock');
284
+ const content = this.checkedContent(event, wire.Content, 'appendMany.content');
285
+ const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
286
+ const hash = createHash('sha256').update(`${wire.EventType.Id}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64');
287
+ const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length + index));
288
+ const eventType = getEventTypeFor(event.constructor);
289
+ const stored: AppendedEvent = { eventType, content, context: {
290
+ eventStore: this._store, namespace: this._namespace, sequenceNumber: sequenceNumber.value, eventSourceId,
291
+ eventSourceType: wire.EventSourceType || 'Default', eventStreamType: wire.EventStreamType || 'All',
292
+ eventStreamId: wire.EventStreamId || 'Default', subject: wire.Subject, hash, causedBy: identity,
293
+ observationState: EventObservationState.Initial, eventType, occurred, correlationId: correlationId.toString(),
294
+ causation: batchCausationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
295
+ tags: wire.Tags.map(tag => new Tag(tag))
296
+ } };
297
+ return { stored, result: this.success(sequenceNumber, event.constructor.name) };
298
+ });
299
+ // Stage and validate every entry before committing any event or result.
300
+ this._history.push(...staged.map(item => this.snapshot(item.stored)));
301
+ const results = staged.map(item => item.result);
302
+ this._results.push(...results);
303
+ if (this.appendOperations.hasSubscribers) {
304
+ const occurredAt = new Date();
305
+ const causationEntries = mapAppendNotificationCausation(batchCausationChain);
306
+ this.appendOperations.publish(staged.map(({ result }, index) =>
307
+ createAppendNotification(eventsForEventSourceIds[index].eventSourceId, eventsForEventSourceIds[index].event, result,
308
+ correlationId.toString(), causationEntries, eventsToAppend[index].Tags, occurredAt)));
309
+ }
310
+ return results;
311
+ } finally {
312
+ await Promise.resolve();
313
+ this._busy = false;
314
+ }
203
315
  }
204
316
 
205
317
  async getNextSequenceNumber(): Promise<EventSequenceNumber> { return new EventSequenceNumber(BigInt(this._history.length)); }
206
318
 
207
319
  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
320
  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);
321
+ this.validateRouteFilters('getTailSequenceNumber.filters', sourceType, streamType, streamId);
322
+ const ids = this.filterIds(types);
323
+ const last = [...this._history].reverse().find(event => (source === undefined || event.context.eventSourceId === source) &&
324
+ (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) &&
325
+ (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) &&
326
+ (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId) &&
327
+ (!ids.length || ids.includes(event.eventType.id.value)));
213
328
  return last ? new EventSequenceNumber(last.context.sequenceNumber) : EventSequenceNumber.unset;
214
329
  }
215
330
 
@@ -223,32 +338,102 @@ export class InProcessEventSequence implements IEventSequence {
223
338
  }
224
339
 
225
340
  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
- }
341
+ if (!types.length) throw this.unsupported('getForEventSourceIdAndEventTypes.filters', source, 'Explicit event types are required.');
229
342
  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));
343
+ this.validateRouteFilters('getForEventSourceIdAndEventTypes.filters', sourceType, streamType, streamId);
344
+ const ids = this.filterIds(types);
345
+ return this._history.filter(event => event.context.eventSourceId === source && ids.includes(event.eventType.id.value) &&
346
+ (sourceType === undefined || sourceType === 'Default' || event.context.eventSourceType === sourceType) &&
347
+ (streamType === undefined || streamType === 'All' || event.context.eventStreamType === streamType) &&
348
+ (streamId === undefined || streamId === 'Default' || event.context.eventStreamId === streamId)).map(event => this.snapshot(event));
236
349
  }
237
350
 
238
351
  async getFromSequenceNumber(sequence: EventSequenceNumber, source?: string, types?: Constructor[]): Promise<AppendedEvent[]> {
239
352
  if (sequence.value < 0n || sequence.value > EventSequenceNumber.unset.value) {
240
353
  throw this.unsupported('getFromSequenceNumber.sequenceNumber', sequence.value.toString(), 'Sequence numbers must fit unsigned 64-bit wire values.');
241
354
  }
242
- if (types?.length) throw this.unsupported('getFromSequenceNumber.filterEventTypes', this.id.value, 'Event-type filtering on sequence reads is not fixture-backed.');
243
355
  if (source !== undefined) this.validateReadSource('getFromSequenceNumber.source', source);
356
+ const ids = this.filterIds(types);
244
357
  return this._history.filter(event => event.context.sequenceNumber >= sequence.value &&
245
- (source === undefined || event.context.eventSourceId === source)).map(event => this.snapshot(event));
358
+ (source === undefined || event.context.eventSourceId === source) &&
359
+ (!ids.length || ids.includes(event.eventType.id.value))).map(event => this.snapshot(event));
246
360
  }
247
361
 
248
362
  async redact(): Promise<void> { throw this.unsupported('redact', this.id.value, 'Redaction requires kernel storage.'); }
249
363
  async redactForEventSource(): Promise<void> { throw this.unsupported('redactForEventSource', this.id.value, 'Redaction requires kernel storage.'); }
250
364
  async completeStream(): Promise<CompleteStreamResult> { throw this.unsupported('completeStream', this.id.value, 'Stream completion requires a kernel.'); }
251
365
 
366
+ private filterIds(types?: Constructor[]): string[] {
367
+ return (types ?? []).map(type => {
368
+ const metadata = this._catalog.get(type);
369
+ if (!metadata) throw this.unsupported('read.eventTypes', type.name, 'Event type is not in the selected catalog.');
370
+ return metadata.eventType.id.value;
371
+ });
372
+ }
373
+
374
+ private validateRouteFilters(operation: string, ...filters: (string | undefined)[]): void {
375
+ for (const filter of filters) {
376
+ if (filter !== undefined && (typeof filter !== 'string' || !/^[A-Za-z0-9_-]+$/.test(filter))) {
377
+ throw this.unsupported(operation, String(filter), 'Only simple, nonblank route filters are fixture-backed.');
378
+ }
379
+ }
380
+ }
381
+
382
+ private validateBatchOptions(options?: AppendOptions): void {
383
+ if (options === undefined) return;
384
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
385
+ throw this.unsupported('appendMany.options', this.id.value, 'Append options must be an object.');
386
+ }
387
+ const allowed = ['correlationId', 'sourceType', 'streamType', 'streamId', 'subject', 'occurred', 'tags'];
388
+ if (Reflect.ownKeys(options).some(key => !allowed.includes(String(key))) ||
389
+ options.concurrencyScope !== undefined || options.concurrencyScopes !== undefined || options.eventSourceId !== undefined) {
390
+ throw this.unsupported('appendMany.options', this.id.value, 'Concurrency and unrecognized metadata are not fixture-backed.');
391
+ }
392
+ if (options.tags && (!Array.isArray(options.tags) || options.tags.some(tag => typeof tag !== 'string'))) {
393
+ throw this.unsupported('appendMany.tags', this.id.value, 'Only plain string tags are fixture-backed.');
394
+ }
395
+ }
396
+
397
+ private validateGuid(value: string | Guid, operation: string): void {
398
+ try {
399
+ toContractsGuid(Guid.as(value));
400
+ if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value.toString())) throw new Error('Invalid GUID.');
401
+ } catch { throw this.unsupported(operation, this.id.value, 'Correlation ID must be a valid GUID.'); }
402
+ }
403
+
404
+ private checkedDate(value: Date, operation: string): Date {
405
+ if (!(value instanceof Date) || Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1 || value.getUTCFullYear() > 9999) {
406
+ throw this.unsupported(operation, this.id.value, 'Occurrence must be within UTC years 1–9999 at millisecond precision.');
407
+ }
408
+ return new Date(value);
409
+ }
410
+
411
+ private validateSource(source: string, operation: string): void {
412
+ if (typeof source !== 'string' || !/^[A-Za-z0-9_-]+$/.test(source)) {
413
+ throw this.unsupported(operation, String(source), 'Only simple source identifiers are fixture-backed.');
414
+ }
415
+ }
416
+
417
+ private checkedContent(event: object, serialized: string, operation: string): Record<string, unknown> {
418
+ let content: Record<string, unknown>;
419
+ try { content = JSON.parse(serialized) as Record<string, unknown>; }
420
+ catch { throw this.unsupported(operation, event.constructor.name, 'Content must be valid JSON.'); }
421
+ const properties = this._catalog.get(event.constructor)!.schema.properties!;
422
+ if (!content || typeof content !== 'object' || Array.isArray(content) ||
423
+ Object.keys(content).length !== Object.keys(properties).length ||
424
+ Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
425
+ (property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
426
+ throw this.unsupported(operation, event.constructor.name, 'Content differs from the fixture-backed scalar schema.');
427
+ }
428
+ return content;
429
+ }
430
+
431
+ private success(sequenceNumber: EventSequenceNumber, artifact: string): AppendResult {
432
+ return Object.freeze({ sequenceNumber: Object.freeze(sequenceNumber), constraintViolations: Object.freeze([]),
433
+ errors: Object.freeze([]), isSuccess: true,
434
+ waitForCompletion: async () => { throw this.unsupported('waitForCompletion', artifact, 'No observers run in process.'); } });
435
+ }
436
+
252
437
  private snapshot(event: AppendedEvent): AppendedEvent {
253
438
  const eventType = new EventType(new EventTypeId(event.eventType.id.value),
254
439
  new EventTypeGeneration(event.eventType.generation.value), event.eventType.tombstone);