@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.
Files changed (96) hide show
  1. package/dist/projections/declarative/AddChildBuilder.d.ts +13 -2
  2. package/dist/projections/declarative/AddChildBuilder.d.ts.map +1 -1
  3. package/dist/projections/declarative/AddChildBuilder.js +27 -1
  4. package/dist/projections/declarative/AddChildBuilder.js.map +1 -1
  5. package/dist/projections/declarative/FromBuilder.d.ts +4 -0
  6. package/dist/projections/declarative/FromBuilder.d.ts.map +1 -1
  7. package/dist/projections/declarative/FromBuilder.js +1 -2
  8. package/dist/projections/declarative/FromBuilder.js.map +1 -1
  9. package/dist/projections/declarative/IAddChildBuilder.d.ts +12 -0
  10. package/dist/projections/declarative/IAddChildBuilder.d.ts.map +1 -1
  11. package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts +16 -1
  12. package/dist/projections/declarative/IReadModelPropertiesBuilder.d.ts.map +1 -1
  13. package/dist/projections/declarative/JoinBuilder.d.ts +4 -0
  14. package/dist/projections/declarative/JoinBuilder.d.ts.map +1 -1
  15. package/dist/projections/declarative/JoinBuilder.js +1 -2
  16. package/dist/projections/declarative/JoinBuilder.js.map +1 -1
  17. package/dist/projections/declarative/ProjectionBuilderCore.js +1 -1
  18. package/dist/projections/declarative/ProjectionBuilderCore.js.map +1 -1
  19. package/dist/projections/modelBound/childrenAndNestedBuilder.d.ts.map +1 -1
  20. package/dist/projections/modelBound/childrenAndNestedBuilder.js +41 -10
  21. package/dist/projections/modelBound/childrenAndNestedBuilder.js.map +1 -1
  22. package/dist/testing/IReadModelProcessor.d.ts +7 -0
  23. package/dist/testing/IReadModelProcessor.d.ts.map +1 -0
  24. package/dist/testing/IReadModelProcessor.js +4 -0
  25. package/dist/testing/IReadModelProcessor.js.map +1 -0
  26. package/dist/testing/ReadModelScenario.d.ts +6 -8
  27. package/dist/testing/ReadModelScenario.d.ts.map +1 -1
  28. package/dist/testing/ReadModelScenario.js +50 -50
  29. package/dist/testing/ReadModelScenario.js.map +1 -1
  30. package/dist/testing/ReadModelState.d.ts +6 -0
  31. package/dist/testing/ReadModelState.d.ts.map +1 -0
  32. package/dist/testing/ReadModelState.js +4 -0
  33. package/dist/testing/ReadModelState.js.map +1 -0
  34. package/dist/testing/ReducerReadModelProcessor.d.ts +13 -0
  35. package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -0
  36. package/dist/testing/ReducerReadModelProcessor.js +38 -0
  37. package/dist/testing/ReducerReadModelProcessor.js.map +1 -0
  38. package/dist/testing/ScenarioEvent.d.ts +8 -0
  39. package/dist/testing/ScenarioEvent.d.ts.map +1 -0
  40. package/dist/testing/ScenarioEvent.js +4 -0
  41. package/dist/testing/ScenarioEvent.js.map +1 -0
  42. package/dist/testing/index.d.ts +1 -0
  43. package/dist/testing/index.d.ts.map +1 -1
  44. package/dist/testing/index.js +1 -0
  45. package/dist/testing/index.js.map +1 -1
  46. package/dist/testing/projections/ProjectionCapabilities.d.ts +3 -0
  47. package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
  48. package/dist/testing/projections/ProjectionCapabilities.js +117 -43
  49. package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
  50. package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts +9 -0
  51. package/dist/testing/projections/ProjectionExpressionEvaluator.d.ts.map +1 -0
  52. package/dist/testing/projections/ProjectionExpressionEvaluator.js +68 -0
  53. package/dist/testing/projections/ProjectionExpressionEvaluator.js.map +1 -0
  54. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts +30 -0
  55. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -0
  56. package/dist/testing/projections/ProjectionReadModelProcessor.js +142 -0
  57. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -0
  58. package/dist/testing/projections/ProjectionValueConverter.d.ts +13 -0
  59. package/dist/testing/projections/ProjectionValueConverter.d.ts.map +1 -0
  60. package/dist/testing/projections/ProjectionValueConverter.js +122 -0
  61. package/dist/testing/projections/ProjectionValueConverter.js.map +1 -0
  62. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts +0 -2
  63. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.d.ts.map +1 -1
  64. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js +0 -4
  65. package/dist/testing/projections/for_ProjectionCapabilities/given/Changed.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +2 -2
  68. package/projections/ProjectionDefinitionCompiler.registration.spec.ts +4 -2
  69. package/projections/Projections.childrenAndNested.spec.ts +250 -1
  70. package/projections/declarative/AddChildBuilder.ts +33 -2
  71. package/projections/declarative/FromBuilder.ts +9 -1
  72. package/projections/declarative/IAddChildBuilder.ts +14 -0
  73. package/projections/declarative/IReadModelPropertiesBuilder.ts +18 -1
  74. package/projections/declarative/JoinBuilder.ts +9 -1
  75. package/projections/declarative/ProjectionBuilderCore.ts +1 -1
  76. package/projections/declarative/addChildCompatibility.standard.spec.ts +80 -0
  77. package/projections/eventContextExpressions.spec.ts +20 -0
  78. package/projections/modelBound/childrenAndNestedBuilder.ts +45 -11
  79. package/testing/IReadModelProcessor.ts +10 -0
  80. package/testing/ReadModelScenario.spec.ts +156 -10
  81. package/testing/ReadModelScenario.ts +57 -57
  82. package/testing/ReadModelState.ts +5 -0
  83. package/testing/ReducerReadModelProcessor.ts +39 -0
  84. package/testing/ScenarioEvent.ts +7 -0
  85. package/testing/index.ts +1 -0
  86. package/testing/projections/ProjectionCapabilities.ts +100 -39
  87. package/testing/projections/ProjectionExpressionEvaluator.ts +57 -0
  88. package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +135 -0
  89. package/testing/projections/ProjectionReadModelProcessor.ts +147 -0
  90. package/testing/projections/ProjectionValueConverter.spec.ts +81 -0
  91. package/testing/projections/ProjectionValueConverter.ts +113 -0
  92. package/testing/projections/for_ProjectionCapabilities/given/Changed.ts +1 -3
  93. package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +81 -1
  94. package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +40 -12
  95. package/testing/projections/for_ProjectionCapabilities/when_validating_schema_boundaries.spec.ts +76 -29
  96. package/testing/projections/for_ProjectionCapabilities/when_validating_supported_operations.spec.ts +8 -35
