@cratis/chronicle 6.14.1 → 6.14.3

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