@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.
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +6 -100
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/createAppendNotification.d.ts +15 -0
- package/dist/eventSequences/createAppendNotification.d.ts.map +1 -0
- package/dist/eventSequences/createAppendNotification.js +25 -0
- package/dist/eventSequences/createAppendNotification.js.map +1 -0
- package/dist/eventSequences/prepareBatchAppend.d.ts +31 -0
- package/dist/eventSequences/prepareBatchAppend.d.ts.map +1 -0
- package/dist/eventSequences/prepareBatchAppend.js +54 -0
- package/dist/eventSequences/prepareBatchAppend.js.map +1 -0
- package/dist/testing/EventScenario.d.ts +1 -1
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +7 -4
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/EventScenarioGiven.d.ts +1 -1
- package/dist/testing/EventScenarioGiven.d.ts.map +1 -1
- package/dist/testing/EventScenarioGiven.js +2 -5
- package/dist/testing/EventScenarioGiven.js.map +1 -1
- package/dist/testing/EventScenarioSeed.d.ts +2 -2
- package/dist/testing/EventScenarioSeed.d.ts.map +1 -1
- package/dist/testing/EventScenarioSeed.js +2 -2
- package/dist/testing/EventScenarioSeed.js.map +1 -1
- package/dist/testing/EventScenarioWhen.d.ts +1 -1
- package/dist/testing/EventScenarioWhen.d.ts.map +1 -1
- package/dist/testing/EventScenarioWhen.js +2 -3
- package/dist/testing/EventScenarioWhen.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts +18 -7
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +239 -42
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +10 -107
- package/eventSequences/createAppendNotification.ts +35 -0
- package/eventSequences/prepareBatchAppend.ts +63 -0
- package/package.json +1 -1
- package/testing/EventScenario.batches.spec.ts +537 -0
- package/testing/EventScenario.fixtures.spec.ts +2 -13
- package/testing/EventScenario.rejections.spec.ts +5 -39
- package/testing/EventScenario.ts +7 -5
- package/testing/EventScenarioGiven.ts +2 -5
- package/testing/EventScenarioSeed.ts +4 -4
- package/testing/EventScenarioWhen.ts +2 -4
- package/testing/InProcessEventSequence.ts +223 -38
package/testing/EventScenario.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(...
|
|
13
|
-
|
|
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
|
|
7
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
104
|
-
async seed(source: string,
|
|
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.
|
|
108
|
-
|
|
109
|
-
try { pending = this.append(source, event); }
|
|
110
|
-
finally { this._allowSeedAppend = false; }
|
|
113
|
+
this._stagedSeeds = [];
|
|
114
|
+
this._stagedNotifications = [];
|
|
111
115
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
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.
|
|
186
|
-
|
|
187
|
-
|
|
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(
|
|
200
|
-
appendMany(
|
|
201
|
-
async appendMany(): Promise<AppendResult[]> {
|
|
202
|
-
throw this.unsupported('appendMany', this.id.value, '
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
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);
|