@@ -171,6 +171,14 @@ export class FromBuilder<TReadModel, TEvent> implements IFromBuilder<TReadModel,
171
171
  }
172
172
 
173
173
  /** @inheritdoc */
174
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, builderCallback: (builder: IAddChildBuilder<TChildModel, TEvent> & TEvent) => void): this;
175
+ /** @inheritdoc */
176
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, eventPropertyAccessor: PropertyAccessor<TEvent>): this;
177
+ /** @inheritdoc */
178
+ addChild<TChildModel>(
179
+ targetPropertyAccessor: PropertyAccessor<TReadModel>,
180
+ eventPropertyAccessorOrBuilderCallback: PropertyAccessor<TEvent> | ((builder: IAddChildBuilder<TChildModel, TEvent>) => void)
181
+ ): this;
174
182
  addChild<TChildModel>(
175
183
  targetPropertyAccessor: PropertyAccessor<TReadModel>,
176
184
  eventPropertyAccessorOrBuilderCallback: PropertyAccessor<TEvent> | ((builder: IAddChildBuilder<TChildModel, TEvent>) => void)
@@ -184,7 +192,7 @@ export class FromBuilder<TReadModel, TEvent> implements IFromBuilder<TReadModel,
184
192
  (eventPropertyAccessorOrBuilderCallback as (value: unknown) => void)(probeProxy);
185
193
 
186
194
  this.entry.children.push(probe.usedAsBuilder
187
- ? { targetProperty: targetHandler.property, identifiedBy: probe.identifiedByProperty, usingKey: probe.usingKeyProperty }
195
+ ? { targetProperty: targetHandler.property, identifiedBy: probe.identifiedByProperty, usingKey: probe.usingKeyProperty, usingParentKey: probe.usingParentKeyProperty }
188
196
  : { targetProperty: targetHandler.property, fromEventProperty: probe.capturedEventProperty });
189
197
  return this;
190
198
  }
@@ -22,4 +22,18 @@ export interface IAddChildBuilder<TChildModel, TEvent> {
22
22
  * @returns This builder for fluent chaining.
23
23
  */
24
24
  usingKey(eventPropertyAccessor: PropertyAccessor<TEvent>): IAddChildBuilder<TChildModel, TEvent>;
25
+
26
+ /**
27
+ * Specifies an event context property used as the key when adding a child.
28
+ * @param contextPropertyName - Property path on EventContext, using TypeScript casing.
29
+ * @returns This builder for fluent chaining.
30
+ */
31
+ usingKeyFromContext(contextPropertyName: string): IAddChildBuilder<TChildModel, TEvent>;
32
+
33
+ /**
34
+ * Specifies an event context property used as the parent key when adding a child.
35
+ * @param contextPropertyName - Property path on EventContext, using TypeScript casing.
36
+ * @returns This builder for fluent chaining.
37
+ */
38
+ usingParentKeyFromContext(contextPropertyName: string): IAddChildBuilder<TChildModel, TEvent>;
25
39
  }
