@cratis/chronicle 6.14.1 → 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.
Files changed (33) 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 +355 -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/modelBound/childrenAndNestedBuilder.d.ts.map +1 -1
  18. package/dist/projections/modelBound/childrenAndNestedBuilder.js +7 -3
  19. package/dist/projections/modelBound/childrenAndNestedBuilder.js.map +1 -1
  20. package/dist/projections/modelBound/isModelBoundProjection.d.ts +3 -0
  21. package/dist/projections/modelBound/isModelBoundProjection.d.ts.map +1 -0
  22. package/dist/projections/modelBound/isModelBoundProjection.js +9 -0
  23. package/dist/projections/modelBound/isModelBoundProjection.js.map +1 -0
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +1 -1
  26. package/projections/CompiledProjectionDefinitions.ts +13 -0
  27. package/projections/ProjectionDefinitionCompiler.registration.spec.ts +279 -0
  28. package/projections/ProjectionDefinitionCompiler.ts +386 -0
  29. package/projections/Projections.childrenAndNested.spec.ts +47 -2
  30. package/projections/Projections.ts +14 -413
  31. package/projections/ResolvedModelBoundMetadata.ts +11 -0
  32. package/projections/modelBound/childrenAndNestedBuilder.ts +7 -3
  33. package/projections/modelBound/isModelBoundProjection.ts +10 -0
@@ -2,72 +2,29 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { diag } from '@opentelemetry/api';
5
- import {
6
- AutoMap,
7
- ProjectionOwner,
8
- ReadModelObserverType
9
- } from '@cratis/chronicle.contracts';
5
+ import { ProjectionOwner, type ProjectionDefinition } from '@cratis/chronicle.contracts';
10
6
  import { Constructor, Guid } from '@cratis/fundamentals';
11
7
  import { IClientArtifactsProvider } from '../artifacts/index.js';
12
8
  import { ChronicleConnection } from '../connection/index.js';
13
- import { WellKnownSinks } from '../sinks/index.js';
14
9
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
15
10
  import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
16
11
  import { JobId } from '../jobs/JobId.js';
17
12
  import { FailedPartition } from '../observation/FailedPartition.js';
18
13
  import { FailedPartitions } from '../observation/FailedPartitions.js';
19
14
  import { toObserverRunningState } from '../observation/toObserverRunningState.js';
20
- import { getReadModelMetadata } from '../readModels/index.js';
21
15
  import { getReadModelId } from '../readModels/readModel.js';
22
- import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
23
16
  import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
24
17
  import { rootReadModelTypes } from '../readModels/rootReadModelTypes.js';
25
- import { JsonSchemaGenerator } from '../schemas/index.js';
26
- import { TypeIntrospector } from '../types/index.js';
27
- import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
28
18
  import { IProjections } from './IProjections.js';
29
- import { constantValueExpression } from './constantValueExpression.js';
30
19
  import { getProjectionMetadata } from './declarative/projection.js';
31
- import { ProjectionBuilderFor } from './declarative/ProjectionBuilderFor.js';
32
- import type { IProjectionFor } from './declarative/IProjectionFor.js';
33
- import {
34
- applyPropertyMappings,
35
- buildChildrenEntry,
36
- buildNestedEntry,
37
- ChildrenDefinitionLike,
38
- ContractEventType,
39
- ensureFromEntry,
40
- FromRecord,
41
- getEventTypeMapKey,
42
- toContractEventType
43
- } from './modelBound/childrenAndNestedBuilder.js';
44
- import { getChildrenFromMetadata } from './modelBound/childrenFrom.js';
45
- import { getClearWithPropertyMetadata } from './modelBound/clearWith.js';
46
20
  import { getEventSequenceMetadata } from './modelBound/eventSequence.js';
