@cratis/chronicle 6.14.0 → 6.14.2
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/ChronicleClient.ts +11 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +7 -0
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/projections/CompiledProjectionDefinitions.d.ts +10 -0
- package/dist/projections/CompiledProjectionDefinitions.d.ts.map +1 -0
- package/dist/projections/CompiledProjectionDefinitions.js +4 -0
- package/dist/projections/CompiledProjectionDefinitions.js.map +1 -0
- package/dist/projections/ProjectionDefinitionCompiler.d.ts +35 -0
- package/dist/projections/ProjectionDefinitionCompiler.d.ts.map +1 -0
- package/dist/projections/ProjectionDefinitionCompiler.js +355 -0
- package/dist/projections/ProjectionDefinitionCompiler.js.map +1 -0
- package/dist/projections/Projections.d.ts +0 -21
- package/dist/projections/Projections.d.ts.map +1 -1
- package/dist/projections/Projections.js +9 -339
- package/dist/projections/Projections.js.map +1 -1
- package/dist/projections/ResolvedModelBoundMetadata.d.ts +8 -0
- package/dist/projections/ResolvedModelBoundMetadata.d.ts.map +1 -0
- package/dist/projections/ResolvedModelBoundMetadata.js +4 -0
- package/dist/projections/ResolvedModelBoundMetadata.js.map +1 -0
- package/dist/projections/modelBound/childrenAndNestedBuilder.d.ts.map +1 -1
- package/dist/projections/modelBound/childrenAndNestedBuilder.js +7 -3
- package/dist/projections/modelBound/childrenAndNestedBuilder.js.map +1 -1
- package/dist/projections/modelBound/isModelBoundProjection.d.ts +3 -0
- package/dist/projections/modelBound/isModelBoundProjection.d.ts.map +1 -0
- package/dist/projections/modelBound/isModelBoundProjection.js +9 -0
- package/dist/projections/modelBound/isModelBoundProjection.js.map +1 -0
- package/dist/readModels/ReadModels.js +1 -1
- package/dist/readModels/ReadModels.js.map +1 -1
- package/dist/readModels/rootReadModelTypes.d.ts +2 -0
- package/dist/readModels/rootReadModelTypes.d.ts.map +1 -1
- package/dist/readModels/rootReadModelTypes.js +10 -2
- package/dist/readModels/rootReadModelTypes.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/TypeDiscoverer.d.ts.map +1 -1
- package/dist/types/TypeDiscoverer.js +7 -4
- package/dist/types/TypeDiscoverer.js.map +1 -1
- package/dist/types/modelBoundProperty.d.ts.map +1 -1
- package/dist/types/modelBoundProperty.js +7 -2
- package/dist/types/modelBoundProperty.js.map +1 -1
- package/dist/types/modelBoundPropertyMetadata.d.ts +9 -0
- package/dist/types/modelBoundPropertyMetadata.d.ts.map +1 -0
- package/dist/types/modelBoundPropertyMetadata.js +66 -0
- package/dist/types/modelBoundPropertyMetadata.js.map +1 -0
- package/for_ChronicleClient/when_registering_artifacts/with_legacy_property_mappings.legacy.spec.ts +31 -0
- package/for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts +138 -0
- package/package.json +2 -2
- package/projections/CompiledProjectionDefinitions.ts +13 -0
- package/projections/ProjectionDefinitionCompiler.registration.spec.ts +279 -0
- package/projections/ProjectionDefinitionCompiler.ts +386 -0
- package/projections/Projections.childrenAndNested.spec.ts +47 -2
- package/projections/Projections.ts +14 -413
- package/projections/ResolvedModelBoundMetadata.ts +11 -0
- package/projections/modelBound/childrenAndNestedBuilder.ts +7 -3
- package/projections/modelBound/decorators.standard.spec.ts +2 -2
- package/projections/modelBound/isModelBoundProjection.ts +10 -0
- package/readModels/ReadModels.ts +1 -1
- package/readModels/discovery.spec.ts +2 -2
- package/readModels/registration.legacy.spec.ts +38 -0
- package/readModels/registration.standard.spec.ts +108 -0
- package/readModels/rootReadModelTypes.ts +12 -2
- package/types/TypeDiscoverer.ts +6 -5
- package/types/modelBoundProperty.ts +5 -2
- package/types/modelBoundPropertyMetadata.ts +67 -0
|
@@ -0,0 +1,386 @@
|
|
|
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 { AutoMap, ReadModelObserverType, type ProjectionDefinition } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { Constructor } from '@cratis/fundamentals';
|
|
6
|
+
import { IClientArtifactsProvider } from '../artifacts/index.js';
|
|
7
|
+
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
8
|
+
import { getReadModelMetadata } from '../readModels/index.js';
|
|
9
|
+
import { getReadModelId } from '../readModels/readModel.js';
|
|
10
|
+
import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
|
|
11
|
+
import { rootReadModelTypes } from '../readModels/rootReadModelTypes.js';
|
|
12
|
+
import { JsonSchemaGenerator } from '../schemas/index.js';
|
|
13
|
+
import { WellKnownSinks } from '../sinks/index.js';
|
|
14
|
+
import { TypeIntrospector } from '../types/index.js';
|
|
15
|
+
import { CompiledProjectionDefinitions } from './CompiledProjectionDefinitions.js';
|
|
16
|
+
import { constantValueExpression } from './constantValueExpression.js';
|
|
17
|
+
import { getProjectionMetadata } from './declarative/projection.js';
|
|
18
|
+
import { ProjectionBuilderFor } from './declarative/ProjectionBuilderFor.js';
|
|
19
|
+
import type { IProjectionFor } from './declarative/IProjectionFor.js';
|
|
20
|
+
import {
|
|
21
|
+
applyPropertyMappings, buildChildrenEntry, buildNestedEntry, ChildrenDefinitionLike,
|
|
22
|
+
ContractEventType, ensureFromEntry, FromRecord, getEventTypeMapKey, toContractEventType
|
|
23
|
+
} from './modelBound/childrenAndNestedBuilder.js';
|
|
24
|
+
import { getChildrenFromMetadata } from './modelBound/childrenFrom.js';
|
|
25
|
+
import { getClearWithPropertyMetadata } from './modelBound/clearWith.js';
|
|
26
|
+
import { getEventSequenceMetadata } from './modelBound/eventSequence.js';
|
|
27
|
+
import { getFromAllMetadata } from './modelBound/fromAll.js';
|
|
28
|
+
import { getFromEveryMetadata } from './modelBound/fromEvery.js';
|
|
29
|
+
import { getFromEventMetadata } from './modelBound/fromEvent.js';
|
|
30
|
+
import { isModelBoundProjection } from './modelBound/isModelBoundProjection.js';
|
|
31
|
+
import { getJoinMetadata } from './modelBound/join.js';
|
|
32
|
+
import { isNoAutoMap, isPropertyNoAutoMap } from './modelBound/noAutoMap.js';
|
|
33
|
+
import { isNested } from './modelBound/nested.js';
|
|
34
|
+
import { isNotRewindable } from './modelBound/notRewindable.js';
|
|
35
|
+
import { isPassive } from './modelBound/passive.js';
|
|
36
|
+
import { getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './modelBound/removedWith.js';
|
|
37
|
+
import { getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './modelBound/removedWithJoin.js';
|
|
38
|
+
import { getVariantOfMetadata } from './modelBound/variantOf.js';
|
|
39
|
+
import { getEntersOnMetadata } from './modelBound/entersOn.js';
|
|
40
|
+
import { getGlobalForMetadata } from './modelBound/globalFor.js';
|
|
41
|
+
import type { JoinRecord } from './declarative/ProjectionBuilderCore.js';
|
|
42
|
+
import { BuiltProjection, crossWireGroups, mergeGlobalHandlers, reclassify, VariantDeclaration } from './VariantReclassifier.js';
|
|
43
|
+
|
|
44
|
+
/** Compiles discovered projection types into the contracts sent to the kernel, without a connection. */
|
|
45
|
+
export class ProjectionDefinitionCompiler {
|
|
46
|
+
/**
|
|
47
|
+
* @param _clientArtifacts - Discovered artifact types used to resolve schemas, variants, and read models.
|
|
48
|
+
* @param _defaultSinkTypeId - Sink identifier for active read models.
|
|
49
|
+
*/
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly _clientArtifacts: IClientArtifactsProvider,
|
|
52
|
+
private readonly _defaultSinkTypeId: string
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Builds all definitions together so variant cross-wiring precedes the final hash.
|
|
57
|
+
* @param declarative - Discovered declarative projection types.
|
|
58
|
+
* @param modelBound - Discovered model-bound read model types.
|
|
59
|
+
* @returns Projection definitions and read-model registrations ready for the wire.
|
|
60
|
+
*/
|
|
61
|
+
compile(declarative: Iterable<Constructor>, modelBound: Iterable<Constructor>): CompiledProjectionDefinitions {
|
|
62
|
+
const builtProjections: BuiltProjection[] = [
|
|
63
|
+
...Array.from(declarative, type => this.buildDeclarativeDefinition(type)),
|
|
64
|
+
...Array.from(modelBound, type => this.buildModelBoundDefinition(type))
|
|
65
|
+
];
|
|
66
|
+
// A sibling's entering event adds RemovedWith to each variant. Hash only after
|
|
67
|
+
// every variant has been cross-wired, so repeated registrations remain stable.
|
|
68
|
+
crossWireGroups(builtProjections);
|
|
69
|
+
for (const built of builtProjections) {
|
|
70
|
+
built.definition.LastUpdated = { Value: this.computeStableLastUpdated(built.definition) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The generated contract includes fields initialized by the transport encoder. Keep the
|
|
74
|
+
// existing sparse wire objects: adding those default-valued fields would change the payload.
|
|
75
|
+
// Evaluators must tolerate absent optional contract fields (for example NoAutoMapProperties).
|
|
76
|
+
const definitions = builtProjections.map(built => built.definition as unknown as ProjectionDefinition);
|
|
77
|
+
return {
|
|
78
|
+
definitions,
|
|
79
|
+
readModels: this.buildReadModelDefinitions(definitions)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private buildReadModelDefinitions(projections: ProjectionDefinition[]): ReturnType<typeof buildReadModelDefinition>[] {
|
|
84
|
+
const byReadModel = new Map<string, ReturnType<typeof buildReadModelDefinition>>();
|
|
85
|
+
for (const projection of projections) {
|
|
86
|
+
const readModelIdentifier = projection.ReadModel;
|
|
87
|
+
if (!readModelIdentifier) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const existing = byReadModel.get(readModelIdentifier);
|
|
91
|
+
if (existing) {
|
|
92
|
+
if (existing.ObserverIdentifier !== projection.Identifier) {
|
|
93
|
+
throw new Error(`Read model id '${readModelIdentifier}' has multiple projections.`);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const readModelType = this.getReadModelType(readModelIdentifier);
|
|
98
|
+
byReadModel.set(readModelIdentifier, buildReadModelDefinition({
|
|
99
|
+
identifier: readModelIdentifier,
|
|
100
|
+
type: readModelType,
|
|
101
|
+
schema: readModelType
|
|
102
|
+
? JSON.stringify(getReadModelMetadata(readModelType)?.schema ?? JsonSchemaGenerator.generate(readModelType))
|
|
103
|
+
: '{}',
|
|
104
|
+
// Passive projections never write to a materialized sink, so they register with
|
|
105
|
+
// the None sink. This lets the kernel fall through to immediate projection when
|
|
106
|
+
// resolving the instance by key instead of reading an empty sink and returning null.
|
|
107
|
+
sinkTypeId: projection.IsActive === false ? WellKnownSinks.None : this._defaultSinkTypeId,
|
|
108
|
+
observerType: ReadModelObserverType.Projection,
|
|
109
|
+
observerIdentifier: projection.Identifier
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
return Array.from(byReadModel.values());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private getReadModelType(readModelIdentifier: string): Constructor | undefined {
|
|
116
|
+
const types = [
|
|
117
|
+
...rootReadModelTypes(this._clientArtifacts),
|
|
118
|
+
...this._clientArtifacts.projections
|
|
119
|
+
.map(projectionType => getProjectionMetadata(projectionType)?.readModelType)
|
|
120
|
+
.filter((type): type is Constructor => type !== undefined)
|
|
121
|
+
];
|
|
122
|
+
for (const type of types) {
|
|
123
|
+
if (getReadModelId(type) === readModelIdentifier) return type;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private buildDeclarativeDefinition(type: Constructor): BuiltProjection {
|
|
129
|
+
const metadata = getProjectionMetadata(type);
|
|
130
|
+
if (!metadata) {
|
|
131
|
+
throw new Error(`Type '${type.name}' is missing declarative projection metadata.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const builder = new ProjectionBuilderFor<unknown>();
|
|
135
|
+
const instance = new type() as IProjectionFor<unknown>;
|
|
136
|
+
instance.define(builder);
|
|
137
|
+
const definition = builder.build(metadata.id.value, type.name) as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
const explicitReadModelIdentifier = definition.ReadModel as string;
|
|
140
|
+
if (explicitReadModelIdentifier === type.name) {
|
|
141
|
+
if (metadata.readModelType) {
|
|
142
|
+
definition.ReadModel = getReadModelId(metadata.readModelType);
|
|
143
|
+
} else {
|
|
144
|
+
const inferredReadModelIdentifier = this.inferReadModelIdentifier(builder.getMappedReadModelProperties());
|
|
145
|
+
if (inferredReadModelIdentifier) {
|
|
146
|
+
definition.ReadModel = inferredReadModelIdentifier;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let variant: VariantDeclaration | undefined;
|
|
152
|
+
const variantDeclaration = builder.getVariantDeclaration();
|
|
153
|
+
if (variantDeclaration) {
|
|
154
|
+
const reclassified = reclassify(
|
|
155
|
+
type.name, definition.From as FromRecord[], definition.Join as JoinRecord[],
|
|
156
|
+
variantDeclaration.enteringEventTypes, variantDeclaration.key);
|
|
157
|
+
definition.From = reclassified.from;
|
|
158
|
+
definition.Join = reclassified.join;
|
|
159
|
+
variant = variantDeclaration;
|
|
160
|
+
}
|
|
161
|
+
return { typeName: type.name, definition, variant };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private inferReadModelIdentifier(mappedProperties: string[]): string | undefined {
|
|
165
|
+
if (mappedProperties.length === 0) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
const matchingReadModels = rootReadModelTypes(this._clientArtifacts)
|
|
169
|
+
.map(type => ({ type, metadata: getReadModelMetadata(type) }))
|
|
170
|
+
.filter(candidate => candidate.metadata)
|
|
171
|
+
.filter(candidate => {
|
|
172
|
+
const readModelProperties = Array.from(candidate.metadata!.members.keys());
|
|
173
|
+
return mappedProperties.every(property => readModelProperties.includes(property));
|
|
174
|
+
});
|
|
175
|
+
if (matchingReadModels.length !== 1) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
return matchingReadModels[0].metadata!.id.value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private buildModelBoundDefinition(type: Constructor): BuiltProjection {
|
|
182
|
+
if (!isModelBoundProjection(type)) {
|
|
183
|
+
throw new Error(`Type '${type.name}' is missing model-bound projection metadata.`);
|
|
184
|
+
}
|
|
185
|
+
const readModelIdentifier = getReadModelId(type);
|
|
186
|
+
const eventSequenceId = getEventSequenceMetadata(type);
|
|
187
|
+
const properties = TypeIntrospector.getTrackedProperties(type);
|
|
188
|
+
const prototype = type.prototype;
|
|
189
|
+
|
|
190
|
+
const fromByEventType = new Map<string, FromRecord>();
|
|
191
|
+
const joinByEventType = new Map<string, { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } }>();
|
|
192
|
+
const removedWithByEventType = new Map<string, { Key: ContractEventType; Value: { Key: string; ParentKey: string } }>();
|
|
193
|
+
const removedWithJoinByEventType = new Map<string, { Key: ContractEventType; Value: { Key: string } }>();
|
|
194
|
+
const childrenByProperty: Record<string, ChildrenDefinitionLike> = {};
|
|
195
|
+
const nestedByProperty: Record<string, ChildrenDefinitionLike> = {};
|
|
196
|
+
|
|
197
|
+
const fromEvents = getFromEventMetadata(type);
|
|
198
|
+
for (const fromEvent of fromEvents) {
|
|
199
|
+
const eventType = toContractEventType(fromEvent.eventType);
|
|
200
|
+
const eventKey = getEventTypeMapKey(eventType);
|
|
201
|
+
fromByEventType.set(eventKey, {
|
|
202
|
+
Key: eventType,
|
|
203
|
+
Value: {
|
|
204
|
+
Properties: {},
|
|
205
|
+
Key: fromEvent.constantKey ? constantValueExpression(fromEvent.constantKey) : (fromEvent.key ?? '$eventSourceId'),
|
|
206
|
+
ParentKey: fromEvent.parentKey ?? ''
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const removedWithClass = getRemovedWithClassMetadata(type);
|
|
212
|
+
for (const removed of removedWithClass) {
|
|
213
|
+
const eventType = toContractEventType(removed.eventType);
|
|
214
|
+
removedWithByEventType.set(getEventTypeMapKey(eventType), {
|
|
215
|
+
Key: eventType,
|
|
216
|
+
Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' }
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const removedWithJoinClass = getRemovedWithJoinClassMetadata(type);
|
|
221
|
+
for (const removed of removedWithJoinClass) {
|
|
222
|
+
const eventType = toContractEventType(removed.eventType);
|
|
223
|
+
removedWithJoinByEventType.set(getEventTypeMapKey(eventType), {
|
|
224
|
+
Key: eventType,
|
|
225
|
+
Value: { Key: removed.key ?? '$eventSourceId' }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const property of properties) {
|
|
230
|
+
applyPropertyMappings(prototype, property, fromByEventType);
|
|
231
|
+
for (const mapping of getJoinMetadata(prototype, property)) {
|
|
232
|
+
const entry = this.ensureJoinEntry(joinByEventType, mapping.eventType);
|
|
233
|
+
entry.Value.On = mapping.on ?? (entry.Value.On || property);
|
|
234
|
+
entry.Value.Properties[property] = mapping.eventPropertyName ?? property;
|
|
235
|
+
}
|
|
236
|
+
for (const removed of getRemovedWithPropertyMetadata(prototype, property)) {
|
|
237
|
+
const eventType = toContractEventType(removed.eventType);
|
|
238
|
+
removedWithByEventType.set(getEventTypeMapKey(eventType), {
|
|
239
|
+
Key: eventType,
|
|
240
|
+
Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' }
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
for (const removed of getRemovedWithJoinPropertyMetadata(prototype, property)) {
|
|
244
|
+
const eventType = toContractEventType(removed.eventType);
|
|
245
|
+
removedWithJoinByEventType.set(getEventTypeMapKey(eventType), {
|
|
246
|
+
Key: eventType,
|
|
247
|
+
Value: { Key: removed.key ?? '$eventSourceId' }
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const childrenFromList = getChildrenFromMetadata(prototype, property);
|
|
251
|
+
if (childrenFromList.length > 0) {
|
|
252
|
+
childrenByProperty[property] = buildChildrenEntry(type, property, childrenFromList);
|
|
253
|
+
}
|
|
254
|
+
const propertyIsNested = isNested(prototype, property);
|
|
255
|
+
if (propertyIsNested) {
|
|
256
|
+
nestedByProperty[property] = buildNestedEntry(type, property);
|
|
257
|
+
}
|
|
258
|
+
// A scalar root property clears back to no value when the given event is observed.
|
|
259
|
+
// Nested single-object clearWith is handled by buildNestedEntry instead.
|
|
260
|
+
if (childrenFromList.length === 0 && !propertyIsNested) {
|
|
261
|
+
for (const clearWith of getClearWithPropertyMetadata(prototype, property)) {
|
|
262
|
+
const entry = ensureFromEntry(fromByEventType, clearWith.eventType);
|
|
263
|
+
entry.Value.Properties[property] = '$null';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const allProperties: Record<string, string> = {};
|
|
269
|
+
for (const property of properties) {
|
|
270
|
+
const fromEvery = getFromEveryMetadata(prototype, property) ?? getFromAllMetadata(prototype, property);
|
|
271
|
+
if (fromEvery) {
|
|
272
|
+
allProperties[property] = fromEvery.contextProperty
|
|
273
|
+
? fromEvery.contextProperty
|
|
274
|
+
: (fromEvery.property ?? property);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let from = Array.from(fromByEventType.values());
|
|
279
|
+
let join = Array.from(joinByEventType.values());
|
|
280
|
+
let variant: VariantDeclaration | undefined;
|
|
281
|
+
const variantMetadata = getVariantOfMetadata(type);
|
|
282
|
+
if (variantMetadata) {
|
|
283
|
+
const entersOnList = getEntersOnMetadata(type);
|
|
284
|
+
const enteringEventTypes = entersOnList.map(entersOn => {
|
|
285
|
+
const contractType = toContractEventType(entersOn.eventType);
|
|
286
|
+
if (entersOn.key) {
|
|
287
|
+
const entry = ensureFromEntry(fromByEventType, entersOn.eventType);
|
|
288
|
+
entry.Value.Key = entersOn.key;
|
|
289
|
+
}
|
|
290
|
+
return contractType;
|
|
291
|
+
});
|
|
292
|
+
from = Array.from(fromByEventType.values());
|
|
293
|
+
const globalHandlers = new Map<string, FromRecord[]>();
|
|
294
|
+
for (const handlerType of this._clientArtifacts.globalForHandlers) {
|
|
295
|
+
const globalForMetadata = getGlobalForMetadata(handlerType);
|
|
296
|
+
if (globalForMetadata?.identity === variantMetadata.identity) {
|
|
297
|
+
globalHandlers.set(handlerType.name, this.buildFromRecordsForType(handlerType));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const memberNames = new Set(getReadModelMetadata(type)?.members.keys() ?? TypeIntrospector.getMembers(type).keys());
|
|
301
|
+
const merged = mergeGlobalHandlers(type.name, memberNames, from, globalHandlers);
|
|
302
|
+
const reclassified = reclassify(type.name, merged, join, enteringEventTypes, variantMetadata.key);
|
|
303
|
+
from = reclassified.from;
|
|
304
|
+
join = reclassified.join;
|
|
305
|
+
variant = { identity: variantMetadata.identity, key: variantMetadata.key, enteringEventTypes };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const definition: Record<string, unknown> = {
|
|
309
|
+
EventSequenceId: eventSequenceId ?? EventSequenceId.eventLog.value,
|
|
310
|
+
Identifier: readModelIdentifier,
|
|
311
|
+
ReadModel: readModelIdentifier,
|
|
312
|
+
IsActive: !isPassive(type),
|
|
313
|
+
IsRewindable: !isNotRewindable(type),
|
|
314
|
+
InitialModelState: '{}',
|
|
315
|
+
From: from,
|
|
316
|
+
Join: join,
|
|
317
|
+
Children: childrenByProperty,
|
|
318
|
+
FromEvery: [],
|
|
319
|
+
All: {
|
|
320
|
+
Properties: allProperties,
|
|
321
|
+
IncludeChildren: false,
|
|
322
|
+
AutoMap: AutoMap.Inherit
|
|
323
|
+
},
|
|
324
|
+
RemovedWith: Array.from(removedWithByEventType.values()),
|
|
325
|
+
RemovedWithJoin: Array.from(removedWithJoinByEventType.values()),
|
|
326
|
+
LastUpdated: { Value: '' },
|
|
327
|
+
Tags: [],
|
|
328
|
+
AutoMap: isNoAutoMap(type) ? AutoMap.Disabled : AutoMap.Enabled,
|
|
329
|
+
NoAutoMapProperties: properties.filter(property => isPropertyNoAutoMap(prototype, property)),
|
|
330
|
+
Nested: nestedByProperty
|
|
331
|
+
};
|
|
332
|
+
return { typeName: type.name, definition, variant };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Builds event mappings for a global variant handler without creating a separate projection.
|
|
337
|
+
* @param type - The global handler type containing from-event and property mappings.
|
|
338
|
+
* @returns The event mappings to merge into each matching variant.
|
|
339
|
+
*/
|
|
340
|
+
private buildFromRecordsForType(type: Constructor): FromRecord[] {
|
|
341
|
+
const fromByEventType = new Map<string, FromRecord>();
|
|
342
|
+
const fromEvents = getFromEventMetadata(type);
|
|
343
|
+
for (const fromEvent of fromEvents) {
|
|
344
|
+
const eventType = toContractEventType(fromEvent.eventType);
|
|
345
|
+
fromByEventType.set(getEventTypeMapKey(eventType), {
|
|
346
|
+
Key: eventType,
|
|
347
|
+
Value: {
|
|
348
|
+
Properties: {},
|
|
349
|
+
Key: fromEvent.constantKey ? constantValueExpression(fromEvent.constantKey) : (fromEvent.key ?? '$eventSourceId'),
|
|
350
|
+
ParentKey: fromEvent.parentKey ?? ''
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const prototype = type.prototype;
|
|
355
|
+
for (const property of TypeIntrospector.getTrackedProperties(type)) {
|
|
356
|
+
applyPropertyMappings(prototype, property, fromByEventType);
|
|
357
|
+
}
|
|
358
|
+
return Array.from(fromByEventType.values());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private ensureJoinEntry(
|
|
362
|
+
joinByEventType: Map<string, { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } }>,
|
|
363
|
+
eventTypeConstructor: Function
|
|
364
|
+
): { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } } {
|
|
365
|
+
const eventType = toContractEventType(eventTypeConstructor);
|
|
366
|
+
const key = getEventTypeMapKey(eventType);
|
|
367
|
+
const existing = joinByEventType.get(key);
|
|
368
|
+
if (existing) {
|
|
369
|
+
return existing;
|
|
370
|
+
}
|
|
371
|
+
const created = { Key: eventType, Value: { On: '', Properties: {}, Key: '$eventSourceId' } };
|
|
372
|
+
joinByEventType.set(key, created);
|
|
373
|
+
return created;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Hash the final wire definition, excluding LastUpdated itself, to avoid unnecessary replays. */
|
|
377
|
+
private computeStableLastUpdated(definition: Record<string, unknown>): string {
|
|
378
|
+
const { LastUpdated: _omit, ...rest } = definition;
|
|
379
|
+
const content = JSON.stringify(rest, Object.keys(rest).sort());
|
|
380
|
+
let hash = 5381;
|
|
381
|
+
for (let i = 0; i < content.length; i++) {
|
|
382
|
+
hash = ((hash << 5) + hash + content.charCodeAt(i)) >>> 0;
|
|
383
|
+
}
|
|
384
|
+
return new Date(hash * 1000).toISOString();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -28,6 +28,9 @@ class LineQuantityChanged {
|
|
|
28
28
|
}
|
|
29
29
|
eventType()(LineQuantityChanged);
|
|
30
30
|
|
|
31
|
+
class LineQuantityCleared {}
|
|
32
|
+
eventType()(LineQuantityCleared);
|
|
33
|
+
|
|
31
34
|
class SummaryUpdated {
|
|
32
35
|
total!: number;
|
|
33
36
|
}
|
|
@@ -36,6 +39,9 @@ eventType()(SummaryUpdated);
|
|
|
36
39
|
class SummaryCleared {}
|
|
37
40
|
eventType()(SummaryCleared);
|
|
38
41
|
|
|
42
|
+
class SummaryTotalCleared {}
|
|
43
|
+
eventType()(SummaryTotalCleared);
|
|
44
|
+
|
|
39
45
|
class NoteAdded {
|
|
40
46
|
text!: string;
|
|
41
47
|
}
|
|
@@ -44,6 +50,9 @@ eventType()(NoteAdded);
|
|
|
44
50
|
class NoteCleared {}
|
|
45
51
|
eventType()(NoteCleared);
|
|
46
52
|
|
|
53
|
+
class OrderLabelCleared {}
|
|
54
|
+
eventType()(OrderLabelCleared);
|
|
55
|
+
|
|
47
56
|
class TagAdded {
|
|
48
57
|
name!: string;
|
|
49
58
|
}
|
|
@@ -57,11 +66,13 @@ class OrderLine {
|
|
|
57
66
|
quantity!: number;
|
|
58
67
|
}
|
|
59
68
|
setFrom(LineQuantityChanged)(OrderLine.prototype, 'quantity');
|
|
69
|
+
clearWith(LineQuantityCleared)(OrderLine.prototype, 'quantity');
|
|
60
70
|
|
|
61
71
|
class OrderSummary {
|
|
62
72
|
total!: number;
|
|
63
73
|
}
|
|
64
74
|
setFrom(SummaryUpdated)(OrderSummary.prototype, 'total');
|
|
75
|
+
clearWith(SummaryTotalCleared)(OrderSummary.prototype, 'total');
|
|
65
76
|
clearWith(SummaryCleared)(OrderSummary);
|
|
66
77
|
|
|
67
78
|
class OrderNote {
|
|
@@ -75,7 +86,9 @@ class Order {
|
|
|
75
86
|
summary!: OrderSummary | undefined;
|
|
76
87
|
note!: OrderNote | undefined;
|
|
77
88
|
tags!: string[];
|
|
89
|
+
label?: string;
|
|
78
90
|
}
|
|
91
|
+
clearWith(OrderLabelCleared)(Order.prototype, 'label');
|
|
79
92
|
childrenFrom(LineAdded, undefined, 'productId', undefined)(Order.prototype, 'lines');
|
|
80
93
|
field(Array, { enumerable: true, genericArguments: [OrderLine] })(Order.prototype, 'lines');
|
|
81
94
|
nested(Order.prototype, 'summary');
|
|
@@ -94,10 +107,11 @@ interface FromRecord {
|
|
|
94
107
|
interface ChildrenDefinition {
|
|
95
108
|
IdentifiedBy: string;
|
|
96
109
|
From: FromRecord[];
|
|
97
|
-
RemovedWith: Array<{ Key: { Id: string } }>;
|
|
110
|
+
RemovedWith: Array<{ Key: { Id: string }; Value: { Key: string; ParentKey: string } }>;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
interface BuiltDefinition {
|
|
114
|
+
From: FromRecord[];
|
|
101
115
|
Children: Record<string, ChildrenDefinition>;
|
|
102
116
|
Nested: Record<string, ChildrenDefinition>;
|
|
103
117
|
}
|
|
@@ -150,6 +164,16 @@ describe('Projections with childrenFrom, nested and clearWith', () => {
|
|
|
150
164
|
expect(updateEntry.Value.Properties.quantity).toBe('quantity');
|
|
151
165
|
});
|
|
152
166
|
|
|
167
|
+
it('should clear only the child member through a From mapping', async () => {
|
|
168
|
+
const { projections, registerMock } = createProjections([Order]);
|
|
169
|
+
await projections.register();
|
|
170
|
+
|
|
171
|
+
const lines = (registerMock.mock.calls[0][0].Projections[0] as BuiltDefinition).Children.lines;
|
|
172
|
+
const clearEntry = lines.From.find(candidate => candidate.Key.Id === 'LineQuantityCleared')!;
|
|
173
|
+
expect(clearEntry.Value).toEqual({ Properties: { quantity: '$null' }, Key: '$eventSourceId', ParentKey: '' });
|
|
174
|
+
expect(lines.RemovedWith.some(candidate => candidate.Key.Id === 'LineQuantityCleared')).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
153
177
|
it('should build a children definition even when the child element type cannot be resolved', async () => {
|
|
154
178
|
const { projections, registerMock } = createProjections([Order]);
|
|
155
179
|
await projections.register();
|
|
@@ -170,7 +194,18 @@ describe('Projections with childrenFrom, nested and clearWith', () => {
|
|
|
170
194
|
expect(summary.IdentifiedBy).toBe('*NotSet*');
|
|
171
195
|
const updateEntry = summary.From.find(candidate => candidate.Key.Id === 'SummaryUpdated')!;
|
|
172
196
|
expect(updateEntry.Value.Properties.total).toBe('total');
|
|
173
|
-
expect(summary.RemovedWith.
|
|
197
|
+
expect(summary.RemovedWith).toContainEqual({ Key: expect.objectContaining({ Id: 'SummaryCleared' }), Value: { Key: '$eventSourceId', ParentKey: '' } });
|
|
198
|
+
expect(summary.From.some(candidate => candidate.Key.Id === 'SummaryCleared')).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should clear only the nested type member through a From mapping', async () => {
|
|
202
|
+
const { projections, registerMock } = createProjections([Order]);
|
|
203
|
+
await projections.register();
|
|
204
|
+
|
|
205
|
+
const summary = (registerMock.mock.calls[0][0].Projections[0] as BuiltDefinition).Nested.summary;
|
|
206
|
+
const clearEntry = summary.From.find(candidate => candidate.Key.Id === 'SummaryTotalCleared')!;
|
|
207
|
+
expect(clearEntry.Value).toEqual({ Properties: { total: '$null' }, Key: '$eventSourceId', ParentKey: '' });
|
|
208
|
+
expect(summary.RemovedWith.some(candidate => candidate.Key.Id === 'SummaryTotalCleared')).toBe(false);
|
|
174
209
|
});
|
|
175
210
|
|
|
176
211
|
it('should build a nested definition honoring a property-level clearWith', async () => {
|
|
@@ -184,6 +219,16 @@ describe('Projections with childrenFrom, nested and clearWith', () => {
|
|
|
184
219
|
const updateEntry = note.From.find(candidate => candidate.Key.Id === 'NoteAdded')!;
|
|
185
220
|
expect(updateEntry.Value.Properties.text).toBe('text');
|
|
186
221
|
expect(note.RemovedWith.some(candidate => candidate.Key.Id === 'NoteCleared')).toBe(true);
|
|
222
|
+
expect(note.From.some(candidate => candidate.Key.Id === 'NoteCleared')).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should keep root scalar clearWith as a From mapping', async () => {
|
|
226
|
+
const { projections, registerMock } = createProjections([Order]);
|
|
227
|
+
await projections.register();
|
|
228
|
+
|
|
229
|
+
const definition = registerMock.mock.calls[0][0].Projections[0] as BuiltDefinition;
|
|
230
|
+
const clearEntry = definition.From.find(candidate => candidate.Key.Id === 'OrderLabelCleared')!;
|
|
231
|
+
expect(clearEntry.Value).toEqual({ Properties: { label: '$null' }, Key: '$eventSourceId', ParentKey: '' });
|
|
187
232
|
});
|
|
188
233
|
});
|
|
189
234
|
});
|