@@ -107,7 +107,24 @@ export interface IReadModelPropertiesBuilder<TReadModel, TEvent, TBuilder> {
107
107
  count(readModelPropertyAccessor: PropertyAccessor<TReadModel>): TBuilder;
108
108
 
109
109
  /**
110
- * Adds a child model from the event to a collection property on the read model.
110
+ * Adds a child model configured through a child builder to a collection on the read model.
111
+ * Event members remain available while TypeScript resolves the two function-shaped overloads.
112
+ * @param targetPropertyAccessor - Accessor for the collection property on the read model.
113
+ * @param builderCallback - Callback configuring the child identifier and keys.
114
+ * @returns This builder for fluent chaining.
115
+ */
116
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, builderCallback: (builder: IAddChildBuilder<TChildModel, TEvent> & TEvent) => void): TBuilder;
117
+
118
+ /**
119
+ * Adds a child model directly from an event property to a collection on the read model.
120
+ * @param targetPropertyAccessor - Accessor for the collection property on the read model.
121
+ * @param eventPropertyAccessor - Accessor for the event property holding the child value.
122
+ * @returns This builder for fluent chaining.
123
+ */
124
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, eventPropertyAccessor: PropertyAccessor<TEvent>): TBuilder;
125
+
126
+ /**
127
+ * Retains support for arbitrary read model accessors and explicitly selected child types.
111
128
  * @param targetPropertyAccessor - Accessor for the collection property on the read model.
112
129
  * @param eventPropertyAccessorOrBuilderCallback - Either a direct event property accessor or a builder callback.
113
130
  * @returns This builder for fluent chaining.
@@ -170,6 +170,14 @@ export class JoinBuilder<TReadModel, TEvent> implements IJoinBuilder<TReadModel,
170
170
  }
171
171
 
172
172
  /** @inheritdoc */
173
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, builderCallback: (builder: IAddChildBuilder<TChildModel, TEvent> & TEvent) => void): this;
174
+ /** @inheritdoc */
175
+ addChild<TChildModel>(targetPropertyAccessor: (model: TReadModel) => readonly TChildModel[] | null | undefined, eventPropertyAccessor: PropertyAccessor<TEvent>): this;
176
+ /** @inheritdoc */
177
+ addChild<TChildModel>(
178
+ targetPropertyAccessor: PropertyAccessor<TReadModel>,
179
+ eventPropertyAccessorOrBuilderCallback: PropertyAccessor<TEvent> | ((builder: IAddChildBuilder<TChildModel, TEvent>) => void)
180
+ ): this;
173
181
  addChild<TChildModel>(
174
182
  targetPropertyAccessor: PropertyAccessor<TReadModel>,
175
183
  eventPropertyAccessorOrBuilderCallback: PropertyAccessor<TEvent> | ((builder: IAddChildBuilder<TChildModel, TEvent>) => void)
@@ -183,7 +191,7 @@ export class JoinBuilder<TReadModel, TEvent> implements IJoinBuilder<TReadModel,
183
191
  (eventPropertyAccessorOrBuilderCallback as (value: unknown) => void)(probeProxy);
184
192
 
185
193
  this.entry.children.push(probe.usedAsBuilder
186
- ? { targetProperty: targetHandler.property, identifiedBy: probe.identifiedByProperty, usingKey: probe.usingKeyProperty }
194
+ ? { targetProperty: targetHandler.property, identifiedBy: probe.identifiedByProperty, usingKey: probe.usingKeyProperty, usingParentKey: probe.usingParentKeyProperty }
187
195
  : { targetProperty: targetHandler.property, fromEventProperty: probe.capturedEventProperty });
188
196
  return this;
189
197
  }
@@ -256,7 +256,7 @@ export abstract class ProjectionBuilderCore<TReadModel, TBuilder> {
256
256
  Key: eventType,
257
257
  Value: {
258
258
  Key: child.usingKey ?? '$eventSourceId',
259
- ParentKey: '$eventSourceId',
259
+ ParentKey: child.usingParentKey ?? '$eventSourceId',
260
260
  Properties: {}
261
261
  }
262
262
  });