47
- import { getFromAllMetadata } from './modelBound/fromAll.js';
48
- import { getFromEveryMetadata } from './modelBound/fromEvery.js';
49
- import { getFromEventMetadata, hasFromEventMetadata } from './modelBound/fromEvent.js';
50
- import { getJoinMetadata } from './modelBound/join.js';
51
- import { isNoAutoMap, isPropertyNoAutoMap } from './modelBound/noAutoMap.js';
21
+ import { isModelBoundProjection } from './modelBound/isModelBoundProjection.js';
52
22
  import { ProjectionId } from './ProjectionId.js';
53
23
  import { ProjectionQueryResult } from './ProjectionQueryResult.js';
54
24
  import { ProjectionState } from './ProjectionState.js';
55
- import { isNested } from './modelBound/nested.js';
56
- import { isNotRewindable } from './modelBound/notRewindable.js';
57
- import { isPassive } from './modelBound/passive.js';
58
- import { getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './modelBound/removedWith.js';
59
- import { getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './modelBound/removedWithJoin.js';
60
- import { getVariantOfMetadata } from './modelBound/variantOf.js';
61
- import { getEntersOnMetadata } from './modelBound/entersOn.js';
62
- import { getGlobalForMetadata } from './modelBound/globalFor.js';
25
+ import { ProjectionDefinitionCompiler } from './ProjectionDefinitionCompiler.js';
26
+ import type { ResolvedModelBoundMetadata } from './ResolvedModelBoundMetadata.js';
63
27
  import { UnableToQueryProjection } from './UnableToQueryProjection.js';
64
- import { BuiltProjection, crossWireGroups, mergeGlobalHandlers, reclassify, VariantDeclaration } from './VariantReclassifier.js';
65
-
66
- interface ResolvedModelBoundMetadata {
67
- id: ProjectionId;
68
- eventSequenceId: string | undefined;
69
- readModelIdentifier: string;
70
- }
71
28
 
72
29
  /**
73
30
  * Implements {@link IProjections}, managing discovery and registration of projections
@@ -116,7 +73,7 @@ export class Projections implements IProjections {
116
73
  }
117
74
 
118
75
  for (const type of readModelTypes) {
119
- if (!hasFromEventMetadata(type) && !hasModelBoundProperties(type)) {
76
+ if (!isModelBoundProjection(type)) {
120
77
  continue;
121
78
  }
122
79
 
@@ -147,27 +104,16 @@ export class Projections implements IProjections {
147
104
  .map(type => getProjectionMetadata(type)?.readModelType)
148
105
  .filter((type): type is Constructor => type !== undefined);
149
106
  assertUniqueReadModelIds([...rootReadModelTypes(this._clientArtifacts), ...inferred]);
150
- const builtProjections: BuiltProjection[] = [
151
- ...Array.from(this._declarative.values()).map(type => this.buildDeclarativeDefinition(type)),
152
- ...Array.from(this._modelBound.values()).map(type => this.buildModelBoundDefinition(type))
153
- ];
154
-
155
- // Cross-wiring can only run once every variant in this registration call is known, so it
156
- // runs here rather than as each definition is built - and LastUpdated is computed only
157
- // afterward, so a RemovedWith entry it adds is reflected in the hash sent to the kernel.
158
- crossWireGroups(builtProjections);
159
- for (const built of builtProjections) {
160
- built.definition.LastUpdated = { Value: this.computeStableLastUpdated(built.definition) };
161
- }
162
-
163
- const projections = builtProjections.map(built => built.definition);
107
+ const compiled = new ProjectionDefinitionCompiler(this._clientArtifacts, this._defaultSinkTypeId)
108
+ .compile(this._declarative.values(), this._modelBound.values());
109
+ const projections = compiled.definitions;
164
110
 
165
111
  if (projections.length === 0) {
166
112
  this._logger.info('No projections to register');
167
113
  return;
168
114
  }
169
115
 
170
- const readModels = this.buildReadModelDefinitions(projections);
116
+ const readModels = compiled.readModels;
171
117
  if (readModels.length > 0) {
172
118
  await this._connection.readModels.registerMany({
173
119
  EventStore: this._eventStore,
@@ -178,7 +124,7 @@ export class Projections implements IProjections {
178
124
  }
179
125
 
180
126
  for (const projection of projections) {
181
- const identifier = String((projection as { Identifier?: unknown }).Identifier ?? '<unknown>');
127
+ const identifier = String(projection.Identifier ?? '<unknown>');
182
128
  await this.registerWithRetry(projection, identifier);
183
129
  }
184
130
  }
@@ -325,15 +271,15 @@ export class Projections implements IProjections {
325
271
  return EventSequenceId.eventLog.value;
326
272
  }
327
273
 
328
- private async registerWithRetry(projection: unknown, identifier: string, maxAttempts = 5): Promise<void> {
274
+ private async registerWithRetry(projection: ProjectionDefinition, identifier: string, maxAttempts = 5): Promise<void> {
329
275
  let delay = 2000;
330
276
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
331
277
  try {
332
- this._logger.info('Registering projection', { identifier, readModel: (projection as any).ReadModel, attempt });
278
+ this._logger.info('Registering projection', { identifier, readModel: projection.ReadModel, attempt });
333
279
  await this._connection.projections.register({
334
280
  EventStore: this._eventStore,
335
281
  Owner: ProjectionOwner.PROJECTION_OWNER_Client,
336
- Projections: [projection as any]
282
+ Projections: [projection]
337
283
  });
338
284
  this._logger.info('Projection registered successfully', { identifier });
339
285
  return;
@@ -355,314 +301,8 @@ export class Projections implements IProjections {
355
301
  }
356
302
  }
357
303
 
358
- private buildReadModelDefinitions(projections: any[]): any[] {
359
- const byReadModel = new Map<string, any>();
360
-
361
- for (const projection of projections) {
362
- const readModelIdentifier = projection.ReadModel;
363
- if (!readModelIdentifier) {
364
- continue;
365
- }
366
- const existing = byReadModel.get(readModelIdentifier);
367
- if (existing) {
368
- if (existing.ObserverIdentifier !== projection.Identifier) {
369
- throw new Error(`Read model id '${readModelIdentifier}' has multiple projections.`);
370
- }
371
- continue;
372
- }
373
-
374
- const readModelType = this.getReadModelType(readModelIdentifier);
375
- byReadModel.set(readModelIdentifier, buildReadModelDefinition({
376
- identifier: readModelIdentifier,
377
- type: readModelType,
378
- schema: readModelType
379
- ? JSON.stringify(getReadModelMetadata(readModelType)?.schema ?? JsonSchemaGenerator.generate(readModelType))
380
- : '{}',
381
- // Passive projections never write to a materialized sink, so they register with
382
- // the None sink. This lets the kernel fall through to immediate projection when
383
- // resolving the instance by key instead of reading an empty sink and returning null.
384
- sinkTypeId: projection.IsActive === false ? WellKnownSinks.None : this._defaultSinkTypeId,
385
- observerType: ReadModelObserverType.Projection,
386
- observerIdentifier: projection.Identifier
387
- }));
388
- }
389
-
390
- return Array.from(byReadModel.values());
391
- }
392
-
393
- private getReadModelType(readModelIdentifier: string): Constructor | undefined {
394
- const types = [
395
- ...rootReadModelTypes(this._clientArtifacts),
396
- ...this._clientArtifacts.projections
397
- .map(projectionType => getProjectionMetadata(projectionType)?.readModelType)
398
- .filter((type): type is Constructor => type !== undefined)
399
- ];
400
- for (const type of types) {
401
- if (getReadModelId(type) === readModelIdentifier) {
402
- return type;
403
- }
404
- }
405
-
406
- return undefined;
407
- }
408
-
409
- private buildDeclarativeDefinition(type: Constructor): BuiltProjection {
410
- const metadata = getProjectionMetadata(type);
411
- if (!metadata) {
412
- throw new Error(`Type '${type.name}' is missing declarative projection metadata.`);
413
- }
414
-
415
- const builder = new ProjectionBuilderFor<unknown>();
416
- const instance = new type() as IProjectionFor<unknown>;
417
- instance.define(builder);
418
- const definition = builder.build(metadata.id.value, type.name) as Record<string, unknown>;
419
-
420
- const explicitReadModelIdentifier = definition.ReadModel as string;
421
- if (explicitReadModelIdentifier === type.name) {
422
- // Use explicit readModelType from decorator if provided
423
- if (metadata.readModelType) {
424
- definition.ReadModel = getReadModelId(metadata.readModelType);
425
- } else {
426
- const inferredReadModelIdentifier = this.inferReadModelIdentifier(builder.getMappedReadModelProperties());
427
- if (inferredReadModelIdentifier) {
428
- definition.ReadModel = inferredReadModelIdentifier;
429
- }
430
- }
431
- }
432
-
433
- let variant: VariantDeclaration | undefined;
434
- const variantDeclaration = builder.getVariantDeclaration();
435
- if (variantDeclaration) {
436
- const reclassified = reclassify(
437
- type.name,
438
- definition.From as any,
439
- definition.Join as any,
440
- variantDeclaration.enteringEventTypes,
441
- variantDeclaration.key);
442
- definition.From = reclassified.from;
443
- definition.Join = reclassified.join;
444
- variant = variantDeclaration;
445
- }
446
-
447
- return { typeName: type.name, definition, variant };
448
- }
449
-
450
- private inferReadModelIdentifier(mappedProperties: string[]): string | undefined {
451
- if (mappedProperties.length === 0) {
452
- return undefined;
453
- }
454
-
455
- const matchingReadModels = rootReadModelTypes(this._clientArtifacts)
456
- .map(type => ({ type, metadata: getReadModelMetadata(type) }))
457
- .filter(candidate => candidate.metadata)
458
- .filter(candidate => {
459
- const readModelProperties = Array.from(candidate.metadata!.members.keys());
460
- return mappedProperties.every(property => readModelProperties.includes(property));
461
- });
462
-
463
- if (matchingReadModels.length !== 1) {
464
- return undefined;
465
- }
466
-
467
- return matchingReadModels[0].metadata!.id.value;
468
- }
469
-
470
- private buildModelBoundDefinition(type: Constructor): BuiltProjection {
471
- const metadata = this.resolveModelBoundMetadata(type);
472
- if (!metadata) {
473
- throw new Error(`Type '${type.name}' is missing model-bound projection metadata.`);
474
- }
475
-
476
- const properties = TypeIntrospector.getTrackedProperties(type);
477
- const prototype = type.prototype;
478
-
479
- const fromByEventType = new Map<string, FromRecord>();
480
- const joinByEventType = new Map<string, { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } }>();
481
- const removedWithByEventType = new Map<string, { Key: ContractEventType; Value: { Key: string; ParentKey: string } }>();
482
- const removedWithJoinByEventType = new Map<string, { Key: ContractEventType; Value: { Key: string } }>();
483
- const childrenByProperty: Record<string, ChildrenDefinitionLike> = {};
484
- const nestedByProperty: Record<string, ChildrenDefinitionLike> = {};
485
-
486
- const fromEvents = getFromEventMetadata(type);
487
- for (const fromEvent of fromEvents) {
488
- const eventType = toContractEventType(fromEvent.eventType);
489
- const eventKey = getEventTypeMapKey(eventType);
490
- fromByEventType.set(eventKey, {
491
- Key: eventType,
492
- Value: {
493
- Properties: {},
494
- Key: fromEvent.constantKey ? constantValueExpression(fromEvent.constantKey) : (fromEvent.key ?? '$eventSourceId'),
495
- ParentKey: fromEvent.parentKey ?? ''
496
- }
497
- });
498
- }
499
-
500
- const removedWithClass = getRemovedWithClassMetadata(type);
501
- for (const removed of removedWithClass) {
502
- const eventType = toContractEventType(removed.eventType);
503
- removedWithByEventType.set(getEventTypeMapKey(eventType), {
504
- Key: eventType,
505
- Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' }
506
- });
507
- }
508
-
509
- const removedWithJoinClass = getRemovedWithJoinClassMetadata(type);
510
- for (const removed of removedWithJoinClass) {
511
- const eventType = toContractEventType(removed.eventType);
512
- removedWithJoinByEventType.set(getEventTypeMapKey(eventType), {
513
- Key: eventType,
514
- Value: { Key: removed.key ?? '$eventSourceId' }
515
- });
516
- }
517
-
518
- for (const property of properties) {
519
- applyPropertyMappings(prototype, property, fromByEventType);
520
-
521
- for (const mapping of getJoinMetadata(prototype, property)) {
522
- const entry = this.ensureJoinEntry(joinByEventType, mapping.eventType);
523
- entry.Value.On = mapping.on ?? (entry.Value.On || property);
524
- entry.Value.Properties[property] = mapping.eventPropertyName ?? property;
525
- }
526
-
527
- for (const removed of getRemovedWithPropertyMetadata(prototype, property)) {
528
- const eventType = toContractEventType(removed.eventType);
529
- removedWithByEventType.set(getEventTypeMapKey(eventType), {
530
- Key: eventType,
531
- Value: { Key: removed.key ?? '$eventSourceId', ParentKey: removed.parentKey ?? '' }
532
- });
533
- }
534
-
535
- for (const removed of getRemovedWithJoinPropertyMetadata(prototype, property)) {
536
- const eventType = toContractEventType(removed.eventType);
537
- removedWithJoinByEventType.set(getEventTypeMapKey(eventType), {
538
- Key: eventType,
539
- Value: { Key: removed.key ?? '$eventSourceId' }
540
- });
541
- }
542
-
543
- const childrenFromList = getChildrenFromMetadata(prototype, property);
544
- if (childrenFromList.length > 0) {
545
- childrenByProperty[property] = buildChildrenEntry(type, property, childrenFromList);
546
- }
547
-
548
- const propertyIsNested = isNested(prototype, property);
549
- if (propertyIsNested) {
550
- nestedByProperty[property] = buildNestedEntry(type, property);
551
- }
552
-
553
- // A scalar (non-nested, non-children-collection) root property clears back to no value
554
- // every time the given event is observed. A nested single-object property's clearWith is
555
- // handled by buildNestedEntry instead, which clears the whole nested object.
556
- if (childrenFromList.length === 0 && !propertyIsNested) {
557
- for (const clearWith of getClearWithPropertyMetadata(prototype, property)) {
558
- const entry = ensureFromEntry(fromByEventType, clearWith.eventType);
559
- entry.Value.Properties[property] = '$null';
560
- }
561
- }
562
- }
563
-
564
- const allProperties: Record<string, string> = {};
565
- for (const property of properties) {
566
- const fromEvery = getFromEveryMetadata(prototype, property) ?? getFromAllMetadata(prototype, property);
567
- if (fromEvery) {
568
- allProperties[property] = fromEvery.contextProperty
569
- ? fromEvery.contextProperty
570
- : (fromEvery.property ?? property);
571
- }
572
- }
573
-
574
- let from = Array.from(fromByEventType.values());
575
- let join = Array.from(joinByEventType.values());
576
- let variant: VariantDeclaration | undefined;
577
-
578
- const variantMetadata = getVariantOfMetadata(type);
579
- if (variantMetadata) {
580
- const entersOnList = getEntersOnMetadata(type);
581
- const enteringEventTypes = entersOnList.map(entersOn => {
582
- const contractType = toContractEventType(entersOn.eventType);
583
- if (entersOn.key) {
584
- const entry = ensureFromEntry(fromByEventType, entersOn.eventType);
585
- entry.Value.Key = entersOn.key;
586
- }
587
- return contractType;
588
- });
589
- from = Array.from(fromByEventType.values());
590
-
591
- const globalHandlers = new Map<string, FromRecord[]>();
592
- for (const handlerType of this._clientArtifacts.globalForHandlers) {
593
- const globalForMetadata = getGlobalForMetadata(handlerType);
594
- if (globalForMetadata?.identity === variantMetadata.identity) {
595
- globalHandlers.set(handlerType.name, this.buildFromRecordsForType(handlerType));
596
- }
597
- }
598
-
599
- const memberNames = new Set(getReadModelMetadata(type)?.members.keys() ?? TypeIntrospector.getMembers(type).keys());
600
- const merged = mergeGlobalHandlers(type.name, memberNames, from, globalHandlers);
601
- const reclassified = reclassify(type.name, merged, join, enteringEventTypes, variantMetadata.key);
602
- from = reclassified.from;
603
- join = reclassified.join;
604
- variant = { identity: variantMetadata.identity, key: variantMetadata.key, enteringEventTypes };
605
- }
606
-
607
- const definition: Record<string, unknown> = {
608
- EventSequenceId: metadata.eventSequenceId ?? EventSequenceId.eventLog.value,
609
- Identifier: metadata.id.value,
610
- ReadModel: metadata.readModelIdentifier,
611
- IsActive: !isPassive(type),
612
- IsRewindable: !isNotRewindable(type),
613
- InitialModelState: '{}',
614
- From: from,
615
- Join: join,
616
- Children: childrenByProperty,
617
- FromEvery: [],
618
- All: {
619
- Properties: allProperties,
620
- IncludeChildren: false,
621
- AutoMap: AutoMap.Inherit
622
- },
623
- RemovedWith: Array.from(removedWithByEventType.values()),
624
- RemovedWithJoin: Array.from(removedWithJoinByEventType.values()),
625
- LastUpdated: { Value: '' },
626
- Tags: [],
627
- AutoMap: isNoAutoMap(type) ? AutoMap.Disabled : AutoMap.Enabled,
628
- NoAutoMapProperties: properties.filter(property => isPropertyNoAutoMap(prototype, property)),
629
- Nested: nestedByProperty
630
- };
631
- return { typeName: type.name, definition, variant };
632
- }
633
-
634
- /**
635
- * Builds the raw From records for an arbitrary type using the same property-mapping rules as
636
- * a model-bound read model - used to fold a globalFor shared handler's mappings into every
637
- * variant of its identity.
638
- * @param type - The type to build From records for.
639
- * @returns The built From records.
640
- */
641
- private buildFromRecordsForType(type: Constructor): FromRecord[] {
642
- const fromByEventType = new Map<string, FromRecord>();
643
- const fromEvents = getFromEventMetadata(type);
644
- for (const fromEvent of fromEvents) {
645
- const eventType = toContractEventType(fromEvent.eventType);
646
- fromByEventType.set(getEventTypeMapKey(eventType), {
647
- Key: eventType,
648
- Value: {
649
- Properties: {},
650
- Key: fromEvent.constantKey ? constantValueExpression(fromEvent.constantKey) : (fromEvent.key ?? '$eventSourceId'),
651
- ParentKey: fromEvent.parentKey ?? ''
652
- }
653
- });
654
- }
655
-
656
- const prototype = type.prototype;
657
- for (const property of TypeIntrospector.getTrackedProperties(type)) {
658
- applyPropertyMappings(prototype, property, fromByEventType);
659
- }
660
-
661
- return Array.from(fromByEventType.values());
662
- }
663
-
664
304
  private resolveModelBoundMetadata(type: Constructor): ResolvedModelBoundMetadata | undefined {
665
- if (hasFromEventMetadata(type) || hasModelBoundProperties(type)) {
305
+ if (isModelBoundProjection(type)) {
666
306
  const identifier = getReadModelId(type);
667
307
  return {
668
308
  id: new ProjectionId(identifier),
@@ -673,43 +313,4 @@ export class Projections implements IProjections {
673
313
 
674
314
  return undefined;
675
315
  }
676
-
677
- private ensureJoinEntry(
678
- joinByEventType: Map<string, { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } }>,
679
- eventTypeConstructor: Function
680
- ): { Key: ContractEventType; Value: { On: string; Properties: Record<string, string>; Key: string } } {
681
- const eventType = toContractEventType(eventTypeConstructor);
682
- const key = getEventTypeMapKey(eventType);
683
- const existing = joinByEventType.get(key);
684
- if (existing) {
685
- return existing;
686
- }
687
-
688
- const created = {
689
- Key: eventType,
690
- Value: {
691
- On: '',
692
- Properties: {},
693
- Key: '$eventSourceId'
694
- }
695
- };
696
- joinByEventType.set(key, created);
697
- return created;
698
- }
699
-
700
- /**
701
- * Computes a stable, deterministic ISO timestamp from the projection definition content,
702
- * excluding the LastUpdated field itself. This ensures the server does not interpret
703
- * a repeated registration of an unchanged definition as a definition change, which
704
- * would otherwise trigger an unnecessary auto-replay.
705
- */
706
- private computeStableLastUpdated(definition: Record<string, unknown>): string {
707
- const { LastUpdated: _omit, ...rest } = definition;
708
- const content = JSON.stringify(rest, Object.keys(rest).sort());
709
- let hash = 5381;
710
- for (let i = 0; i < content.length; i++) {
711
- hash = ((hash << 5) + hash + content.charCodeAt(i)) >>> 0;
712
- }
713
- return new Date(hash * 1000).toISOString();
714
- }
715
316
  }
@@ -0,0 +1,11 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import type { ProjectionId } from './ProjectionId.js';
5
+
6
+ /** Metadata resolved during model-bound projection discovery. */
7
+ export interface ResolvedModelBoundMetadata {
8
+ id: ProjectionId;
9
+ eventSequenceId: string | undefined;
10
+ readModelIdentifier: string;
11
+ }
@@ -274,9 +274,13 @@ function populateFromType(definition: ChildrenDefinitionLike, childType: Functio
274
274
  for (const property of TypeIntrospector.getTrackedProperties(childType)) {
275
275
  applyPropertyMappings(prototype, property, fromByEventType);
276
276
 
277
- for (const clearWith of getClearWithPropertyMetadata(prototype, property)) {
278
- const eventType = toContractEventType(clearWith.eventType);
279
- removedWithByEventType.set(getEventTypeMapKey(eventType), { Key: eventType, Value: { Key: '$eventSourceId', ParentKey: '' } });
277
+ // A clear on the member carrying @nested removes that nested object; a clear on
278
+ // any other member maps the member to null in this child/nested definition.
279
+ if (!isNested(prototype, property)) {
280
+ for (const clearWith of getClearWithPropertyMetadata(prototype, property)) {
281
+ const entry = ensureFromEntry(fromByEventType, clearWith.eventType);
282
+ entry.Value.Properties[property] = '$null';
283
+ }
280
284
  }
281
285
 
282
286
  for (const removed of getRemovedWithPropertyMetadata(prototype, property)) {
@@ -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 { hasModelBoundProperties } from '../../types/TypeDiscoverer.js';
5
+ import { hasFromEventMetadata } from './fromEvent.js';
6
+
7
+ /** Returns whether a read model declares model-bound projection metadata. */
8
+ export function isModelBoundProjection(type: Function): boolean {
9
+ return hasFromEventMetadata(type) || hasModelBoundProperties(type);
10
+ }