@cratis/chronicle 6.17.0 → 6.19.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/projections/declarative/AddChildBuilder.d.ts +13 -2
- package/dist/projections/declarative/AddChildBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/AddChildBuilder.js +27 -1
- package/dist/projections/declarative/AddChildBuilder.js.map +1 -1
- package/dist/projections/declarative/FromBuilder.d.ts +4 -0
- package/dist/projections/declarative/FromBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/FromBuilder.js +1 -2
- package/dist/projections/declarative/FromBuilder.js.map +1 -1
- package/dist/projections/declarative/IAddChildBuilder.d.ts +12 -0
- package/dist/projections/declarative/IAddChildBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts +16 -1
- package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/JoinBuilder.d.ts +4 -0
- package/dist/projections/declarative/JoinBuilder.d.ts.map +1 -1
- package/dist/projections/declarative/JoinBuilder.js +1 -2
- package/dist/projections/declarative/JoinBuilder.js.map +1 -1
- package/dist/projections/declarative/ProjectionBuilderCore.js +1 -1
- package/dist/projections/declarative/ProjectionBuilderCore.js.map +1 -1
- package/dist/projections/modelBound/childrenAndNestedBuilder.d.ts.map +1 -1
- package/dist/projections/modelBound/childrenAndNestedBuilder.js +41 -10
- package/dist/projections/modelBound/childrenAndNestedBuilder.js.map +1 -1
- 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/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/index.d.ts +1 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -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/package.json +2 -2
- package/projections/ProjectionDefinitionCompiler.registration.spec.ts +4 -2
- package/projections/Projections.childrenAndNested.spec.ts +250 -1
- package/projections/declarative/AddChildBuilder.ts +33 -2
- package/projections/declarative/FromBuilder.ts +9 -1
- package/projections/declarative/IAddChildBuilder.ts +14 -0
- package/projections/declarative/IReadModelPropertiesBuilder.ts +18 -1
- package/projections/declarative/JoinBuilder.ts +9 -1
- package/projections/declarative/ProjectionBuilderCore.ts +1 -1
- package/projections/declarative/addChildCompatibility.standard.spec.ts +80 -0
- package/projections/eventContextExpressions.spec.ts +20 -0
- package/projections/modelBound/childrenAndNestedBuilder.ts +45 -11
- package/testing/IReadModelProcessor.ts +10 -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/index.ts +1 -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
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import 'reflect-metadata';
|
|
5
5
|
import { JsonSerializer } from '@cratis/fundamentals';
|
|
6
6
|
import type { Constructor } from '@cratis/fundamentals';
|
|
7
|
+
import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
|
|
7
8
|
import type { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
|
|
8
|
-
import type { EventContext } from '../events/EventContext.js';
|
|
9
9
|
import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
10
10
|
import { getFromEventMetadata } from '../projections/modelBound/fromEvent.js';
|
|
11
11
|
import { getRemovedWithClassMetadata } from '../projections/modelBound/removedWith.js';
|
|
@@ -14,54 +14,73 @@ import { getClearWithClassMetadata } from '../projections/modelBound/clearWith.j
|
|
|
14
14
|
import { getVariantOfMetadata } from '../projections/modelBound/variantOf.js';
|
|
15
15
|
import { getEntersOnMetadata } from '../projections/modelBound/entersOn.js';
|
|
16
16
|
import { getProjectionMetadata } from '../projections/declarative/projection.js';
|
|
17
|
+
import { ProjectionDefinitionCompiler } from '../projections/ProjectionDefinitionCompiler.js';
|
|
18
|
+
import { getReadModelId } from '../readModels/readModel.js';
|
|
17
19
|
import { getReducerMetadata } from '../reducers/reducer.js';
|
|
18
|
-
import { ReducerEventDispatcher } from '../reducers/ReducerEventDispatcher.js';
|
|
19
|
-
import { DecoratorType } from '../types/DecoratorType.js';
|
|
20
20
|
import { hasModelBoundProperties, TypeDiscoverer } from '../types/TypeDiscoverer.js';
|
|
21
21
|
import { ReadModelScenarioGivenBuilder } from './ReadModelScenarioGivenBuilder.js';
|
|
22
|
+
import type { IReadModelProcessor } from './IReadModelProcessor.js';
|
|
23
|
+
import type { ReadModelState } from './ReadModelState.js';
|
|
24
|
+
import { ReducerReadModelProcessor } from './ReducerReadModelProcessor.js';
|
|
25
|
+
import type { ScenarioEvent } from './ScenarioEvent.js';
|
|
26
|
+
import { ProjectionReadModelProcessor } from './projections/ProjectionReadModelProcessor.js';
|
|
27
|
+
import { UnsupportedProjectionOperation } from './projections/UnsupportedProjectionOperation.js';
|
|
22
28
|
|
|
23
|
-
type ScenarioArtifacts = Pick<IClientArtifactsProvider, 'reducers' | 'eventTypes' | 'projections'
|
|
24
|
-
|
|
25
|
-
type SeededEvent = { sourceId: string; content: unknown; context: EventContext };
|
|
26
|
-
type ReducedState<T> = { instance: T | null; deleted: boolean };
|
|
29
|
+
type ScenarioArtifacts = Pick<IClientArtifactsProvider, 'reducers' | 'eventTypes' | 'projections'> &
|
|
30
|
+
Partial<Pick<IClientArtifactsProvider, 'readModels' | 'globalForHandlers'>>;
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
|
-
* Folds seeded events through a reducer
|
|
30
|
-
*
|
|
33
|
+
* Folds seeded events through a reducer or a validated flat projection, without a Chronicle kernel.
|
|
34
|
+
* Observer scheduling, storage, migrations, and compliance are not simulated.
|
|
31
35
|
* Supply an artifact catalog to isolate a scenario from process-wide decorator discovery.
|
|
32
36
|
*/
|
|
33
37
|
export class ReadModelScenario<TReadModel extends object> {
|
|
34
|
-
private readonly
|
|
35
|
-
private readonly
|
|
36
|
-
private readonly
|
|
37
|
-
private
|
|
38
|
-
private _results: Promise<Map<string, ReducedState<TReadModel>>> | undefined;
|
|
38
|
+
private readonly _processor: IReadModelProcessor<TReadModel>;
|
|
39
|
+
private readonly _modelName: string;
|
|
40
|
+
private readonly _events: ScenarioEvent[] = [];
|
|
41
|
+
private _results: Promise<Map<string, ReadModelState<TReadModel>>> | undefined;
|
|
39
42
|
|
|
40
|
-
/** Selects the reducer
|
|
43
|
+
/** Selects the reducer (when present) or a single applicable compiled projection. */
|
|
41
44
|
constructor(readModelType: Constructor<TReadModel>, artifacts?: ScenarioArtifacts) {
|
|
42
|
-
this.
|
|
43
|
-
const registered = artifacts ??
|
|
44
|
-
reducers: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.Reducer),
|
|
45
|
-
eventTypes: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.EventType),
|
|
46
|
-
projections: TypeDiscoverer.default.getTypesByDecoratorType(DecoratorType.Projection)
|
|
47
|
-
};
|
|
45
|
+
this._modelName = readModelType.name;
|
|
46
|
+
const registered = artifacts ?? new DefaultClientArtifactsProvider(TypeDiscoverer.default);
|
|
48
47
|
const reducerTypes = registered.reducers.filter(type => getReducerMetadata(type)?.readModel === readModelType);
|
|
49
48
|
if (reducerTypes.length > 1) {
|
|
50
49
|
throw new Error(`Multiple reducers found for read model '${readModelType.name}'.`);
|
|
51
50
|
}
|
|
52
|
-
if (reducerTypes.length
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
if (reducerTypes.length) {
|
|
52
|
+
this._processor = new ReducerReadModelProcessor(readModelType, reducerTypes[0], registered.eventTypes);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const modelBound = getFromEventMetadata(readModelType).length > 0 || hasModelBoundProperties(readModelType) ||
|
|
56
|
+
getRemovedWithClassMetadata(readModelType).length > 0 || getRemovedWithJoinClassMetadata(readModelType).length > 0 ||
|
|
57
|
+
getClearWithClassMetadata(readModelType).length > 0 || getVariantOfMetadata(readModelType) !== undefined ||
|
|
58
|
+
getEntersOnMetadata(readModelType).length > 0;
|
|
59
|
+
// Definitions without a declared model can be associated by the registration compiler's
|
|
60
|
+
// unambiguous schema inference; never guess their association from decorators here.
|
|
61
|
+
const declarative = registered.projections.filter(type => {
|
|
62
|
+
const metadata = getProjectionMetadata(type);
|
|
63
|
+
return metadata !== undefined && (metadata.readModelType === readModelType ||
|
|
64
|
+
(metadata.readModelType === undefined && registered.readModels?.includes(readModelType)));
|
|
65
|
+
});
|
|
66
|
+
if (Number(modelBound) + declarative.filter(type => getProjectionMetadata(type)?.readModelType === readModelType).length > 1) {
|
|
67
|
+
throw new Error(`Multiple projections found for read model '${readModelType.name}'.`);
|
|
68
|
+
}
|
|
69
|
+
if (!modelBound && !declarative.length) {
|
|
70
|
+
throw new Error(`No reducer or projection found for read model '${readModelType.name}'.`);
|
|
71
|
+
}
|
|
72
|
+
const catalog: IClientArtifactsProvider = {
|
|
73
|
+
eventTypes: registered.eventTypes, reducers: registered.reducers, projections: declarative,
|
|
74
|
+
readModels: [...new Set([...(registered.readModels ?? []), readModelType])],
|
|
75
|
+
globalForHandlers: artifacts?.globalForHandlers ?? [], reactors: [], seeders: [], constraints: [],
|
|
76
|
+
webhooks: [], eventTypeMigrations: []
|
|
77
|
+
};
|
|
78
|
+
const compiled = new ProjectionDefinitionCompiler(catalog, 'scenario').compile(declarative, modelBound ? [readModelType] : []);
|
|
79
|
+
const definitions = compiled.definitions.filter(definition => definition.ReadModel === getReadModelId(readModelType));
|
|
80
|
+
if (definitions.length !== 1) {
|
|
81
|
+
throw new Error(`Expected one projection for read model '${readModelType.name}', found ${definitions.length}.`);
|
|
62
82
|
}
|
|
63
|
-
this.
|
|
64
|
-
this._dispatcher = new ReducerEventDispatcher(this._reducerType, registered.eventTypes);
|
|
83
|
+
this._processor = new ProjectionReadModelProcessor(readModelType, compiled, definitions[0]);
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
/** Fluent entry point for event history. */
|
|
@@ -119,33 +138,14 @@ export class ReadModelScenario<TReadModel extends object> {
|
|
|
119
138
|
this._results = undefined;
|
|
120
139
|
}
|
|
121
140
|
|
|
122
|
-
private process(): Promise<Map<string,
|
|
141
|
+
private process(): Promise<Map<string, ReadModelState<TReadModel>>> {
|
|
123
142
|
if (!this._results) {
|
|
124
143
|
const events = [...this._events];
|
|
125
|
-
this._results = this.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private async reduce(events: readonly SeededEvent[]): Promise<Map<string, ReducedState<TReadModel>>> {
|
|
131
|
-
const results = new Map<string, ReducedState<TReadModel>>();
|
|
132
|
-
const reducer = new (this._reducerType as new () => Record<string, Function>)();
|
|
133
|
-
for (const event of events) {
|
|
134
|
-
const handler = this._dispatcher.handlerFor(event.context.eventType.id.value);
|
|
135
|
-
if (!handler) continue;
|
|
136
|
-
const prior = results.get(event.sourceId);
|
|
137
|
-
const previous = prior?.deleted ? undefined : prior?.instance;
|
|
138
|
-
let next: unknown;
|
|
139
|
-
try {
|
|
140
|
-
next = await this._dispatcher.invoke(reducer, handler, event.content, previous, event.context);
|
|
141
|
-
} catch (cause) {
|
|
142
|
-
throw new Error(`Reducer '${this._reducerType.name}' for read model '${this._readModelType.name}' failed`, { cause });
|
|
143
|
-
}
|
|
144
|
-
results.set(event.sourceId, {
|
|
145
|
-
instance: next === undefined ? null : next as TReadModel | null,
|
|
146
|
-
deleted: next === undefined
|
|
144
|
+
this._results = this._processor.process(events).catch(error => {
|
|
145
|
+
if (!(this._processor instanceof ProjectionReadModelProcessor) || error instanceof UnsupportedProjectionOperation) throw error;
|
|
146
|
+
throw new Error(`Projection replay for read model '${this._modelName}' failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
|
-
return
|
|
149
|
+
return this._results;
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
/** Materialized state and explicit removal status for one source. */
|
|
5
|
+
export type ReadModelState<T> = { instance: T | null; deleted: boolean };
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { ReducerEventDispatcher } from '../reducers/ReducerEventDispatcher.js';
|
|
6
|
+
import type { IReadModelProcessor } from './IReadModelProcessor.js';
|
|
7
|
+
import type { ReadModelState } from './ReadModelState.js';
|
|
8
|
+
import type { ScenarioEvent } from './ScenarioEvent.js';
|
|
9
|
+
|
|
10
|
+
/** Preserves the SDK reducer dispatch and the scenario's null-versus-deletion semantics. */
|
|
11
|
+
export class ReducerReadModelProcessor<TReadModel extends object> implements IReadModelProcessor<TReadModel> {
|
|
12
|
+
private readonly _dispatcher: ReducerEventDispatcher;
|
|
13
|
+
|
|
14
|
+
constructor(private readonly _readModelType: Constructor<TReadModel>, private readonly _reducerType: Constructor, eventTypes: Constructor[]) {
|
|
15
|
+
this._dispatcher = new ReducerEventDispatcher(_reducerType, eventTypes);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
|
|
19
|
+
const results = new Map<string, ReadModelState<TReadModel>>();
|
|
20
|
+
const reducer = new (this._reducerType as new () => Record<string, Function>)();
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
const handler = this._dispatcher.handlerFor(event.context.eventType.id.value);
|
|
23
|
+
if (!handler) continue;
|
|
24
|
+
const prior = results.get(event.sourceId);
|
|
25
|
+
const previous = prior?.deleted ? undefined : prior?.instance;
|
|
26
|
+
let next: unknown;
|
|
27
|
+
try {
|
|
28
|
+
next = await this._dispatcher.invoke(reducer, handler, event.content, previous, event.context);
|
|
29
|
+
} catch (cause) {
|
|
30
|
+
throw new Error(`Reducer '${this._reducerType.name}' for read model '${this._readModelType.name}' failed`, { cause });
|
|
31
|
+
}
|
|
32
|
+
results.set(event.sourceId, {
|
|
33
|
+
instance: next === undefined ? null : next as TReadModel | null,
|
|
34
|
+
deleted: next === undefined
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { EventContext } from '../events/EventContext.js';
|
|
5
|
+
|
|
6
|
+
/** An event serialized at the scenario boundary. */
|
|
7
|
+
export type ScenarioEvent = { sourceId: string; content: unknown; context: EventContext };
|
package/testing/index.ts
CHANGED
|
@@ -4,3 +4,4 @@
|
|
|
4
4
|
export { ReadModelScenario } from './ReadModelScenario.js';
|
|
5
5
|
export { ReadModelScenarioGiven } from './ReadModelScenarioGiven.js';
|
|
6
6
|
export { ReadModelScenarioGivenBuilder } from './ReadModelScenarioGivenBuilder.js';
|
|
7
|
+
export { UnsupportedProjectionOperation } from './projections/UnsupportedProjectionOperation.js';
|
|
@@ -5,8 +5,6 @@ import { AutoMap, type ProjectionDefinition } from '@cratis/chronicle.contracts'
|
|
|
5
5
|
import { EventSequenceId } from '../../eventSequences/EventSequenceId.js';
|
|
6
6
|
import type { CompiledProjectionDefinitions } from '../../projections/CompiledProjectionDefinitions.js';
|
|
7
7
|
import { eventContractPath } from '../../projections/eventContractPath.js';
|
|
8
|
-
import { eventContextPropertyExpression } from '../../projections/eventContextPropertyExpression.js';
|
|
9
|
-
import { InvalidEventContextPropertyError } from '../../projections/InvalidEventContextPropertyError.js';
|
|
10
8
|
import type { ContractEventType, FromRecord, RemovedWithRecord } from '../../projections/declarative/ProjectionBuilderCore.js';
|
|
11
9
|
import { getEventTypeMapKey } from '../../projections/modelBound/childrenAndNestedBuilder.js';
|
|
12
10
|
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
@@ -36,6 +34,7 @@ export class ProjectionCapabilities {
|
|
|
36
34
|
const variant = provenance.find(entry => entry.contractPath === 'Variant');
|
|
37
35
|
if (variant) reject('Variant', 'variants require a kernel-backed test');
|
|
38
36
|
if (wire.IsActive === false) reject('IsActive', 'passive projections require a kernel-backed test');
|
|
37
|
+
if (wire.SubscribesToAllEvents === true) reject('SubscribesToAllEvents', 'subscribe-to-all projections require a kernel-backed test');
|
|
39
38
|
if (wire.EventSequenceId !== EventSequenceId.eventLog.value) reject('EventSequenceId', 'non-default event sequences require a kernel-backed test');
|
|
40
39
|
for (const section of ['Join', 'Children', 'Nested', 'RemovedWithJoin'] as const) {
|
|
41
40
|
const value = wire[section];
|
|
@@ -59,11 +58,17 @@ export class ProjectionCapabilities {
|
|
|
59
58
|
} catch {
|
|
60
59
|
reject('ReadModel.Schema', 'read-model schema is not valid JSON');
|
|
61
60
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
(identifier.type === '
|
|
66
|
-
reject('ReadModel.Schema.id', '
|
|
61
|
+
this.checkProtection(schema!, 'ReadModel.Schema', reject);
|
|
62
|
+
const identifier = schema!.properties?.id;
|
|
63
|
+
if (!identifier || !((identifier.type === 'string' && (!identifier.format || identifier.format === 'guid')) ||
|
|
64
|
+
(identifier.type === 'number' && identifier.format === 'double'))) {
|
|
65
|
+
reject('ReadModel.Schema.id', 'only a lowercase id with a fixture-backed string, GUID, or number/double schema is supported; other identifiers require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
66
|
+
}
|
|
67
|
+
const names = Object.keys(schema!.properties ?? {});
|
|
68
|
+
for (const [index, name] of names.entries()) {
|
|
69
|
+
if (names.slice(index + 1).some(other => other.toLowerCase() === name.toLowerCase())) {
|
|
70
|
+
reject(`ReadModel.Schema.${name}`, 'case-insensitively colliding read-model properties require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
71
|
+
}
|
|
67
72
|
}
|
|
68
73
|
let initial: unknown;
|
|
69
74
|
try { initial = JSON.parse(String(wire.InitialModelState ?? '{}')); } catch { reject('InitialModelState', 'initial values must be a JSON object'); }
|
|
@@ -72,14 +77,7 @@ export class ProjectionCapabilities {
|
|
|
72
77
|
const target = schema!.properties?.[property]
|
|
73
78
|
?? reject(`InitialModelState.${property}`, 'initial value has no read-model schema');
|
|
74
79
|
this.checkSchema(target, `InitialModelState.${property}`, reject);
|
|
75
|
-
|
|
76
|
-
const outsideIntegerRange = !Number.isSafeInteger(value) ||
|
|
77
|
-
(target.format === 'int32' && (value < -2147483648 || value > 2147483647)) ||
|
|
78
|
-
(target.format === 'uint32' && (value < 0 || value > 4294967295));
|
|
79
|
-
if (!Number.isFinite(value) || (target.type === 'integer' && outsideIntegerRange)) {
|
|
80
|
-
reject(`InitialModelState.${property}`, 'initial numeric value is outside the supported finite/integer range');
|
|
81
|
-
}
|
|
82
|
-
}
|
|
80
|
+
this.checkInitialValue(value, target, `InitialModelState.${property}`, reject);
|
|
83
81
|
}
|
|
84
82
|
const generations = new Map<string, number>();
|
|
85
83
|
const requireEventSchema = (eventType: ContractEventType, path: string): JsonSchema => {
|
|
@@ -91,6 +89,11 @@ export class ProjectionCapabilities {
|
|
|
91
89
|
};
|
|
92
90
|
const from = wire.From as FromRecord[] ?? [];
|
|
93
91
|
const removedWith = wire.RemovedWith as RemovedWithRecord[] ?? [];
|
|
92
|
+
for (const entry of from) {
|
|
93
|
+
if (removedWith.some(removal => removal.Key.Id === entry.Key.Id)) {
|
|
94
|
+
reject(eventContractPath('From', entry.Key), 'events subscribed through both From and RemovedWith require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
94
97
|
for (const entry of [...from, ...removedWith]) {
|
|
95
98
|
const section = from.includes(entry as FromRecord) ? 'From' : 'RemovedWith';
|
|
96
99
|
const path = eventContractPath(section, entry.Key);
|
|
@@ -103,10 +106,7 @@ export class ProjectionCapabilities {
|
|
|
103
106
|
const mappingPath = `${path}.Properties.${property}`;
|
|
104
107
|
this.checkMapping(schema!, eventSchema, property, expression, mappingPath, reject);
|
|
105
108
|
}
|
|
106
|
-
|
|
107
|
-
const aggregateOnly = expressions.length > 0 && expressions.every(expression =>
|
|
108
|
-
/^(?:\$add\([^()]+\)|\$subtract\([^()]+\)|\$count|\$increment|\$decrement)$/.test(expression));
|
|
109
|
-
if (wire.AutoMap === AutoMap.Enabled && !aggregateOnly) {
|
|
109
|
+
if (wire.AutoMap !== AutoMap.Disabled) {
|
|
110
110
|
this.checkAutoMap(schema!, eventSchema, properties, wire.NoAutoMapProperties as string[] ?? [], path, reject);
|
|
111
111
|
}
|
|
112
112
|
}
|
|
@@ -117,7 +117,8 @@ export class ProjectionCapabilities {
|
|
|
117
117
|
path: string, reject: (path: string, reason: string) => never
|
|
118
118
|
): void {
|
|
119
119
|
for (const [destination] of Object.entries(modelSchema.properties ?? {})) {
|
|
120
|
-
if (
|
|
120
|
+
if (Object.keys(explicit).some(name => name.toLowerCase() === destination.toLowerCase()) ||
|
|
121
|
+
exclusions.some(name => name.toLowerCase() === destination.toLowerCase())) continue;
|
|
121
122
|
const candidates = Object.keys(eventSchema.properties ?? {}).filter(source => source.toLowerCase() === destination.toLowerCase());
|
|
122
123
|
if (candidates.length > 1) reject(`${path}.AutoMap.${destination}`, `inferred AutoMap source is ambiguous: ${candidates.join(', ')}`);
|
|
123
124
|
if (candidates.length) this.checkMapping(modelSchema, eventSchema, destination, candidates[0], `${path}.AutoMap.${destination}`, reject);
|
|
@@ -132,25 +133,36 @@ export class ProjectionCapabilities {
|
|
|
132
133
|
const target = modelSchema.properties?.[destination]
|
|
133
134
|
?? fail('dynamic or unknown destination paths require a kernel-backed test');
|
|
134
135
|
if (destination.includes('.') || destination.includes('$')) fail('dynamic or unknown destination paths require a kernel-backed test');
|
|
136
|
+
if (destination.toLowerCase() === 'id' || Object.keys(modelSchema.properties ?? {}).some(name =>
|
|
137
|
+
name !== destination && name.toLowerCase() === destination.toLowerCase())) {
|
|
138
|
+
fail('identifier or case-insensitively colliding target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
139
|
+
}
|
|
140
|
+
if (target.type === 'object' || target.type === 'array') fail('object/array target mappings require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
135
141
|
this.checkSchema(target, path, (_path, reason) => fail(reason));
|
|
136
|
-
if (expression === '$eventSourceId'
|
|
142
|
+
if (expression === '$eventSourceId') {
|
|
143
|
+
if (target.type !== 'string' || (target.format && target.format !== 'guid')) {
|
|
144
|
+
fail('$eventSourceId requires a string or GUID target; other targets require a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (expression === '$null') return;
|
|
137
149
|
if (expression.startsWith('$eventContext(')) {
|
|
150
|
+
if (expression === '$eventContext(Occurred)') fail('raw Occurred is converted inconsistently by the kernel; only its Year, Month and Day paths are supported');
|
|
138
151
|
if (/^\$eventContext\(.+\(\)\)$/.test(expression)) fail('derived event-context functions require a kernel-backed test');
|
|
139
152
|
const path = /^\$eventContext\(([^()]*)\)$/.exec(expression)?.[1];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
fail('event-context path is not supported by the client');
|
|
153
|
+
const strings = ['EventSourceId', 'EventStore', 'Namespace', 'EventSourceType', 'EventStreamType',
|
|
154
|
+
'EventStreamId', 'Subject', 'Subject.Value', 'Hash', 'CorrelationId', 'CorrelationId.Value',
|
|
155
|
+
'CausedBy.Subject', 'CausedBy.Name', 'CausedBy.UserName', 'EventType.Id.Value', 'SequenceNumber', 'SequenceNumber.Value'];
|
|
156
|
+
const integers = ['Occurred.Year', 'Occurred.Month', 'Occurred.Day', 'EventType.Generation.Value'];
|
|
157
|
+
if (path && ((strings.includes(path) && target.type === 'string' && !target.format) ||
|
|
158
|
+
(integers.includes(path) && target.type === 'integer' && target.format === 'int32'))) return;
|
|
159
|
+
fail('event-context path and target schema require a kernel-backed test');
|
|
148
160
|
}
|
|
149
161
|
if (/^\$value\([\p{L}\p{Mn}\p{Nd}\p{Pc} ._/:*+-]*\)$/u.test(expression)) {
|
|
150
162
|
const text = expression.slice(7, -1);
|
|
151
163
|
if (target.format === 'date-time') fail('$value date-time literals require a kernel-backed test');
|
|
152
164
|
if (this.numeric(target)) {
|
|
153
|
-
if (
|
|
165
|
+
if (!(target.type === 'integer' ? /^[+-]?\d+$/.test(text) : /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text)) || !Number.isFinite(Number(text)) ||
|
|
154
166
|
(target.type === 'integer' && (!Number.isSafeInteger(Number(text)) ||
|
|
155
167
|
(target.format === 'int32' && (Number(text) < -2147483648 || Number(text) > 2147483647)) ||
|
|
156
168
|
(target.format === 'uint32' && (Number(text) < 0 || Number(text) > 4294967295))))) {
|
|
@@ -167,18 +179,18 @@ export class ProjectionCapabilities {
|
|
|
167
179
|
}
|
|
168
180
|
const operation = /^(\$add|\$subtract)\(([^()]+)\)$/.exec(expression);
|
|
169
181
|
const arithmetic = operation || ['$count', '$increment', '$decrement'].includes(expression);
|
|
170
|
-
if (arithmetic)
|
|
171
|
-
if (!this.numeric(target)) fail('arithmetic requires a supported finite number/double or int32/uint32 schema (not float, decimal, duration, or int64)');
|
|
172
|
-
if (operation) {
|
|
173
|
-
const operand = this.propertyAt(eventSchema, operation[2]);
|
|
174
|
-
if (!operand || !this.numeric(operand)) fail('arithmetic operand requires a supported numeric event schema');
|
|
175
|
-
}
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
182
|
+
if (arithmetic) fail('arithmetic requires a kernel-backed test (ChronicleKernelScenario / live kernel)');
|
|
178
183
|
if (/^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*$/.test(expression)) {
|
|
184
|
+
if (['true', 'True', 'false', 'False'].includes(expression)) {
|
|
185
|
+
fail('kernel resolves this expression as a boolean literal before event content');
|
|
186
|
+
}
|
|
187
|
+
if (expression.includes('.')) fail('nested event property paths require a kernel-backed test');
|
|
179
188
|
const source = this.propertyAt(eventSchema, expression);
|
|
180
189
|
if (!source) return fail(`event property '${expression}' is absent from the participating event schema`);
|
|
181
190
|
this.checkSchema(source, path, (_path, reason) => fail(reason));
|
|
191
|
+
if (!this.compatible(source, target)) {
|
|
192
|
+
fail(`source '${expression}' (${source.type}${source.format ? `/${source.format}` : ''}) to target '${destination}' (${target.type}${target.format ? `/${target.format}` : ''}) requires a kernel-backed test`);
|
|
193
|
+
}
|
|
182
194
|
return;
|
|
183
195
|
}
|
|
184
196
|
if (expression.startsWith('$context.')) {
|
|
@@ -187,6 +199,43 @@ export class ProjectionCapabilities {
|
|
|
187
199
|
fail('requires a kernel-backed test');
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
private static compatible(source: JsonSchema, target: JsonSchema): boolean {
|
|
203
|
+
if (JSON.stringify(source.type) === JSON.stringify(target.type) && source.format === target.format) {
|
|
204
|
+
return source.type !== 'object' && source.type !== 'array';
|
|
205
|
+
}
|
|
206
|
+
if (source.type === 'string' && !source.format && Array.isArray(target.type) &&
|
|
207
|
+
target.type[0] === 'string' && target.type[1] === 'null') return true;
|
|
208
|
+
if (source.type === 'string' && !source.format &&
|
|
209
|
+
((target.type === 'number' && (!target.format || target.format === 'double')) ||
|
|
210
|
+
(target.type === 'integer' && ['int32', 'uint32'].includes(target.format ?? '')))) return true;
|
|
211
|
+
if (source.type === 'string' && source.format === 'guid' && target.type === 'string' && !target.format) return true;
|
|
212
|
+
return source.type === 'integer' && ['int32', 'uint32'].includes(source.format ?? '') &&
|
|
213
|
+
target.type === 'number' && (!target.format || target.format === 'double');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private static checkInitialValue(value: unknown, target: JsonSchema, path: string, reject: (path: string, reason: string) => never): void {
|
|
217
|
+
if (value === null && Array.isArray(target.type) && target.type.includes('null')) return;
|
|
218
|
+
if (target.type === 'object' || target.type === 'array' || target.format === 'date-time') {
|
|
219
|
+
reject(path, 'initial object, array, or date-time values require a kernel-backed test');
|
|
220
|
+
}
|
|
221
|
+
if (target.type === 'integer' || target.type === 'number') {
|
|
222
|
+
if (typeof value !== 'number') reject(path, 'initial numeric value must be a JSON number');
|
|
223
|
+
const outsideIntegerRange = !Number.isSafeInteger(value) ||
|
|
224
|
+
(target.format === 'int32' && (value < -2147483648 || value > 2147483647)) ||
|
|
225
|
+
(target.format === 'uint32' && (value < 0 || value > 4294967295));
|
|
226
|
+
if (!Number.isFinite(value) || (target.type === 'integer' && outsideIntegerRange)) {
|
|
227
|
+
reject(path, 'initial numeric value is outside the supported finite/integer range');
|
|
228
|
+
}
|
|
229
|
+
} else if (target.type === 'boolean') {
|
|
230
|
+
if (typeof value !== 'boolean') reject(path, 'initial boolean value must be a JSON boolean');
|
|
231
|
+
} else if (target.type === 'string' || Array.isArray(target.type)) {
|
|
232
|
+
if (typeof value !== 'string') reject(path, 'initial string value must be a JSON string');
|
|
233
|
+
if (target.format === 'guid' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) {
|
|
234
|
+
reject(path, 'initial GUID value must be canonical lowercase text');
|
|
235
|
+
}
|
|
236
|
+
} else reject(path, 'initial value requires a kernel-backed test');
|
|
237
|
+
}
|
|
238
|
+
|
|
190
239
|
private static propertyAt(schema: JsonSchema, path: string): JsonSchema | undefined {
|
|
191
240
|
return path.split('.').reduce<JsonSchema | undefined>((current, segment) => current?.properties?.[segment], schema);
|
|
192
241
|
}
|
|
@@ -196,8 +245,20 @@ export class ProjectionCapabilities {
|
|
|
196
245
|
(schema.type === 'integer' && ['int32', 'uint32'].includes(schema.format ?? ''));
|
|
197
246
|
}
|
|
198
247
|
|
|
248
|
+
private static checkProtection(schema: JsonSchema, path: string, reject: (path: string, reason: string) => never): void {
|
|
249
|
+
if (schema.compliance?.length || schema.security?.length) reject(path, 'protected fields require a kernel-backed test');
|
|
250
|
+
for (const [name, property] of Object.entries(schema.properties ?? {})) this.checkProtection(property, `${path}.${name}`, reject);
|
|
251
|
+
if (schema.items) this.checkProtection(schema.items, `${path}.items`, reject);
|
|
252
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
253
|
+
this.checkProtection(schema.additionalProperties, `${path}.additionalProperties`, reject);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
199
257
|
private static checkSchema(schema: JsonSchema, path: string, reject: (path: string, reason: string) => never): void {
|
|
200
|
-
if (
|
|
258
|
+
if (schema.compliance?.length || schema.security?.length) reject(path, 'protected fields require a kernel-backed test');
|
|
259
|
+
if (!schema.type || schema.type === 'null' || (Array.isArray(schema.type) &&
|
|
260
|
+
(schema.type.length !== 2 || schema.type[0] !== 'string' || schema.type[1] !== 'null')) ||
|
|
261
|
+
(schema.format && !['guid', 'date-time', 'double', 'int32', 'uint32'].includes(schema.format)) ||
|
|
201
262
|
(schema.format === 'date-time' && schema.type !== 'string') ||
|
|
202
263
|
(schema.format === 'guid' && schema.type !== 'string') ||
|
|
203
264
|
(schema.format === 'double' && schema.type !== 'number') ||
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { EventContext } from '../../events/EventContext.js';
|
|
5
|
+
import { Identity } from '../../identity/Identity.js';
|
|
6
|
+
import type { JsonSchema } from '../../schemas/JsonSchema.js';
|
|
7
|
+
import { ProjectionValueConverter } from './ProjectionValueConverter.js';
|
|
8
|
+
|
|
9
|
+
/** Evaluates validated wire expressions, without executing JavaScript supplied by a definition. */
|
|
10
|
+
export class ProjectionExpressionEvaluator {
|
|
11
|
+
static value(expression: string, content: unknown, context: EventContext, target: JsonSchema): unknown {
|
|
12
|
+
if (expression === '$null') return null;
|
|
13
|
+
if (expression === '$eventSourceId') return ProjectionValueConverter.convert(context.eventSourceId, target);
|
|
14
|
+
if (expression.startsWith('$value(') && expression.endsWith(')')) {
|
|
15
|
+
return ProjectionValueConverter.convert(expression.slice(7, -1), target);
|
|
16
|
+
}
|
|
17
|
+
if (expression.startsWith('$eventContext(') && expression.endsWith(')')) {
|
|
18
|
+
return ProjectionValueConverter.convert(this.contextValue(expression.slice(14, -1), context), target);
|
|
19
|
+
}
|
|
20
|
+
return ProjectionValueConverter.convert(this.pathValue(content, expression), target);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static pathValue(value: unknown, path: string): unknown {
|
|
24
|
+
return path.split('.').reduce<unknown>((current, segment) => {
|
|
25
|
+
if (current === null || typeof current !== 'object') return null;
|
|
26
|
+
return (current as Record<string, unknown>)[segment] ?? null;
|
|
27
|
+
}, value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private static contextValue(path: string, context: EventContext): unknown {
|
|
31
|
+
const [root, ...members] = path.split('.');
|
|
32
|
+
const roots: Record<string, unknown> = {
|
|
33
|
+
SequenceNumber: context.sequenceNumber.toString(), EventSourceId: context.eventSourceId,
|
|
34
|
+
EventStore: context.eventStore, Namespace: context.namespace, EventSourceType: context.eventSourceType,
|
|
35
|
+
EventStreamType: context.eventStreamType, EventStreamId: context.eventStreamId,
|
|
36
|
+
Subject: context.subject ?? context.eventSourceId, Hash: context.hash ?? '', CausedBy: context.causedBy ?? Identity.notSet,
|
|
37
|
+
ObservationState: context.observationState, EventType: context.eventType,
|
|
38
|
+
Occurred: context.occurred, CorrelationId: context.correlationId,
|
|
39
|
+
Causation: context.causation, Tags: context.tags
|
|
40
|
+
};
|
|
41
|
+
let value: unknown = roots[root];
|
|
42
|
+
for (const member of members) {
|
|
43
|
+
if (value instanceof Date) {
|
|
44
|
+
if (member === 'Year') { value = value.getUTCFullYear(); continue; }
|
|
45
|
+
if (member === 'Month') { value = value.getUTCMonth() + 1; continue; }
|
|
46
|
+
if (member === 'Day') { value = value.getUTCDate(); continue; }
|
|
47
|
+
}
|
|
48
|
+
if (member === 'Value' && (typeof value !== 'object' || value === null)) continue;
|
|
49
|
+
if (value === null || typeof value !== 'object') return null;
|
|
50
|
+
const object = value as Record<string, unknown>;
|
|
51
|
+
value = object[member[0].toLowerCase() + member.slice(1)] ?? object[member] ?? null;
|
|
52
|
+
}
|
|
53
|
+
if (value instanceof Date) return value.toISOString();
|
|
54
|
+
if (typeof value === 'bigint') return value.toString();
|
|
55
|
+
return value ?? null;
|
|
56
|
+
}
|
|
57
|
+
}
|