@@ -0,0 +1,80 @@
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 { describe, expect, it } from 'vitest';
5
+ import { eventType } from '../../events/eventTypeDecorator.js';
6
+ import { FromBuilder } from './FromBuilder.js';
7
+ import { IAddChildBuilder } from './IAddChildBuilder.js';
8
+ import { IReadModelPropertiesBuilder } from './IReadModelPropertiesBuilder.js';
9
+ import { JoinBuilder } from './JoinBuilder.js';
10
+ import { ProjectionBuilderFor } from './ProjectionBuilderFor.js';
11
+
12
+ class Line {
13
+ id = '';
14
+ }
15
+
16
+ class Base {
17
+ id = '';
18
+ }
19
+
20
+ class Derived extends Base {
21
+ derivedId = '';
22
+ }
23
+
24
+ class LineAdded {
25
+ line = new Line();
26
+ }
27
+ eventType()(LineAdded);
28
+
29
+ class Order {
30
+ id = '';
31
+ lines: Line[] = [];
32
+ nullableLines: Line[] | null = null;
33
+ bases: Base[] = [];
34
+ }
35
+
36
+ // This checks the shared public interface independently of either concrete builder.
37
+ function acceptOriginalCallSites(builder: IReadModelPropertiesBuilder<Order, LineAdded, unknown>): void {
38
+ builder.addChild<Line>(model => model.nullableLines, event => event.line);
39
+ builder.addChild(model => model.nullableLines, child => child.identifiedBy(item => item.id));
40
+ builder.addChild<Derived>(model => model.bases, (event: LineAdded) => event.line);
41
+ builder.addChild<Derived>(model => model.bases, (child: IAddChildBuilder<Derived, LineAdded>) => child
42
+ .identifiedBy(item => item.derivedId)
43
+ .usingKeyFromContext('sequenceNumber'));
44
+ builder.addChild<Line>(model => model.lines, (event: LineAdded) => event.line);
45
+ }
46
+
47
+ describe('addChild source compatibility', () => {
48
+ it('should accept nullable targets and explicitly narrower child types on the shared interface', () => {
49
+ const builder: IReadModelPropertiesBuilder<Order, LineAdded, unknown> = new FromBuilder<Order, LineAdded>();
50
+ acceptOriginalCallSites(builder);
51
+ });
52
+
53
+ it('should accept the original call shapes on the concrete from builder', () => {
54
+ const builder = new FromBuilder<Order, LineAdded>();
55
+ builder.addChild<Line>(model => model.nullableLines, event => event.line);
56
+ builder.addChild<Derived>(model => model.bases, (child: IAddChildBuilder<Derived, LineAdded>) => child.identifiedBy(item => item.derivedId));
57
+ builder.addChild<Derived>(model => model.bases, (event: LineAdded) => event.line);
58
+ builder.addChild<Line>(model => model.lines, (event: LineAdded) => event.line);
59
+ expect(builder.entry.children).toHaveLength(4);
60
+ });
61
+
62
+ it('should accept the original call shapes on the concrete join builder', () => {
63
+ const builder = new JoinBuilder<Order, LineAdded>();
64
+ builder.addChild<Line>(model => model.nullableLines, event => event.line);
65
+ builder.addChild<Derived>(model => model.bases, (child: IAddChildBuilder<Derived, LineAdded>) => child.identifiedBy(item => item.derivedId));
66
+ builder.addChild<Derived>(model => model.bases, (event: LineAdded) => event.line);
67
+ builder.addChild<Line>(model => model.lines, (event: LineAdded) => event.line);
68
+ expect(builder.entry.children).toHaveLength(4);
69
+ });
70
+
71
+ it('should contextually type the unannotated builder callback in the documentation example', () => {
72
+ const projection = new ProjectionBuilderFor<Order>();
73
+ projection.from(LineAdded, from => from
74
+ .addChild(model => model.lines, child => child
75
+ .identifiedBy(item => item.id)
76
+ .usingKeyFromContext('sequenceNumber')
77
+ .usingParentKeyFromContext('eventSourceId')));
78
+ expect(projection.build('order', 'Order').Children).toHaveProperty('lines');
79
+ });
80
+ });
@@ -182,6 +182,24 @@ describe('event context expressions', () => {
182
182
  .From[0].Value.Key.should.equal('$eventContext(EventSourceId)');
183
183
  });
184
184
 
