@cratis/chronicle 6.18.0 → 6.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +2 -9
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/prepareSingleAppend.d.ts +12 -0
- package/dist/eventSequences/prepareSingleAppend.d.ts.map +1 -0
- package/dist/eventSequences/prepareSingleAppend.js +22 -0
- package/dist/eventSequences/prepareSingleAppend.js.map +1 -0
- package/dist/testing/EventScenario.d.ts +29 -0
- package/dist/testing/EventScenario.d.ts.map +1 -0
- package/dist/testing/EventScenario.js +60 -0
- package/dist/testing/EventScenario.js.map +1 -0
- package/dist/testing/EventScenarioGiven.d.ts +9 -0
- package/dist/testing/EventScenarioGiven.d.ts.map +1 -0
- package/dist/testing/EventScenarioGiven.js +19 -0
- package/dist/testing/EventScenarioGiven.js.map +1 -0
- package/dist/testing/EventScenarioGivenBuilder.d.ts +9 -0
- package/dist/testing/EventScenarioGivenBuilder.d.ts.map +1 -0
- package/dist/testing/EventScenarioGivenBuilder.js +12 -0
- package/dist/testing/EventScenarioGivenBuilder.js.map +1 -0
- package/dist/testing/EventScenarioOptions.d.ts +21 -0
- package/dist/testing/EventScenarioOptions.d.ts.map +1 -0
- package/dist/testing/EventScenarioOptions.js +4 -0
- package/dist/testing/EventScenarioOptions.js.map +1 -0
- package/dist/testing/EventScenarioSeed.d.ts +5 -0
- package/dist/testing/EventScenarioSeed.d.ts.map +1 -0
- package/dist/testing/EventScenarioSeed.js +14 -0
- package/dist/testing/EventScenarioSeed.js.map +1 -0
- package/dist/testing/EventScenarioWhen.d.ts +11 -0
- package/dist/testing/EventScenarioWhen.d.ts.map +1 -0
- package/dist/testing/EventScenarioWhen.js +17 -0
- package/dist/testing/EventScenarioWhen.js.map +1 -0
- package/dist/testing/EventScenarioWhenBuilder.d.ts +9 -0
- package/dist/testing/EventScenarioWhenBuilder.d.ts.map +1 -0
- package/dist/testing/EventScenarioWhenBuilder.js +12 -0
- package/dist/testing/EventScenarioWhenBuilder.js.map +1 -0
- package/dist/testing/IReadModelProcessor.d.ts +7 -0
- package/dist/testing/IReadModelProcessor.d.ts.map +1 -0
- package/dist/testing/IReadModelProcessor.js +4 -0
- package/dist/testing/IReadModelProcessor.js.map +1 -0
- package/dist/testing/InProcessEventSequence.d.ts +49 -0
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -0
- package/dist/testing/InProcessEventSequence.js +265 -0
- package/dist/testing/InProcessEventSequence.js.map +1 -0
- package/dist/testing/ReadModelScenario.d.ts +6 -8
- package/dist/testing/ReadModelScenario.d.ts.map +1 -1
- package/dist/testing/ReadModelScenario.js +50 -50
- package/dist/testing/ReadModelScenario.js.map +1 -1
- package/dist/testing/ReadModelState.d.ts +6 -0
- package/dist/testing/ReadModelState.d.ts.map +1 -0
- package/dist/testing/ReadModelState.js +4 -0
- package/dist/testing/ReadModelState.js.map +1 -0
- package/dist/testing/ReducerReadModelProcessor.d.ts +13 -0
- package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -0
- package/dist/testing/ReducerReadModelProcessor.js +38 -0
- package/dist/testing/ReducerReadModelProcessor.js.map +1 -0
- package/dist/testing/ScenarioEvent.d.ts +8 -0
- package/dist/testing/ScenarioEvent.d.ts.map +1 -0
- package/dist/testing/ScenarioEvent.js +4 -0
- package/dist/testing/ScenarioEvent.js.map +1 -0
- package/dist/testing/UnsupportedEventSequenceOperation.d.ts +5 -0
- package/dist/testing/UnsupportedEventSequenceOperation.d.ts.map +1 -0
- package/dist/testing/UnsupportedEventSequenceOperation.js +10 -0
- package/dist/testing/UnsupportedEventSequenceOperation.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.d.ts +3 -0
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.js +117 -43
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts +9 -0
- package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionExpressionEvaluator.js +68 -0
- package/dist/testing/projections/ProjectionExpressionEvaluator.js.map +1 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +30 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.js +142 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -0
- package/dist/testing/projections/ProjectionValueConverter.d.ts +13 -0
- package/dist/testing/projections/ProjectionValueConverter.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionValueConverter.js +122 -0
- package/dist/testing/projections/ProjectionValueConverter.js.map +1 -0
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts +0 -2
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts.map +1 -1
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js +0 -4
- package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +2 -12
- package/eventSequences/prepareSingleAppend.ts +25 -0
- package/package.json +1 -1
- package/testing/EventScenario.fixtures.spec.ts +271 -0
- package/testing/EventScenario.rejections.spec.ts +307 -0
- package/testing/EventScenario.ts +77 -0
- package/testing/EventScenarioGiven.ts +16 -0
- package/testing/EventScenarioGivenBuilder.ts +11 -0
- package/testing/EventScenarioOptions.ts +20 -0
- package/testing/EventScenarioSeed.ts +17 -0
- package/testing/EventScenarioWhen.ts +16 -0
- package/testing/EventScenarioWhenBuilder.ts +11 -0
- package/testing/IReadModelProcessor.ts +10 -0
- package/testing/InProcessEventSequence.ts +275 -0
- package/testing/ReadModelScenario.spec.ts +156 -10
- package/testing/ReadModelScenario.ts +57 -57
- package/testing/ReadModelState.ts +5 -0
- package/testing/ReducerReadModelProcessor.ts +39 -0
- package/testing/ScenarioEvent.ts +7 -0
- package/testing/UnsupportedEventSequenceOperation.ts +10 -0
- package/testing/index.ts +8 -0
- package/testing/projections/ProjectionCapabilities.ts +100 -39
- package/testing/projections/ProjectionExpressionEvaluator.ts +57 -0
- package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +135 -0
- package/testing/projections/ProjectionReadModelProcessor.ts +147 -0
- package/testing/projections/ProjectionValueConverter.spec.ts +81 -0
- package/testing/projections/ProjectionValueConverter.ts +113 -0
- package/testing/projections/for_ProjectionCapabilities/given/Changed.ts +1 -3
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +81 -1
- package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +40 -12
- package/testing/projections/for_ProjectionCapabilities/when_validating_schema_boundaries.spec.ts +76 -29
- package/testing/projections/for_ProjectionCapabilities/when_validating_supported_operations.spec.ts +8 -35
|
@@ -0,0 +1,16 @@
|
|
|
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 { EventScenario } from './EventScenario.js';
|
|
5
|
+
import { seedScenario } from './EventScenarioSeed.js';
|
|
6
|
+
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
7
|
+
|
|
8
|
+
/** Seeds exactly one event through the ordinary single-append pipeline. */
|
|
9
|
+
export class EventScenarioGiven {
|
|
10
|
+
constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
|
|
11
|
+
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]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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 { EventScenario } from './EventScenario.js';
|
|
5
|
+
import { EventScenarioGiven } from './EventScenarioGiven.js';
|
|
6
|
+
|
|
7
|
+
/** Selects the source for setup events. */
|
|
8
|
+
export class EventScenarioGivenBuilder {
|
|
9
|
+
constructor(private readonly _scenario: EventScenario) {}
|
|
10
|
+
forEventSource(id: string): EventScenarioGiven { return new EventScenarioGiven(this._scenario, id); }
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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 { Constructor } from '@cratis/fundamentals';
|
|
5
|
+
import type { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
6
|
+
|
|
7
|
+
/** Configuration captured once when constructing an event scenario. */
|
|
8
|
+
export interface EventScenarioOptions {
|
|
9
|
+
/** An isolated catalog; omitting it discovers process-wide event and constraint artifacts. */
|
|
10
|
+
artifacts?: { eventTypes: Constructor[]; constraints?: Constructor[]; eventTypeMigrations?: Constructor[] };
|
|
11
|
+
/** Constraint discovery is the default. Explicitly disable it only for tests without constraints. */
|
|
12
|
+
constraints?: 'disabled';
|
|
13
|
+
/** Scenario-local identifiers; only the fixture-backed defaults are supported in this increment. */
|
|
14
|
+
eventStore?: string;
|
|
15
|
+
namespace?: string;
|
|
16
|
+
eventSequenceId?: EventSequenceId;
|
|
17
|
+
/** Deterministic sources for metadata otherwise provided by the environment. */
|
|
18
|
+
clock?: () => Date;
|
|
19
|
+
correlationId?: () => string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { EventScenario } from './EventScenario.js';
|
|
5
|
+
|
|
6
|
+
const seeders = new WeakMap<EventScenario, (source: string, event: object) => Promise<void>>();
|
|
7
|
+
|
|
8
|
+
/** Internal builder wiring; not exported by the testing package. */
|
|
9
|
+
export function registerScenarioSeed(scenario: EventScenario, seed: (source: string, event: object) => Promise<void>): void {
|
|
10
|
+
seeders.set(scenario, seed);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function seedScenario(scenario: EventScenario, source: string, event: object): Promise<void> {
|
|
14
|
+
const seed = seeders.get(scenario);
|
|
15
|
+
if (!seed) throw new Error('EventScenario setup requires a constructed scenario.');
|
|
16
|
+
return seed(source, event);
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 { AppendResult } from '../eventSequences/AppendResult.js';
|
|
5
|
+
import type { EventScenario } from './EventScenario.js';
|
|
6
|
+
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
7
|
+
|
|
8
|
+
/** A single act-phase append; the plural form is reserved for atomic batches. */
|
|
9
|
+
export class EventScenarioWhen {
|
|
10
|
+
constructor(private readonly _scenario: EventScenario, private readonly _id: string) {}
|
|
11
|
+
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.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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 { EventScenario } from './EventScenario.js';
|
|
5
|
+
import { EventScenarioWhen } from './EventScenarioWhen.js';
|
|
6
|
+
|
|
7
|
+
/** Selects the source for an act-phase append. */
|
|
8
|
+
export class EventScenarioWhenBuilder {
|
|
9
|
+
constructor(private readonly _scenario: EventScenario) {}
|
|
10
|
+
forEventSource(id: string): EventScenarioWhen { return new EventScenarioWhen(this._scenario, id); }
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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 { ReadModelState } from './ReadModelState.js';
|
|
5
|
+
import type { ScenarioEvent } from './ScenarioEvent.js';
|
|
6
|
+
|
|
7
|
+
/** Replays the complete serialized scenario history for independent event sources. */
|
|
8
|
+
export interface IReadModelProcessor<TReadModel extends object> {
|
|
9
|
+
process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
5
|
+
import { EventObservationState } from '@cratis/chronicle.contracts';
|
|
6
|
+
import type { Constructor } from '@cratis/fundamentals';
|
|
7
|
+
import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
8
|
+
import { getTagsFor } from '../events/tagDecorator.js';
|
|
9
|
+
import type { AppendedEvent } from '../events/AppendedEvent.js';
|
|
10
|
+
import { Tag } from '../events/Tag.js';
|
|
11
|
+
import { EventType } from '../events/EventType.js';
|
|
12
|
+
import { EventTypeId } from '../events/EventTypeId.js';
|
|
13
|
+
import { EventTypeGeneration } from '../events/EventTypeGeneration.js';
|
|
14
|
+
import { Identity } from '../identity/Identity.js';
|
|
15
|
+
import type { AppendOptions } from '../eventSequences/AppendOptions.js';
|
|
16
|
+
import type { AppendResult } from '../eventSequences/AppendResult.js';
|
|
17
|
+
import type { AppendedEventWithResult } from '../eventSequences/AppendedEventWithResult.js';
|
|
18
|
+
import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
|
|
19
|
+
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
20
|
+
import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
|
|
21
|
+
import type { IEventSequence } from '../eventSequences/IEventSequence.js';
|
|
22
|
+
import type { ITransactionalEventSequence } from '../eventSequences/ITransactionalEventSequence.js';
|
|
23
|
+
import type { CompleteStreamResult } from '../eventSequences/CompleteStreamResult.js';
|
|
24
|
+
import { prepareSingleAppend } from '../eventSequences/prepareSingleAppend.js';
|
|
25
|
+
import { toContractsGuid } from '../connection/Guid.js';
|
|
26
|
+
import { getUniqueEventMetadata, getUniquePropertyMetadata } from '../events/constraints/unique.js';
|
|
27
|
+
import { getRemovedConstraintNames } from '../events/constraints/removeConstraint.js';
|
|
28
|
+
import type { EventScenarioOptions } from './EventScenarioOptions.js';
|
|
29
|
+
import { UnsupportedEventSequenceOperation } from './UnsupportedEventSequenceOperation.js';
|
|
30
|
+
|
|
31
|
+
// JS trim() omits U+0085, which the kernel trims. Reject unproven non-ASCII whitespace and controls.
|
|
32
|
+
const unprovenFilterCharacters = /[\u007f-\u009f]|(?=[^\x00-\x7f])\p{White_Space}/u;
|
|
33
|
+
|
|
34
|
+
/** Fixture-backed, scenario-local single-append sequence; no kernel or observer scheduler is started. */
|
|
35
|
+
export class InProcessEventSequence implements IEventSequence {
|
|
36
|
+
readonly id: EventSequenceId;
|
|
37
|
+
private readonly _catalog = new Map<Function, ReturnType<typeof getEventTypeMetadata>>();
|
|
38
|
+
private readonly _history: AppendedEvent[] = [];
|
|
39
|
+
private readonly _store: string;
|
|
40
|
+
private readonly _namespace: string;
|
|
41
|
+
private readonly _clock: () => Date;
|
|
42
|
+
private readonly _correlationId?: () => string;
|
|
43
|
+
private _setup = false;
|
|
44
|
+
private _allowSeedAppend = false;
|
|
45
|
+
private _busy = false;
|
|
46
|
+
private readonly _results: AppendResult[] = [];
|
|
47
|
+
|
|
48
|
+
constructor(options: EventScenarioOptions, eventTypes: Constructor[]) {
|
|
49
|
+
if (options.eventSequenceId && options.eventSequenceId.value !== EventSequenceId.eventLog.value) {
|
|
50
|
+
throw this.unsupported('options.eventSequenceId', options.eventSequenceId.value, 'Custom sequences are not fixture-backed.');
|
|
51
|
+
}
|
|
52
|
+
this.id = options.eventSequenceId ?? EventSequenceId.eventLog;
|
|
53
|
+
if (options.eventStore !== undefined && options.eventStore !== 'test-event-store') {
|
|
54
|
+
throw this.unsupported('options.eventStore', options.eventStore, 'Custom event stores are not fixture-backed.');
|
|
55
|
+
}
|
|
56
|
+
if (options.namespace !== undefined && options.namespace !== 'default') {
|
|
57
|
+
throw this.unsupported('options.namespace', options.namespace, 'Custom namespaces are not fixture-backed.');
|
|
58
|
+
}
|
|
59
|
+
this._store = options.eventStore ?? 'test-event-store';
|
|
60
|
+
this._namespace = options.namespace ?? 'default';
|
|
61
|
+
this._clock = options.clock ?? (() => new Date());
|
|
62
|
+
this._correlationId = options.correlationId;
|
|
63
|
+
const ids = new Set<string>();
|
|
64
|
+
for (const type of eventTypes) {
|
|
65
|
+
const metadata = getEventTypeMetadata(type);
|
|
66
|
+
if (!metadata) throw this.unsupported('artifacts.eventTypes', type.name, 'Event type has no @eventType metadata.');
|
|
67
|
+
const eventType = metadata.eventType;
|
|
68
|
+
if (!eventType.id.value.trim() || eventType.id.value !== eventType.id.value.trim() ||
|
|
69
|
+
unprovenFilterCharacters.test(eventType.id.value) || eventType.id.value.includes(',')) {
|
|
70
|
+
throw this.unsupported('artifacts.eventTypes.id', eventType.id.value, 'Comma-separated or padded event IDs have unproven filter behavior.');
|
|
71
|
+
}
|
|
72
|
+
if (ids.has(eventType.id.value)) throw this.unsupported('artifacts.eventTypes', type.name, 'Duplicate event ID or generation history.');
|
|
73
|
+
ids.add(eventType.id.value);
|
|
74
|
+
if (eventType.generation.value !== 1 || eventType.tombstone) {
|
|
75
|
+
throw this.unsupported('artifacts.eventTypes', type.name, 'Only generation 1, non-tombstone events are proven.');
|
|
76
|
+
}
|
|
77
|
+
if (options.constraints !== 'disabled' && (getUniqueEventMetadata(type) || getRemovedConstraintNames(type).length ||
|
|
78
|
+
[...metadata.members.keys()].some(key => getUniquePropertyMetadata(type, key)))) {
|
|
79
|
+
throw this.unsupported('artifacts.eventTypes.constraints', type.name, 'Constraint metadata is not supported in this increment.');
|
|
80
|
+
}
|
|
81
|
+
if (getTagsFor(type).length) throw this.unsupported('artifacts.eventTypes.tags', type.name, 'Tagged events are not fixture-backed.');
|
|
82
|
+
// Validate the complete schema, not only properties present on a particular event.
|
|
83
|
+
const schema = metadata.schema;
|
|
84
|
+
if (schema.type !== 'object' || schema.compliance?.length || schema.security?.length ||
|
|
85
|
+
schema.enum !== undefined || schema.items !== undefined || schema.format !== undefined ||
|
|
86
|
+
schema.additionalProperties !== false ||
|
|
87
|
+
schema.required?.length !== Object.keys(schema.properties ?? {}).length ||
|
|
88
|
+
!schema.properties || Object.keys(schema.properties).length === 0 ||
|
|
89
|
+
Object.entries(schema.properties).some(([key, property]) =>
|
|
90
|
+
!/^[a-z][a-zA-Z0-9]*$/.test(key) || !['string', 'boolean'].includes(property.type ?? '') ||
|
|
91
|
+
property.format !== undefined || property.compliance?.length || property.security?.length ||
|
|
92
|
+
property.items !== undefined || property.properties !== undefined || property.enum !== undefined ||
|
|
93
|
+
property.additionalProperties !== undefined)) {
|
|
94
|
+
throw this.unsupported('artifacts.eventTypes.schema', type.name, 'Only flat, unclassified string/boolean fields are fixture-backed.');
|
|
95
|
+
}
|
|
96
|
+
this._catalog.set(type, metadata);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get results(): readonly AppendResult[] { return Object.freeze([...this._results]); }
|
|
101
|
+
get appendedEvents(): readonly AppendedEvent[] { return Object.freeze(this._history.map(event => this.snapshot(event))); }
|
|
102
|
+
|
|
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.');
|
|
106
|
+
this._setup = true;
|
|
107
|
+
this._allowSeedAppend = true;
|
|
108
|
+
let pending: Promise<AppendResult>;
|
|
109
|
+
try { pending = this.append(source, event); }
|
|
110
|
+
finally { this._allowSeedAppend = false; }
|
|
111
|
+
try {
|
|
112
|
+
const result = await pending;
|
|
113
|
+
if (!result.isSuccess) throw new Error(`EventScenario given setup failed: ${JSON.stringify(result)}`);
|
|
114
|
+
} finally {
|
|
115
|
+
this._setup = false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
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
|
+
|
|
122
|
+
async append(eventSourceId: string, event: object, options?: AppendOptions): Promise<AppendResult> {
|
|
123
|
+
if (this._busy || (this._setup && !this._allowSeedAppend)) {
|
|
124
|
+
throw this.unsupported('append', this.id.value, 'Overlapping calls do not have proven ordering.');
|
|
125
|
+
}
|
|
126
|
+
this._busy = true;
|
|
127
|
+
try {
|
|
128
|
+
if (!event || typeof event !== 'object') throw this.unsupported('append.event', this.id.value, 'Only registered event instances are supported.');
|
|
129
|
+
if (!/^[A-Za-z0-9_-]+$/.test(eventSourceId)) throw this.unsupported('append.source', eventSourceId, 'Only simple source identifiers are fixture-backed.');
|
|
130
|
+
if (options && (Reflect.ownKeys(options).length || options.correlationId !== undefined || options.sourceType !== undefined ||
|
|
131
|
+
options.streamType !== undefined || options.streamId !== undefined || options.subject !== undefined ||
|
|
132
|
+
options.occurred !== undefined || options.eventSourceId !== undefined || options.concurrencyScope !== undefined ||
|
|
133
|
+
options.tags !== undefined || options.concurrencyScopes !== undefined)) {
|
|
134
|
+
throw this.unsupported('append.options', event.constructor.name, 'Append metadata, routing and concurrency are not fixture-backed.');
|
|
135
|
+
}
|
|
136
|
+
const metadata = this._catalog.get(event.constructor);
|
|
137
|
+
if (!metadata) throw this.unsupported('append.event', event.constructor.name, 'Event is not in the selected, validated catalog.');
|
|
138
|
+
let prepared: ReturnType<typeof prepareSingleAppend>;
|
|
139
|
+
let content: Record<string, unknown>;
|
|
140
|
+
try {
|
|
141
|
+
prepared = prepareSingleAppend(event, this._correlationId ? { correlationId: this._correlationId() } : undefined);
|
|
142
|
+
content = JSON.parse(prepared.content) as Record<string, unknown>;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw this.unsupported('append.serialization', event.constructor.name, `Payload or metadata could not be serialized: ${String(error)}.`);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
toContractsGuid(prepared.correlationId);
|
|
148
|
+
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(prepared.correlationId.toString())) throw new Error('Invalid correlation ID.');
|
|
149
|
+
} catch {
|
|
150
|
+
throw this.unsupported('append.correlationId', event.constructor.name, 'Correlation ID must be a valid GUID.');
|
|
151
|
+
}
|
|
152
|
+
if (prepared.identity !== Identity.system || prepared.causationChain.length !== 2 ||
|
|
153
|
+
Object.keys(prepared.causationChain[0].properties).length !== 0) {
|
|
154
|
+
throw this.unsupported('append.context', event.constructor.name, 'Ambient identity or causation metadata is not fixture-backed.');
|
|
155
|
+
}
|
|
156
|
+
const properties = metadata.schema.properties!;
|
|
157
|
+
if (!content || typeof content !== 'object' || Array.isArray(content) ||
|
|
158
|
+
Object.keys(content).length !== Object.keys(properties).length ||
|
|
159
|
+
Object.entries(properties).some(([key, property]) => typeof content[key] !== property.type ||
|
|
160
|
+
(property.type === 'string' && !/^[\x20-\x21\x23-\x5b\x5d-\x7e\u00e9]*$/.test(content[key] as string)))) {
|
|
161
|
+
throw this.unsupported('append.content', event.constructor.name, 'Content differs from the flat scalar schema; null, missing and extra values are not proven.');
|
|
162
|
+
}
|
|
163
|
+
const occurred = this._clock();
|
|
164
|
+
// Date has millisecond precision; only the UTC year range representable by DateTimeOffset is supported.
|
|
165
|
+
if (!(occurred instanceof Date) || Number.isNaN(occurred.getTime()) ||
|
|
166
|
+
occurred.getUTCFullYear() < 1 || occurred.getUTCFullYear() > 9999) {
|
|
167
|
+
throw this.unsupported('append.clock', event.constructor.name, 'Clock must return a valid Date within UTC years 1–9999 (millisecond precision).');
|
|
168
|
+
}
|
|
169
|
+
const canonical = Object.fromEntries(Object.entries(content).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
170
|
+
const hash = createHash('sha256').update(`${prepared.eventType.id.value}|${eventSourceId}|${JSON.stringify(canonical)}`).digest('base64');
|
|
171
|
+
const sequenceNumber = new EventSequenceNumber(BigInt(this._history.length));
|
|
172
|
+
const stored: AppendedEvent = {
|
|
173
|
+
eventType: prepared.eventType,
|
|
174
|
+
content,
|
|
175
|
+
context: {
|
|
176
|
+
eventStore: this._store, namespace: this._namespace,
|
|
177
|
+
sequenceNumber: sequenceNumber.value, eventSourceId,
|
|
178
|
+
eventSourceType: 'Default', eventStreamType: 'All', eventStreamId: 'Default',
|
|
179
|
+
subject: eventSourceId, hash, causedBy: prepared.identity, observationState: EventObservationState.Initial,
|
|
180
|
+
eventType: prepared.eventType, occurred, correlationId: prepared.correlationId.toString(),
|
|
181
|
+
causation: prepared.causationChain.map(item => ({ type: item.type.name, occurred: item.occurred, properties: { ...item.properties } })),
|
|
182
|
+
tags: prepared.tags.map(tag => new Tag(tag))
|
|
183
|
+
}
|
|
184
|
+
};
|
|
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
|
+
});
|
|
190
|
+
if (!this._setup) this._results.push(result);
|
|
191
|
+
return result;
|
|
192
|
+
} finally {
|
|
193
|
+
// Keep the append in flight until its promise crosses an async boundary.
|
|
194
|
+
await Promise.resolve();
|
|
195
|
+
this._busy = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
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.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async getNextSequenceNumber(): Promise<EventSequenceNumber> { return new EventSequenceNumber(BigInt(this._history.length)); }
|
|
206
|
+
|
|
207
|
+
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
|
+
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);
|
|
213
|
+
return last ? new EventSequenceNumber(last.context.sequenceNumber) : EventSequenceNumber.unset;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async getTailSequenceNumberForObserver(observer: Constructor): Promise<EventSequenceNumber> {
|
|
217
|
+
throw this.unsupported('getTailSequenceNumberForObserver', observer.name, 'Observer discovery is not fixture-backed.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async hasEventsFor(source: string): Promise<boolean> {
|
|
221
|
+
this.validateReadSource('hasEventsFor.source', source);
|
|
222
|
+
return this._history.some(event => event.context.eventSourceId === source);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
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
|
+
}
|
|
229
|
+
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));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async getFromSequenceNumber(sequence: EventSequenceNumber, source?: string, types?: Constructor[]): Promise<AppendedEvent[]> {
|
|
239
|
+
if (sequence.value < 0n || sequence.value > EventSequenceNumber.unset.value) {
|
|
240
|
+
throw this.unsupported('getFromSequenceNumber.sequenceNumber', sequence.value.toString(), 'Sequence numbers must fit unsigned 64-bit wire values.');
|
|
241
|
+
}
|
|
242
|
+
if (types?.length) throw this.unsupported('getFromSequenceNumber.filterEventTypes', this.id.value, 'Event-type filtering on sequence reads is not fixture-backed.');
|
|
243
|
+
if (source !== undefined) this.validateReadSource('getFromSequenceNumber.source', source);
|
|
244
|
+
return this._history.filter(event => event.context.sequenceNumber >= sequence.value &&
|
|
245
|
+
(source === undefined || event.context.eventSourceId === source)).map(event => this.snapshot(event));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async redact(): Promise<void> { throw this.unsupported('redact', this.id.value, 'Redaction requires kernel storage.'); }
|
|
249
|
+
async redactForEventSource(): Promise<void> { throw this.unsupported('redactForEventSource', this.id.value, 'Redaction requires kernel storage.'); }
|
|
250
|
+
async completeStream(): Promise<CompleteStreamResult> { throw this.unsupported('completeStream', this.id.value, 'Stream completion requires a kernel.'); }
|
|
251
|
+
|
|
252
|
+
private snapshot(event: AppendedEvent): AppendedEvent {
|
|
253
|
+
const eventType = new EventType(new EventTypeId(event.eventType.id.value),
|
|
254
|
+
new EventTypeGeneration(event.eventType.generation.value), event.eventType.tombstone);
|
|
255
|
+
const causedBy = event.context.causedBy;
|
|
256
|
+
return {
|
|
257
|
+
eventType,
|
|
258
|
+
content: JSON.parse(JSON.stringify(event.content)) as Record<string, unknown>,
|
|
259
|
+
context: { ...event.context, eventType, occurred: new Date(event.context.occurred),
|
|
260
|
+
causedBy: causedBy && new Identity(causedBy.subject, causedBy.name, causedBy.userName, causedBy.onBehalfOf),
|
|
261
|
+
causation: event.context.causation.map(item => ({ ...item, occurred: item.occurred && new Date(item.occurred), properties: { ...item.properties } })),
|
|
262
|
+
tags: event.context.tags.map(item => new Tag(item.value)) }
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private validateReadSource(operation: string, source: string): void {
|
|
267
|
+
if (!source.trim() || source !== source.trim() || unprovenFilterCharacters.test(source)) {
|
|
268
|
+
throw this.unsupported(operation, source, 'Blank and padded source filters have unproven kernel normalization.');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private unsupported(operation: string, artifact: string, reason: string): UnsupportedEventSequenceOperation {
|
|
273
|
+
return new UnsupportedEventSequenceOperation(operation, artifact, reason);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import { ConceptAs, field } from '@cratis/fundamentals';
|
|
5
|
+
import { ConceptAs, field, Guid } from '@cratis/fundamentals';
|
|
6
6
|
import type { Constructor } from '@cratis/fundamentals';
|
|
7
|
+
import { pii } from '../compliance/pii.js';
|
|
7
8
|
import { eventType } from '../events/eventTypeDecorator.js';
|
|
8
9
|
import type { EventContext } from '../events/EventContext.js';
|
|
9
10
|
import { fromEvent } from '../projections/modelBound/fromEvent.js';
|
|
11
|
+
import { setFrom } from '../projections/modelBound/setFrom.js';
|
|
12
|
+
import { setFromContext } from '../projections/modelBound/setFromContext.js';
|
|
13
|
+
import { removedWith } from '../projections/modelBound/removedWith.js';
|
|
14
|
+
import type { IProjectionBuilderFor } from '../projections/declarative/IProjectionBuilderFor.js';
|
|
15
|
+
import type { IProjectionFor } from '../projections/declarative/IProjectionFor.js';
|
|
10
16
|
import { projection } from '../projections/declarative/projection.js';
|
|
11
17
|
import { reducer } from '../reducers/reducer.js';
|
|
12
|
-
import {
|
|
18
|
+
import { readModel } from '../readModels/readModel.js';
|
|
19
|
+
import { ReadModelScenario, ReadModelScenarioGivenBuilder, UnsupportedProjectionOperation } from './index.js';
|
|
13
20
|
import { readModelScenarioExample } from './ReadModelScenario.example.js';
|
|
14
21
|
|
|
15
22
|
class ItemAdded {
|
|
16
|
-
|
|
23
|
+
@field(Number) amount: number;
|
|
24
|
+
constructor(amount: number) { this.amount = amount; }
|
|
17
25
|
}
|
|
18
26
|
eventType('scenario-item-added')(ItemAdded);
|
|
19
27
|
class ItemRemoved {}
|
|
@@ -177,15 +185,153 @@ describe('ReadModelScenario', () => {
|
|
|
177
185
|
await expect(scenario.instance).rejects.toThrow('FailingReducer');
|
|
178
186
|
});
|
|
179
187
|
|
|
180
|
-
it('
|
|
181
|
-
class ProjectedState {
|
|
188
|
+
it('replays a compiled model-bound projection and recreates it after removal', async () => {
|
|
189
|
+
class ProjectedState {
|
|
190
|
+
@field(String) id = '';
|
|
191
|
+
@field(Number) count = 0;
|
|
192
|
+
}
|
|
182
193
|
fromEvent(ItemAdded)(ProjectedState);
|
|
183
|
-
|
|
194
|
+
setFrom(ItemAdded, 'amount')(ProjectedState.prototype, 'count');
|
|
195
|
+
removedWith(ItemRemoved)(ProjectedState);
|
|
196
|
+
const scenario = new ReadModelScenario(ProjectedState, artifacts);
|
|
197
|
+
scenario.given.forEventSource('A').events(new ItemAdded(3), new ItemRemoved());
|
|
198
|
+
expect(await scenario.instanceForEventSourceId('A')).toBeNull();
|
|
199
|
+
expect(await scenario.wasDeletedForEventSourceId('A')).toBe(true);
|
|
200
|
+
scenario.given.forEventSource('A').events(new UnrelatedEvent(), new ItemAdded(7));
|
|
201
|
+
expect(await scenario.instance).toMatchObject({ count: 7 });
|
|
202
|
+
expect(await scenario.wasDeletedForEventSourceId('A')).toBe(false);
|
|
203
|
+
});
|
|
184
204
|
|
|
185
|
-
|
|
186
|
-
class
|
|
205
|
+
it('discovers a property-only model-bound read model without an artifact catalog', async () => {
|
|
206
|
+
class PropertyOnlyState { @field(String) id = ''; @field(Number) count = 0; }
|
|
207
|
+
setFrom(ItemAdded, 'amount')(PropertyOnlyState.prototype, 'count');
|
|
208
|
+
const scenario = new ReadModelScenario(PropertyOnlyState);
|
|
209
|
+
scenario.given.forEventSource('A').events(new ItemAdded(5));
|
|
210
|
+
expect(await scenario.instance).toMatchObject({ count: 5 });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('defaults a projection Subject mapping to the event source when the seed has no subject', async () => {
|
|
214
|
+
class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
|
|
215
|
+
fromEvent(ItemAdded)(SubjectState);
|
|
216
|
+
setFromContext(ItemAdded, 'subject')(SubjectState.prototype, 'subject');
|
|
217
|
+
const scenario = new ReadModelScenario(SubjectState, artifacts);
|
|
218
|
+
scenario.given.forEventSource('source-a').events(new ItemAdded(1));
|
|
219
|
+
expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('defaults a projection Hash and CausedBy mapping to kernel sentinels when the seed omits them', async () => {
|
|
223
|
+
class ContextDefaults { @field(String) id = ''; @field(String) hash = ''; @field(String) causedByName = ''; }
|
|
224
|
+
setFromContext(ItemAdded, 'hash')(ContextDefaults.prototype, 'hash');
|
|
225
|
+
setFromContext(ItemAdded, 'causedBy.name')(ContextDefaults.prototype, 'causedByName');
|
|
226
|
+
const scenario = new ReadModelScenario(ContextDefaults, artifacts);
|
|
227
|
+
scenario.given.forEventSource('A').events(new ItemAdded(1));
|
|
228
|
+
expect(await scenario.instance).toMatchObject({ hash: '', causedByName: '[Not Set]' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('rejects a seeded generation different from the subscribed generation before mapping', async () => {
|
|
232
|
+
class LaterItemAdded { @field(Number) oldAmount = 42; }
|
|
233
|
+
eventType('scenario-item-added', 2)(LaterItemAdded);
|
|
234
|
+
class GenerationState { @field(String) id = ''; @field(Number) count = 0; }
|
|
235
|
+
fromEvent(ItemAdded)(GenerationState);
|
|
236
|
+
setFrom(ItemAdded, 'amount')(GenerationState.prototype, 'count');
|
|
237
|
+
const scenario = new ReadModelScenario(GenerationState, { ...artifacts, eventTypes: [...artifacts.eventTypes, LaterItemAdded] });
|
|
238
|
+
scenario.given.forEventSource('source-a').events(new LaterItemAdded());
|
|
239
|
+
await expect(scenario.instance).rejects.toThrow(UnsupportedProjectionOperation);
|
|
240
|
+
await expect(scenario.instance).rejects.toThrow(/GenerationState.*From\[scenario-item-added:1\].*seeded event generation 2/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects a seeded generation different from a removal subscription', async () => {
|
|
244
|
+
class LaterItemRemoved {}
|
|
245
|
+
eventType('scenario-item-removed', 2)(LaterItemRemoved);
|
|
246
|
+
class GenerationState { @field(String) id = ''; @field(Number) count = 0; }
|
|
247
|
+
fromEvent(ItemAdded)(GenerationState);
|
|
248
|
+
removedWith(ItemRemoved)(GenerationState);
|
|
249
|
+
const scenario = new ReadModelScenario(GenerationState, { ...artifacts, eventTypes: [...artifacts.eventTypes, LaterItemRemoved] });
|
|
250
|
+
scenario.given.forEventSource('source-a').events(new LaterItemRemoved());
|
|
251
|
+
await expect(scenario.wasDeletedForEventSourceId('source-a')).rejects.toThrow(/RemovedWith\[scenario-item-removed:1\].*seeded event generation 2/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('selects a declarative projection using its final compiled contract', async () => {
|
|
255
|
+
class FluentState { @field(String) id = ''; @field(Number) count = 0; }
|
|
256
|
+
class FluentProjection implements IProjectionFor<FluentState> {
|
|
257
|
+
define(builder: IProjectionBuilderFor<FluentState>): void {
|
|
258
|
+
builder.from(ItemAdded, from => from.set(model => model.count).to(event => event.amount));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
187
261
|
projection('scenario-fluent-projection', FluentState)(FluentProjection);
|
|
188
|
-
|
|
189
|
-
|
|
262
|
+
const scenario = new ReadModelScenario(FluentState, { ...artifacts, projections: [FluentProjection] });
|
|
263
|
+
scenario.given.forEventSource('A').events(new ItemAdded(8));
|
|
264
|
+
expect(await scenario.instance).toMatchObject({ count: 8 });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not infer an unbound projection against an incomplete isolated read-model catalog', () => {
|
|
268
|
+
class AmbiguousState { @field(Number) count = 0; }
|
|
269
|
+
class AlsoMatching { @field(Number) count = 0; }
|
|
270
|
+
readModel()(AmbiguousState);
|
|
271
|
+
readModel()(AlsoMatching);
|
|
272
|
+
class UnboundProjection implements IProjectionFor<AmbiguousState> {
|
|
273
|
+
define(builder: IProjectionBuilderFor<AmbiguousState>): void {
|
|
274
|
+
builder.from(ItemAdded, from => from.set(model => model.count).to(event => event.amount));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
projection('unbound-scenario-projection')(UnboundProjection);
|
|
278
|
+
expect(() => new ReadModelScenario(AmbiguousState, { ...artifacts, projections: [UnboundProjection] }))
|
|
279
|
+
.toThrow('No reducer or projection');
|
|
280
|
+
expect(() => new ReadModelScenario(AmbiguousState, {
|
|
281
|
+
...artifacts, projections: [UnboundProjection], readModels: [AmbiguousState, AlsoMatching]
|
|
282
|
+
})).toThrow('found 0');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('includes the read-model name and original error when projection replay fails', async () => {
|
|
286
|
+
class NumericState { @field(Guid) id!: Guid; }
|
|
287
|
+
fromEvent(ItemAdded)(NumericState);
|
|
288
|
+
const scenario = new ReadModelScenario(NumericState, artifacts);
|
|
289
|
+
scenario.given.forEventSource('01').events(new ItemAdded(1));
|
|
290
|
+
await expect(scenario.instance).rejects.toMatchObject({
|
|
291
|
+
message: expect.stringContaining('NumericState'),
|
|
292
|
+
cause: expect.objectContaining({ message: expect.stringContaining('Projection GUID value') })
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('rejects a protected identifier before any events are seeded', () => {
|
|
297
|
+
class ProtectedState { @field(String) id = ''; }
|
|
298
|
+
pii()(ProtectedState.prototype, 'id');
|
|
299
|
+
fromEvent(ItemAdded)(ProtectedState);
|
|
300
|
+
expect(() => new ReadModelScenario(ProtectedState, artifacts))
|
|
301
|
+
.toThrow(/ReadModel.Schema.id.*protected fields require a kernel-backed test/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('rejects unsupported mappings before replay even when no events are seeded', () => {
|
|
305
|
+
class CustomKeyState { @field(String) id = ''; @field(Number) count = 0; }
|
|
306
|
+
fromEvent(ItemAdded, { key: 'amount' })(CustomKeyState);
|
|
307
|
+
expect(() => new ReadModelScenario(CustomKeyState, artifacts)).toThrow('only $eventSourceId keys are supported');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects noncanonical GUID source IDs instead of merging independent histories', async () => {
|
|
311
|
+
class NumericState { @field(Guid) id!: Guid; }
|
|
312
|
+
fromEvent(ItemAdded)(NumericState);
|
|
313
|
+
const scenario = new ReadModelScenario(NumericState, artifacts);
|
|
314
|
+
scenario.given.forEventSource('01').events(new ItemAdded(1));
|
|
315
|
+
await expect(scenario.instance).rejects.toThrow('Projection GUID value');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('keeps reducer precedence when a projection also applies', async () => {
|
|
319
|
+
class DualState { @field(Number) count = 0; }
|
|
320
|
+
class DualReducer { itemAdded(event: ItemAdded): DualState { return { count: event.amount }; } }
|
|
321
|
+
reducer('scenario-dual-reducer', undefined, DualState)(DualReducer);
|
|
322
|
+
fromEvent(ItemAdded)(DualState);
|
|
323
|
+
const scenario = new ReadModelScenario(DualState, { ...artifacts, reducers: [DualReducer] });
|
|
324
|
+
scenario.given.forEventSource('A').events(new ItemAdded(4));
|
|
325
|
+
expect(await scenario.instance).toEqual({ count: 4 });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('rejects multiple applicable projections before replay', () => {
|
|
329
|
+
class FluentState { @field(Number) count = 0; }
|
|
330
|
+
class FirstProjection implements IProjectionFor<FluentState> { define(): void {} }
|
|
331
|
+
class SecondProjection implements IProjectionFor<FluentState> { define(): void {} }
|
|
332
|
+
projection('first-projection', FluentState)(FirstProjection);
|
|
333
|
+
projection('second-projection', FluentState)(SecondProjection);
|
|
334
|
+
expect(() => new ReadModelScenario(FluentState, { ...artifacts, projections: [FirstProjection, SecondProjection] }))
|
|
335
|
+
.toThrow('Multiple projections found');
|
|
190
336
|
});
|
|
191
337
|
});
|