185
+ it('should use context properties as addChild keys in a from clause', () => {
186
+ const child = declarative(builder => builder.from(Recorded, from => from.addChild<Child>(model => model.children, added => added
187
+ .identifiedBy(model => model.happened)
188
+ .usingKeyFromContext('sequenceNumber')
189
+ .usingParentKeyFromContext('eventSourceId'))));
190
+ child.Children.children.From[0].Value.Key.should.equal('$eventContext(SequenceNumber)');
191
+ child.Children.children.From[0].Value.ParentKey.should.equal('$eventContext(EventSourceId)');
192
+ });
193
+
194
+ it('should use context properties as addChild keys in a join clause', () => {
195
+ const child = declarative(builder => builder.join(Recorded, join => join.on(model => model.occurred)
196
+ .addChild<Child>(model => model.children, added => added
197
+ .usingKeyFromContext('eventSourceId')
198
+ .usingParentKeyFromContext('sequenceNumber'))));
199
+ child.Children.children.From[0].Value.Key.should.equal('$eventContext(EventSourceId)');
200
+ child.Children.children.From[0].Value.ParentKey.should.equal('$eventContext(SequenceNumber)');
201
+ });
202
+
185
203
  it('should use a context property as a from parent key', () => {
186
204
  declarative(builder => builder.from(Recorded, from => from.usingParentKeyFromContext('eventSourceId')))
187
205
  .From[0].Value.ParentKey.should.equal('$eventContext(EventSourceId)');
@@ -255,6 +273,8 @@ describe('event context expressions', () => {
255
273
  const mappings = [
256
274
  (builder: ProjectionBuilderFor<Model>) => builder.from(Recorded, from => from.usingKeyFromContext('missingKey')),
257
275
  (builder: ProjectionBuilderFor<Model>) => builder.from(Recorded, from => from.usingParentKeyFromContext('missingParent')),
276
+ (builder: ProjectionBuilderFor<Model>) => builder.from(Recorded, from => from.addChild<Child>(model => model.children, child => child.usingKeyFromContext('missingChildKey'))),
277
+ (builder: ProjectionBuilderFor<Model>) => builder.join(Recorded, join => join.on(model => model.occurred).addChild<Child>(model => model.children, child => child.usingParentKeyFromContext('missingChildParent'))),
258
278
  (builder: ProjectionBuilderFor<Model>) => builder.join(Recorded, join => join.on(model => model.occurred).usingKeyFromContext('missingJoinKey')),
259
279
  (builder: ProjectionBuilderFor<Model>) => builder.removedWith(Removed, removed => removed.usingKeyFromContext('missingRemovalKey'))
260
280
  ];
@@ -48,6 +48,8 @@ export interface ChildrenDefinitionLike {
48
48
  NoAutoMapProperties: string[];
49
49
  }
50
50
 
51
+ const aggregateExpressions = ['$count', '$increment', '$decrement', '$add', '$subtract'];
52
+
51
53
  /**
52
54
  * Resolves the contract event type for a decorated event constructor.
53
55
  * @param eventTypeConstructor - The event class constructor.
@@ -209,23 +211,20 @@ export function resolveNestedType(type: Function, property: string): Function |
209
211
 
210
212
  /**
211
213
  * Discovers the child model property used to identify instances, by convention: a property
212
- * named `id` (case-insensitive), matching the C# client's fallback once no `[Key]` attribute
213
- * is present (TypeScript has no `[Key]` decorator equivalent).
214
+ * named `id` (case-insensitive), or a property matching the event key when no id is found.
215
+ * TypeScript has no `[Key]` decorator equivalent.
214
216
  * @param childType - The child/nested model type, when resolvable.
217
+ * @param eventKey - The configured event key, when present.
215
218
  * @returns The discovered property name, or undefined when no convention match is found.
216
219
  */
217
- function discoverIdentifiedBy(childType: Function | undefined): string | undefined {
220
+ function discoverIdentifiedBy(childType: Function | undefined, eventKey: string | undefined): string | undefined {
218
221
  if (!childType) {
219
222
  return undefined;
220
223
  }
221
224
 
222
- for (const name of TypeIntrospector.getMembers(childType).keys()) {
223
- if (name.toLowerCase() === 'id') {
224
- return name;
225
- }
226
- }
227
-
228
- return undefined;
225
+ const members = [...TypeIntrospector.getMembers(childType).keys()];
226
+ return members.find(name => name.toLowerCase() === 'id') ??
227
+ (eventKey !== undefined ? members.find(name => name.toLowerCase() === eventKey.toLowerCase()) : undefined);
229
228
  }
230
229
 
231
230
  /**
@@ -325,7 +324,7 @@ export function buildChildrenEntry(type: Function, property: string, metadataLis
325
324
  const definition = createEmptyChildrenDefinition(type, childType);
326
325
 
327
326
  const explicitIdentifiedBy = metadataList.find(metadata => metadata.identifiedBy)?.identifiedBy;
328
- definition.IdentifiedBy = explicitIdentifiedBy ?? discoverIdentifiedBy(childType) ?? '$eventSourceId';
327
+ definition.IdentifiedBy = explicitIdentifiedBy ?? discoverIdentifiedBy(childType, metadataList[0]?.key) ?? '$eventSourceId';
329
328
 
330
329
  for (const metadata of metadataList) {
331
330
  const eventType = toContractEventType(metadata.eventType);
@@ -340,6 +339,41 @@ export function buildChildrenEntry(type: Function, property: string, metadataLis
340
339
  }
341
340
 
342
341
  populateFromType(definition, childType);
342
+
343
+ // For non-aggregate-only creating events, populate an unmapped child identifier from
344
+ // the creating event's key. IdentifiedBy selects the child property; it is not itself
345
+ // a property mapping. TypeScript has no [Key] decorator, so an explicit identifiedBy
346
+ // plays the same role as a discovered id for this default.
347
+ const identifier = definition.IdentifiedBy;
348
+ if (definition.AutoMap === AutoMap.Enabled && identifier !== '$eventSourceId') {
349
+ const prototype = childType?.prototype;
350
+ const hasExplicitMapping = prototype !== undefined && (
351
+ getSetFromMetadata(prototype, identifier).length > 0 ||
352
+ getSetFromContextMetadata(prototype, identifier).length > 0 ||
353
+ getSetValueMetadata(prototype, identifier).length > 0 ||
354
+ getAddFromMetadata(prototype, identifier).length > 0 ||
355
+ getSubtractFromMetadata(prototype, identifier).length > 0);
356
+ if (!hasExplicitMapping) {
357
+ for (const metadata of metadataList) {
358
+ const entry = definition.From.find(candidate => getEventTypeMapKey(candidate.Key) === getEventTypeMapKey(toContractEventType(metadata.eventType)))!;
359
+ const mappings = Object.values(entry.Value.Properties);
360
+ // Preserve the kernel's aggregate-only AutoMap exemption: Changeset.AddChild already
361
+ // initializes the child identifier from the resolved key before applying property mappers.
362
+ if (mappings.length > 0 && mappings.every(expression =>
363
+ aggregateExpressions.some(aggregate => expression.startsWith(aggregate)))) {
364
+ continue;
365
+ }
366
+ // Child property mappings may replace the creating key (for example, a constant-key count).
367
+ const key = entry.Value.Key;
368
+ // When an event property is also the identifier, AutoMap can fill it directly.
369
+ if (key.toLowerCase() !== identifier.toLowerCase() && !(identifier in entry.Value.Properties)) {
370
+ entry.Value.Properties[identifier] = key === '$eventSourceId'
371
+ ? eventContextPropertyExpression('eventSourceId')
372
+ : key;
373
+ }
374
+ }
375
+ }
376
+ }
343
377
  return definition;
344
378
  }
345
379
 
@@ -0,0 +1,10 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { ReadModelState } from './ReadModelState.js';
5
+ import type { ScenarioEvent } from './ScenarioEvent.js';
6
+
7
+ /** Replays the complete serialized scenario history for independent event sources. */
8
+ export interface IReadModelProcessor<TReadModel extends object> {
9
+ process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>>;
10
+ }
@@ -2,18 +2,26 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { describe, expect, it } from 'vitest';
5
- import { ConceptAs, field } from '@cratis/fundamentals';
5
+ import { ConceptAs, field, Guid } from '@cratis/fundamentals';
6
6
  import type { Constructor } from '@cratis/fundamentals';
7
+ import { pii } from '../compliance/pii.js';
7
8
  import { eventType } from '../events/eventTypeDecorator.js';
8
9
  import type { EventContext } from '../events/EventContext.js';
9
10
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
11
+ import { setFrom } from '../projections/modelBound/setFrom.js';
12
+ import { setFromContext } from '../projections/modelBound/setFromContext.js';
13
+ import { removedWith } from '../projections/modelBound/removedWith.js';
14
+ import type { IProjectionBuilderFor } from '../projections/declarative/IProjectionBuilderFor.js';
15
+ import type { IProjectionFor } from '../projections/declarative/IProjectionFor.js';
10
16
  import { projection } from '../projections/declarative/projection.js';
11
17
  import { reducer } from '../reducers/reducer.js';
12
- import { ReadModelScenario, ReadModelScenarioGivenBuilder } from './index.js';
18
+ import { readModel } from '../readModels/readModel.js';
19
+ import { ReadModelScenario, ReadModelScenarioGivenBuilder, UnsupportedProjectionOperation } from './index.js';
13
20
  import { readModelScenarioExample } from './ReadModelScenario.example.js';
14
21
 
15
22
  class ItemAdded {
16
- constructor(readonly amount: number) {}
23
+ @field(Number) amount: number;
24
+ constructor(amount: number) { this.amount = amount; }
17
25
  }
18
26
  eventType('scenario-item-added')(ItemAdded);
19
27
  class ItemRemoved {}
@@ -177,15 +185,153 @@ describe('ReadModelScenario', () => {
177
185
  await expect(scenario.instance).rejects.toThrow('FailingReducer');
178
186
  });
179
187
 
180
- it('rejects model-bound and declarative projections rather than silently returning null', () => {
181
- class ProjectedState {}
188
+ it('replays a compiled model-bound projection and recreates it after removal', async () => {
189
+ class ProjectedState {
190
+ @field(String) id = '';
191
+ @field(Number) count = 0;
192
+ }
182
193
  fromEvent(ItemAdded)(ProjectedState);
183
- expect(() => new ReadModelScenario(ProjectedState, artifacts)).toThrow('not supported yet; use a kernel-backed test');
194
+ setFrom(ItemAdded, 'amount')(ProjectedState.prototype, 'count');
195
+ removedWith(ItemRemoved)(ProjectedState);
196
+ const scenario = new ReadModelScenario(ProjectedState, artifacts);
197
+ scenario.given.forEventSource('A').events(new ItemAdded(3), new ItemRemoved());
198
+ expect(await scenario.instanceForEventSourceId('A')).toBeNull();
199
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(true);
200
+ scenario.given.forEventSource('A').events(new UnrelatedEvent(), new ItemAdded(7));
201
+ expect(await scenario.instance).toMatchObject({ count: 7 });
202
+ expect(await scenario.wasDeletedForEventSourceId('A')).toBe(false);
203
+ });
184
204
 
185
- class FluentState {}
186
- class FluentProjection {}
205
+ it('discovers a property-only model-bound read model without an artifact catalog', async () => {
206
+ class PropertyOnlyState { @field(String) id = ''; @field(Number) count = 0; }
207
+ setFrom(ItemAdded, 'amount')(PropertyOnlyState.prototype, 'count');
208
+ const scenario = new ReadModelScenario(PropertyOnlyState);
209
+ scenario.given.forEventSource('A').events(new ItemAdded(5));
210
+ expect(await scenario.instance).toMatchObject({ count: 5 });
211
+ });
212
+
213
+ it('defaults a projection Subject mapping to the event source when the seed has no subject', async () => {
214
+ class SubjectState { @field(String) id = ''; @field(String) subject = ''; }
215
+ fromEvent(ItemAdded)(SubjectState);
216
+ setFromContext(ItemAdded, 'subject')(SubjectState.prototype, 'subject');
217
+ const scenario = new ReadModelScenario(SubjectState, artifacts);
218
+ scenario.given.forEventSource('source-a').events(new ItemAdded(1));
219
+ expect(await scenario.instance).toMatchObject({ subject: 'source-a' });
220
+ });
221
+
222
+ it('defaults a projection Hash and CausedBy mapping to kernel sentinels when the seed omits them', async () => {
223
+ class ContextDefaults { @field(String) id = ''; @field(String) hash = ''; @field(String) causedByName = ''; }
224
+ setFromContext(ItemAdded, 'hash')(ContextDefaults.prototype, 'hash');
225
+ setFromContext(ItemAdded, 'causedBy.name')(ContextDefaults.prototype, 'causedByName');
226
+ const scenario = new ReadModelScenario(ContextDefaults, artifacts);
227
+ scenario.given.forEventSource('A').events(new ItemAdded(1));
228
+ expect(await scenario.instance).toMatchObject({ hash: '', causedByName: '[Not Set]' });
229
+ });
230
+
231
+ it('rejects a seeded generation different from the subscribed generation before mapping', async () => {
232
+ class LaterItemAdded { @field(Number) oldAmount = 42; }
233
+ eventType('scenario-item-added', 2)(LaterItemAdded);
234
+ class GenerationState { @field(String) id = ''; @field(Number) count = 0; }
235
+ fromEvent(ItemAdded)(GenerationState);
236
+ setFrom(ItemAdded, 'amount')(GenerationState.prototype, 'count');
237
+ const scenario = new ReadModelScenario(GenerationState, { ...artifacts, eventTypes: [...artifacts.eventTypes, LaterItemAdded] });
238
+ scenario.given.forEventSource('source-a').events(new LaterItemAdded());
239
+ await expect(scenario.instance).rejects.toThrow(UnsupportedProjectionOperation);
240
+ await expect(scenario.instance).rejects.toThrow(/GenerationState.*From\[scenario-item-added:1\].*seeded event generation 2/);
241
+ });
242
+
243
+ it('rejects a seeded generation different from a removal subscription', async () => {
244
+ class LaterItemRemoved {}
245
+ eventType('scenario-item-removed', 2)(LaterItemRemoved);
246
+ class GenerationState { @field(String) id = ''; @field(Number) count = 0; }
247
+ fromEvent(ItemAdded)(GenerationState);
248
+ removedWith(ItemRemoved)(GenerationState);
249
+ const scenario = new ReadModelScenario(GenerationState, { ...artifacts, eventTypes: [...artifacts.eventTypes, LaterItemRemoved] });
250
+ scenario.given.forEventSource('source-a').events(new LaterItemRemoved());
251
+ await expect(scenario.wasDeletedForEventSourceId('source-a')).rejects.toThrow(/RemovedWith\[scenario-item-removed:1\].*seeded event generation 2/);
252
+ });
253
+
254
+ it('selects a declarative projection using its final compiled contract', async () => {
255
+ class FluentState { @field(String) id = ''; @field(Number) count = 0; }
256
+ class FluentProjection implements IProjectionFor<FluentState> {
257
+ define(builder: IProjectionBuilderFor<FluentState>): void {
258
+ builder.from(ItemAdded, from => from.set(model => model.count).to(event => event.amount));
259
+ }
260
+ }
187
261
  projection('scenario-fluent-projection', FluentState)(FluentProjection);
188
- expect(() => new ReadModelScenario(FluentState, { ...artifacts, projections: [FluentProjection] }))
189
- .toThrow('not supported yet; use a kernel-backed test');
262
+ const scenario = new ReadModelScenario(FluentState, { ...artifacts, projections: [FluentProjection] });
263
+ scenario.given.forEventSource('A').events(new ItemAdded(8));
264
+ expect(await scenario.instance).toMatchObject({ count: 8 });
265
+ });
266
+
267
+ it('does not infer an unbound projection against an incomplete isolated read-model catalog', () => {
268
+ class AmbiguousState { @field(Number) count = 0; }
269
+ class AlsoMatching { @field(Number) count = 0; }
270
+ readModel()(AmbiguousState);
271
+ readModel()(AlsoMatching);
272
+ class UnboundProjection implements IProjectionFor<AmbiguousState> {
273
+ define(builder: IProjectionBuilderFor<AmbiguousState>): void {
274
+ builder.from(ItemAdded, from => from.set(model => model.count).to(event => event.amount));
275
+ }
276
+ }
277
+ projection('unbound-scenario-projection')(UnboundProjection);
278
+ expect(() => new ReadModelScenario(AmbiguousState, { ...artifacts, projections: [UnboundProjection] }))
279
+ .toThrow('No reducer or projection');
280
+ expect(() => new ReadModelScenario(AmbiguousState, {
281
+ ...artifacts, projections: [UnboundProjection], readModels: [AmbiguousState, AlsoMatching]
282
+ })).toThrow('found 0');
283
+ });
284
+
285
+ it('includes the read-model name and original error when projection replay fails', async () => {
286
+ class NumericState { @field(Guid) id!: Guid; }
287
+ fromEvent(ItemAdded)(NumericState);
288
+ const scenario = new ReadModelScenario(NumericState, artifacts);
289
+ scenario.given.forEventSource('01').events(new ItemAdded(1));
290
+ await expect(scenario.instance).rejects.toMatchObject({
291
+ message: expect.stringContaining('NumericState'),
292
+ cause: expect.objectContaining({ message: expect.stringContaining('Projection GUID value') })
293
+ });
294
+ });
295
+
296
+ it('rejects a protected identifier before any events are seeded', () => {
297
+ class ProtectedState { @field(String) id = ''; }
298
+ pii()(ProtectedState.prototype, 'id');
299
+ fromEvent(ItemAdded)(ProtectedState);
300
+ expect(() => new ReadModelScenario(ProtectedState, artifacts))
301
+ .toThrow(/ReadModel.Schema.id.*protected fields require a kernel-backed test/);
302
+ });
303
+
304
+ it('rejects unsupported mappings before replay even when no events are seeded', () => {
305
+ class CustomKeyState { @field(String) id = ''; @field(Number) count = 0; }
306
+ fromEvent(ItemAdded, { key: 'amount' })(CustomKeyState);
307
+ expect(() => new ReadModelScenario(CustomKeyState, artifacts)).toThrow('only $eventSourceId keys are supported');
308
+ });
309
+
310
+ it('rejects noncanonical GUID source IDs instead of merging independent histories', async () => {
311
+ class NumericState { @field(Guid) id!: Guid; }
312
+ fromEvent(ItemAdded)(NumericState);
313
+ const scenario = new ReadModelScenario(NumericState, artifacts);
314
+ scenario.given.forEventSource('01').events(new ItemAdded(1));
315
+ await expect(scenario.instance).rejects.toThrow('Projection GUID value');
316
+ });
317
+
318
+ it('keeps reducer precedence when a projection also applies', async () => {
319
+ class DualState { @field(Number) count = 0; }
320
+ class DualReducer { itemAdded(event: ItemAdded): DualState { return { count: event.amount }; } }
321
+ reducer('scenario-dual-reducer', undefined, DualState)(DualReducer);
322
+ fromEvent(ItemAdded)(DualState);
323
+ const scenario = new ReadModelScenario(DualState, { ...artifacts, reducers: [DualReducer] });
324
+ scenario.given.forEventSource('A').events(new ItemAdded(4));
325
+ expect(await scenario.instance).toEqual({ count: 4 });
326
+ });
327
+
328
+ it('rejects multiple applicable projections before replay', () => {
329
+ class FluentState { @field(Number) count = 0; }
330
+ class FirstProjection implements IProjectionFor<FluentState> { define(): void {} }
331
+ class SecondProjection implements IProjectionFor<FluentState> { define(): void {} }
332
+ projection('first-projection', FluentState)(FirstProjection);
333
+ projection('second-projection', FluentState)(SecondProjection);
334
+ expect(() => new ReadModelScenario(FluentState, { ...artifacts, projections: [FirstProjection, SecondProjection] }))
335
+ .toThrow('Multiple projections found');
190
336
  });
191
337
  });