@cratis/chronicle 6.1.1 → 6.2.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 (77) hide show
  1. package/artifacts/DefaultClientArtifactsProvider.ts +5 -0
  2. package/artifacts/IClientArtifactsProvider.ts +3 -0
  3. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts +2 -0
  4. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
  5. package/dist/artifacts/DefaultClientArtifactsProvider.js +4 -0
  6. package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
  7. package/dist/artifacts/IClientArtifactsProvider.d.ts +2 -0
  8. package/dist/artifacts/IClientArtifactsProvider.d.ts.map +1 -1
  9. package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts +14 -0
  10. package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts.map +1 -0
  11. package/dist/projections/GlobalHandlerPropertyNotOnVariant.js +19 -0
  12. package/dist/projections/GlobalHandlerPropertyNotOnVariant.js.map +1 -0
  13. package/dist/projections/Projections.d.ts +8 -0
  14. package/dist/projections/Projections.d.ts.map +1 -1
  15. package/dist/projections/Projections.js +81 -7
  16. package/dist/projections/Projections.js.map +1 -1
  17. package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts +11 -0
  18. package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts.map +1 -0
  19. package/dist/projections/VariantMustDeclareEntersOnEvent.js +16 -0
  20. package/dist/projections/VariantMustDeclareEntersOnEvent.js.map +1 -0
  21. package/dist/projections/VariantReclassifier.d.ts +63 -0
  22. package/dist/projections/VariantReclassifier.d.ts.map +1 -0
  23. package/dist/projections/VariantReclassifier.js +126 -0
  24. package/dist/projections/VariantReclassifier.js.map +1 -0
  25. package/dist/projections/declarative/IProjectionBuilderFor.d.ts +20 -0
  26. package/dist/projections/declarative/IProjectionBuilderFor.d.ts.map +1 -1
  27. package/dist/projections/declarative/ProjectionBuilderFor.d.ts +17 -1
  28. package/dist/projections/declarative/ProjectionBuilderFor.d.ts.map +1 -1
  29. package/dist/projections/declarative/ProjectionBuilderFor.js +34 -0
  30. package/dist/projections/declarative/ProjectionBuilderFor.js.map +1 -1
  31. package/dist/projections/index.d.ts +2 -0
  32. package/dist/projections/index.d.ts.map +1 -1
  33. package/dist/projections/index.js +2 -0
  34. package/dist/projections/index.js.map +1 -1
  35. package/dist/projections/modelBound/entersOn.d.ts +25 -0
  36. package/dist/projections/modelBound/entersOn.d.ts.map +1 -0
  37. package/dist/projections/modelBound/entersOn.js +28 -0
  38. package/dist/projections/modelBound/entersOn.js.map +1 -0
  39. package/dist/projections/modelBound/globalFor.d.ts +24 -0
  40. package/dist/projections/modelBound/globalFor.d.ts.map +1 -0
  41. package/dist/projections/modelBound/globalFor.js +31 -0
  42. package/dist/projections/modelBound/globalFor.js.map +1 -0
  43. package/dist/projections/modelBound/index.d.ts +6 -0
  44. package/dist/projections/modelBound/index.d.ts.map +1 -1
  45. package/dist/projections/modelBound/index.js +3 -0
  46. package/dist/projections/modelBound/index.js.map +1 -1
  47. package/dist/projections/modelBound/variantOf.d.ts +24 -0
  48. package/dist/projections/modelBound/variantOf.d.ts.map +1 -0
  49. package/dist/projections/modelBound/variantOf.js +27 -0
  50. package/dist/projections/modelBound/variantOf.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/types/DecoratorType.d.ts +3 -1
  53. package/dist/types/DecoratorType.d.ts.map +1 -1
  54. package/dist/types/DecoratorType.js +2 -0
  55. package/dist/types/DecoratorType.js.map +1 -1
  56. package/events/for_EventContext/when_receiving/with_kernel_metadata.spec.ts +1 -1
  57. package/package.json +1 -1
  58. package/projections/GlobalHandlerPropertyNotOnVariant.ts +19 -0
  59. package/projections/Projections.childrenAndNested.spec.ts +2 -1
  60. package/projections/Projections.declarativeVariants.spec.ts +157 -0
  61. package/projections/Projections.modelBoundCompleteness.spec.ts +2 -1
  62. package/projections/Projections.operationalSurface.spec.ts +2 -1
  63. package/projections/Projections.spec.ts +2 -1
  64. package/projections/Projections.ts +98 -9
  65. package/projections/Projections.variants.spec.ts +253 -0
  66. package/projections/VariantMustDeclareEntersOnEvent.ts +16 -0
  67. package/projections/VariantReclassifier.ts +177 -0
  68. package/projections/declarative/IProjectionBuilderFor.ts +22 -0
  69. package/projections/declarative/ProjectionBuilderFor.ts +40 -1
  70. package/projections/index.ts +2 -0
  71. package/projections/modelBound/entersOn.ts +39 -0
  72. package/projections/modelBound/globalFor.ts +41 -0
  73. package/projections/modelBound/index.ts +6 -0
  74. package/projections/modelBound/variantOf.ts +38 -0
  75. package/readModels/ReadModels.spec.ts +2 -1
  76. package/reducers/Reducers.spec.ts +2 -1
  77. package/types/DecoratorType.ts +4 -1
@@ -0,0 +1,157 @@
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 'reflect-metadata';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { IClientArtifactsProvider } from '../artifacts';
7
+ import { ChronicleConnection } from '../connection';
8
+ import { eventType } from '../events/eventTypeDecorator';
9
+ import { IProjectionBuilderFor } from './declarative/IProjectionBuilderFor';
10
+ import { IProjectionFor } from './declarative/IProjectionFor';
11
+ import { projection } from './declarative/projection';
12
+ import { Projections } from './Projections';
13
+ import { VariantMustDeclareEntersOnEvent } from './VariantMustDeclareEntersOnEvent';
14
+
15
+ class IssueCreated {
16
+ title!: string;
17
+ }
18
+ eventType()(IssueCreated);
19
+
20
+ class PullRequestCreated {
21
+ pullRequestUrl!: string;
22
+ }
23
+ eventType()(PullRequestCreated);
24
+
25
+ class BuildCompleted {
26
+ buildStatus!: string;
27
+ }
28
+ eventType()(BuildCompleted);
29
+
30
+ /** Anchors the logical identity shared by the two declarative variants below. */
31
+ class DecWorkItem {}
32
+
33
+ class DecBacklogItem {
34
+ id!: string;
35
+ title!: string;
36
+ }
37
+
38
+ class DecBacklogItemProjection implements IProjectionFor<DecBacklogItem> {
39
+ define(builder: IProjectionBuilderFor<DecBacklogItem>): void {
40
+ builder.variantOf(DecWorkItem, m => m.id).entersOn(IssueCreated);
41
+ }
42
+ }
43
+ projection('DecBacklogItem', DecBacklogItem)(DecBacklogItemProjection);
44
+
45
+ class DecPullRequestItem {
46
+ id!: string;
47
+ pullRequestUrl!: string;
48
+ buildStatus!: string;
49
+ }
50
+
51
+ class DecPullRequestItemProjection implements IProjectionFor<DecPullRequestItem> {
52
+ define(builder: IProjectionBuilderFor<DecPullRequestItem>): void {
53
+ builder
54
+ .variantOf(DecWorkItem, m => m.id)
55
+ .entersOn(PullRequestCreated)
56
+ .from(PullRequestCreated)
57
+ .from(BuildCompleted);
58
+ }
59
+ }
60
+ projection('DecPullRequestItem', DecPullRequestItem)(DecPullRequestItemProjection);
61
+
62
+ class DecUndeclaredVariant {
63
+ id!: string;
64
+ }
65
+
66
+ class DecUndeclaredVariantProjection implements IProjectionFor<DecUndeclaredVariant> {
67
+ define(builder: IProjectionBuilderFor<DecUndeclaredVariant>): void {
68
+ builder.variantOf(DecWorkItem, m => m.id).from(IssueCreated);
69
+ }
70
+ }
71
+ projection('DecUndeclaredVariant', DecUndeclaredVariant)(DecUndeclaredVariantProjection);
72
+
73
+ interface CapturedDefinition {
74
+ Identifier: string;
75
+ From: Array<{ Key: { Id: string }; Value: { Properties: Record<string, string>; Key: string } }>;
76
+ Join: Array<{ Key: { Id: string }; Value: { On: string; Properties: Record<string, string>; Key: string } }>;
77
+ RemovedWith: Array<{ Key: { Id: string }; Value: { Key: string; ParentKey: string } }>;
78
+ }
79
+
80
+ function createProjections(projections: (new (...args: unknown[]) => unknown)[]) {
81
+ const registerMock = vi.fn().mockResolvedValue(undefined);
82
+ const registerManyMock = vi.fn().mockResolvedValue(undefined);
83
+ const connection = {
84
+ readModels: { registerMany: registerManyMock },
85
+ projections: { register: registerMock }
86
+ } as unknown as ChronicleConnection;
87
+
88
+ const clientArtifacts: IClientArtifactsProvider = {
89
+ eventTypes: [],
90
+ readModels: [],
91
+ reactors: [],
92
+ reducers: [],
93
+ seeders: [],
94
+ constraints: [],
95
+ projections: projections as unknown as IClientArtifactsProvider['projections'],
96
+ webhooks: [],
97
+ eventTypeMigrations: [],
98
+ globalForHandlers: []
99
+ };
100
+
101
+ const projectionsInstance = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink');
102
+ return { projections: projectionsInstance, registerMock };
103
+ }
104
+
105
+ function definitionsFor(registerMock: ReturnType<typeof vi.fn>): Map<string, CapturedDefinition> {
106
+ const byIdentifier = new Map<string, CapturedDefinition>();
107
+ for (const call of registerMock.mock.calls) {
108
+ const request = call[0] as { Projections: CapturedDefinition[] };
109
+ for (const definition of request.Projections) {
110
+ byIdentifier.set(definition.Identifier, definition);
111
+ }
112
+ }
113
+ return byIdentifier;
114
+ }
115
+
116
+ describe('Projections', () => {
117
+ describe('when registering a declarative variant that projects from an event that is not its entering event', () => {
118
+ it('should reclassify it into an update-only self-referential join', async () => {
119
+ const { projections, registerMock } = createProjections([DecBacklogItemProjection, DecPullRequestItemProjection]);
120
+
121
+ await projections.register();
122
+
123
+ const definitions = definitionsFor(registerMock);
124
+ const pullRequestItem = definitions.get('DecPullRequestItem')!;
125
+
126
+ expect(pullRequestItem.From.some(entry => entry.Key.Id === 'BuildCompleted')).toBe(false);
127
+
128
+ const buildCompletedJoin = pullRequestItem.Join.find(entry => entry.Key.Id === 'BuildCompleted');
129
+ expect(buildCompletedJoin).toBeDefined();
130
+ expect(buildCompletedJoin!.Value.On).toBe('id');
131
+ expect(buildCompletedJoin!.Value.Key).toBe('$eventSourceId');
132
+ });
133
+ });
134
+
135
+ describe('when two declarative variants of the same identity are registered together', () => {
136
+ it('should cross-wire mutual exclusion between them', async () => {
137
+ const { projections, registerMock } = createProjections([DecBacklogItemProjection, DecPullRequestItemProjection]);
138
+
139
+ await projections.register();
140
+
141
+ const definitions = definitionsFor(registerMock);
142
+ const backlogItem = definitions.get('DecBacklogItem')!;
143
+ const pullRequestItem = definitions.get('DecPullRequestItem')!;
144
+
145
+ expect(backlogItem.RemovedWith.some(entry => entry.Key.Id === 'PullRequestCreated')).toBe(true);
146
+ expect(pullRequestItem.RemovedWith.some(entry => entry.Key.Id === 'IssueCreated')).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe('when a declarative variant does not declare an entersOn event', () => {
151
+ it('should throw VariantMustDeclareEntersOnEvent', async () => {
152
+ const { projections } = createProjections([DecUndeclaredVariantProjection]);
153
+
154
+ await expect(projections.register()).rejects.toThrow(VariantMustDeclareEntersOnEvent);
155
+ });
156
+ });
157
+ });
@@ -149,7 +149,8 @@ function createProjections(readModels: (new (...args: unknown[]) => unknown)[])
149
149
  constraints: [],
150
150
  projections: [],
151
151
  webhooks: [],
152
- eventTypeMigrations: []
152
+ eventTypeMigrations: [],
153
+ globalForHandlers: []
153
154
  };
154
155
 
155
156
  const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink');
@@ -82,7 +82,8 @@ async function createDiscoveredProjections() {
82
82
  constraints: [],
83
83
  projections: [DeclarativeSummaryProjection] as unknown as IClientArtifactsProvider['projections'],
84
84
  webhooks: [],
85
- eventTypeMigrations: []
85
+ eventTypeMigrations: [],
86
+ globalForHandlers: []
86
87
  };
87
88
 
88
89
  const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink');
@@ -82,7 +82,8 @@ function createProjections(readModels: (new (...args: unknown[]) => unknown)[])
82
82
  constraints: [],
83
83
  projections: [],
84
84
  webhooks: [],
85
- eventTypeMigrations: []
85
+ eventTypeMigrations: [],
86
+ globalForHandlers: []
86
87
  };
87
88
 
88
89
  const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink');
@@ -50,7 +50,11 @@ import { isNotRewindable } from './modelBound/notRewindable';
50
50
  import { isPassive } from './modelBound/passive';
51
51
  import { getRemovedWithClassMetadata, getRemovedWithPropertyMetadata } from './modelBound/removedWith';
52
52
  import { getRemovedWithJoinClassMetadata, getRemovedWithJoinPropertyMetadata } from './modelBound/removedWithJoin';
53
+ import { getVariantOfMetadata } from './modelBound/variantOf';
54
+ import { getEntersOnMetadata } from './modelBound/entersOn';
55
+ import { getGlobalForMetadata } from './modelBound/globalFor';
53
56
  import { UnableToQueryProjection } from './UnableToQueryProjection';
57
+ import { BuiltProjection, crossWireGroups, mergeGlobalHandlers, reclassify, VariantDeclaration } from './VariantReclassifier';
54
58
 
55
59
  interface ResolvedModelBoundMetadata {
56
60
  id: ProjectionId;
@@ -132,11 +136,21 @@ export class Projections implements IProjections {
132
136
 
133
137
  this._logger.info('Registering projections', { declarativeCount: this._declarative.size, modelBoundCount: this._modelBound.size });
134
138
 
135
- const projections = [
139
+ const builtProjections: BuiltProjection[] = [
136
140
  ...Array.from(this._declarative.values()).map(type => this.buildDeclarativeDefinition(type)),
137
141
  ...Array.from(this._modelBound.values()).map(type => this.buildModelBoundDefinition(type))
138
142
  ];
139
143
 
144
+ // Cross-wiring can only run once every variant in this registration call is known, so it
145
+ // runs here rather than as each definition is built - and LastUpdated is computed only
146
+ // afterward, so a RemovedWith entry it adds is reflected in the hash sent to the kernel.
147
+ crossWireGroups(builtProjections);
148
+ for (const built of builtProjections) {
149
+ built.definition.LastUpdated = { Value: this.computeStableLastUpdated(built.definition) };
150
+ }
151
+
152
+ const projections = builtProjections.map(built => built.definition);
153
+
140
154
  if (projections.length === 0) {
141
155
  this._logger.info('No projections to register');
142
156
  return;
@@ -380,7 +394,7 @@ export class Projections implements IProjections {
380
394
  return '{}';
381
395
  }
382
396
 
383
- private buildDeclarativeDefinition(type: Constructor): any {
397
+ private buildDeclarativeDefinition(type: Constructor): BuiltProjection {
384
398
  const metadata = getProjectionMetadata(type);
385
399
  if (!metadata) {
386
400
  throw new Error(`Type '${type.name}' is missing declarative projection metadata.`);
@@ -405,8 +419,21 @@ export class Projections implements IProjections {
405
419
  }
406
420
  }
407
421
 
408
- definition.LastUpdated = { Value: this.computeStableLastUpdated(definition) };
409
- return definition;
422
+ let variant: VariantDeclaration | undefined;
423
+ const variantDeclaration = builder.getVariantDeclaration();
424
+ if (variantDeclaration) {
425
+ const reclassified = reclassify(
426
+ type.name,
427
+ definition.From as any,
428
+ definition.Join as any,
429
+ variantDeclaration.enteringEventTypes,
430
+ variantDeclaration.key);
431
+ definition.From = reclassified.from;
432
+ definition.Join = reclassified.join;
433
+ variant = variantDeclaration;
434
+ }
435
+
436
+ return { typeName: type.name, definition, variant };
410
437
  }
411
438
 
412
439
  private inferReadModelIdentifier(mappedProperties: string[]): string | undefined {
@@ -429,7 +456,7 @@ export class Projections implements IProjections {
429
456
  return matchingReadModels[0].metadata!.id.value;
430
457
  }
431
458
 
432
- private buildModelBoundDefinition(type: Constructor): any {
459
+ private buildModelBoundDefinition(type: Constructor): BuiltProjection {
433
460
  const metadata = this.resolveModelBoundMetadata(type);
434
461
  if (!metadata) {
435
462
  throw new Error(`Type '${type.name}' is missing model-bound projection metadata.`);
@@ -533,6 +560,39 @@ export class Projections implements IProjections {
533
560
  }
534
561
  }
535
562
 
563
+ let from = Array.from(fromByEventType.values());
564
+ let join = Array.from(joinByEventType.values());
565
+ let variant: VariantDeclaration | undefined;
566
+
567
+ const variantMetadata = getVariantOfMetadata(type);
568
+ if (variantMetadata) {
569
+ const entersOnList = getEntersOnMetadata(type);
570
+ const enteringEventTypes = entersOnList.map(entersOn => {
571
+ const contractType = toContractEventType(entersOn.eventType);
572
+ if (entersOn.key) {
573
+ const entry = ensureFromEntry(fromByEventType, entersOn.eventType);
574
+ entry.Value.Key = entersOn.key;
575
+ }
576
+ return contractType;
577
+ });
578
+ from = Array.from(fromByEventType.values());
579
+
580
+ const globalHandlers = new Map<string, FromRecord[]>();
581
+ for (const handlerType of this._clientArtifacts.globalForHandlers) {
582
+ const globalForMetadata = getGlobalForMetadata(handlerType);
583
+ if (globalForMetadata?.identity === variantMetadata.identity) {
584
+ globalHandlers.set(handlerType.name, this.buildFromRecordsForType(handlerType));
585
+ }
586
+ }
587
+
588
+ const memberNames = new Set(getReadModelMetadata(type)?.members.keys() ?? properties);
589
+ const merged = mergeGlobalHandlers(type.name, memberNames, from, globalHandlers);
590
+ const reclassified = reclassify(type.name, merged, join, enteringEventTypes, variantMetadata.key);
591
+ from = reclassified.from;
592
+ join = reclassified.join;
593
+ variant = { identity: variantMetadata.identity, key: variantMetadata.key, enteringEventTypes };
594
+ }
595
+
536
596
  const definition: Record<string, unknown> = {
537
597
  EventSequenceId: metadata.eventSequenceId ?? EventSequenceId.eventLog.value,
538
598
  Identifier: metadata.id.value,
@@ -540,8 +600,8 @@ export class Projections implements IProjections {
540
600
  IsActive: !isPassive(type),
541
601
  IsRewindable: !isNotRewindable(type),
542
602
  InitialModelState: '{}',
543
- From: Array.from(fromByEventType.values()),
544
- Join: Array.from(joinByEventType.values()),
603
+ From: from,
604
+ Join: join,
545
605
  Children: childrenByProperty,
546
606
  FromEvery: [],
547
607
  All: {
@@ -557,8 +617,37 @@ export class Projections implements IProjections {
557
617
  NoAutoMapProperties: properties.filter(property => isPropertyNoAutoMap(prototype, property)),
558
618
  Nested: nestedByProperty
559
619
  };
560
- definition.LastUpdated = { Value: this.computeStableLastUpdated(definition) };
561
- return definition;
620
+ return { typeName: type.name, definition, variant };
621
+ }
622
+
623
+ /**
624
+ * Builds the raw From records for an arbitrary type using the same property-mapping rules as
625
+ * a model-bound read model - used to fold a globalFor shared handler's mappings into every
626
+ * variant of its identity.
627
+ * @param type - The type to build From records for.
628
+ * @returns The built From records.
629
+ */
630
+ private buildFromRecordsForType(type: Constructor): FromRecord[] {
631
+ const fromByEventType = new Map<string, FromRecord>();
632
+ const fromEvents = getFromEventMetadata(type);
633
+ for (const fromEvent of fromEvents) {
634
+ const eventType = toContractEventType(fromEvent.eventType);
635
+ fromByEventType.set(getEventTypeMapKey(eventType), {
636
+ Key: eventType,
637
+ Value: {
638
+ Properties: {},
639
+ Key: fromEvent.constantKey ?? fromEvent.key ?? '$eventSourceId',
640
+ ParentKey: fromEvent.parentKey ?? ''
641
+ }
642
+ });
643
+ }
644
+
645
+ const prototype = type.prototype;
646
+ for (const property of TypeIntrospector.getTrackedProperties(type)) {
647
+ applyPropertyMappings(prototype, property, fromByEventType);
648
+ }
649
+
650
+ return Array.from(fromByEventType.values());
562
651
  }
563
652
 
564
653
  private resolveModelBoundMetadata(type: Constructor): ResolvedModelBoundMetadata | undefined {
@@ -0,0 +1,253 @@
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 'reflect-metadata';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { IClientArtifactsProvider } from '../artifacts';
7
+ import { ChronicleConnection } from '../connection';
8
+ import { eventType } from '../events/eventTypeDecorator';
9
+ import { readModel } from '../readModels/readModel';
10
+ import { entersOn } from './modelBound/entersOn';
11
+ import { fromEvent } from './modelBound/fromEvent';
12
+ import { globalFor } from './modelBound/globalFor';
13
+ import { setFrom } from './modelBound/setFrom';
14
+ import { variantOf } from './modelBound/variantOf';
15
+ import { GlobalHandlerPropertyNotOnVariant } from './GlobalHandlerPropertyNotOnVariant';
16
+ import { VariantMustDeclareEntersOnEvent } from './VariantMustDeclareEntersOnEvent';
17
+ import { Projections } from './Projections';
18
+
19
+ // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
20
+ // fixtures don't depend on the test runner's decorator-syntax support.
21
+
22
+ class IssueCreated {
23
+ title!: string;
24
+ }
25
+ eventType()(IssueCreated);
26
+
27
+ class PullRequestCreated {
28
+ pullRequestUrl!: string;
29
+ }
30
+ eventType()(PullRequestCreated);
31
+
32
+ class BuildCompleted {
33
+ buildStatus!: string;
34
+ }
35
+ eventType()(BuildCompleted);
36
+
37
+ class TitleChanged {
38
+ title!: string;
39
+ }
40
+ eventType()(TitleChanged);
41
+
42
+ /** Anchors the logical identity shared by every variant below. Never itself a read model. */
43
+ class WorkItem {}
44
+
45
+ class BacklogItem {
46
+ id!: string;
47
+ title!: string;
48
+ }
49
+ setFrom(IssueCreated, 'title')(BacklogItem.prototype, 'title');
50
+ variantOf(WorkItem, 'id')(BacklogItem);
51
+ entersOn(IssueCreated)(BacklogItem);
52
+ fromEvent(IssueCreated)(BacklogItem);
53
+ readModel()(BacklogItem);
54
+
55
+ class PullRequestItem {
56
+ id!: string;
57
+ title!: string;
58
+ pullRequestUrl!: string;
59
+ buildStatus!: string;
60
+ }
61
+ setFrom(PullRequestCreated, 'pullRequestUrl')(PullRequestItem.prototype, 'pullRequestUrl');
62
+ setFrom(BuildCompleted, 'buildStatus')(PullRequestItem.prototype, 'buildStatus');
63
+ variantOf(WorkItem, 'id')(PullRequestItem);
64
+ entersOn(PullRequestCreated)(PullRequestItem);
65
+ fromEvent(PullRequestCreated)(PullRequestItem);
66
+ fromEvent(BuildCompleted)(PullRequestItem);
67
+ readModel()(PullRequestItem);
68
+
69
+ /** A shared handler with no entering event of its own - never registered as its own projection. */
70
+ class TitleHandler {
71
+ title!: string;
72
+ }
73
+ setFrom(TitleChanged, 'title')(TitleHandler.prototype, 'title');
74
+ globalFor(WorkItem)(TitleHandler);
75
+
76
+ /** A variant missing an entersOn declaration entirely. */
77
+ class UndeclaredVariant {
78
+ id!: string;
79
+ title!: string;
80
+ }
81
+ setFrom(IssueCreated, 'title')(UndeclaredVariant.prototype, 'title');
82
+ variantOf(WorkItem, 'id')(UndeclaredVariant);
83
+ fromEvent(IssueCreated)(UndeclaredVariant);
84
+ readModel()(UndeclaredVariant);
85
+
86
+ /** A separate identity, used to prove sibling cross-wiring stays scoped to one identity. */
87
+ class OtherIdentity {}
88
+
89
+ class OtherVariant {
90
+ id!: string;
91
+ }
92
+ variantOf(OtherIdentity, 'id')(OtherVariant);
93
+ entersOn(IssueCreated)(OtherVariant);
94
+ fromEvent(IssueCreated)(OtherVariant);
95
+ readModel()(OtherVariant);
96
+
97
+ interface CapturedDefinition {
98
+ Identifier: string;
99
+ From: Array<{ Key: { Id: string }; Value: { Properties: Record<string, string>; Key: string } }>;
100
+ Join: Array<{ Key: { Id: string }; Value: { On: string; Properties: Record<string, string>; Key: string } }>;
101
+ RemovedWith: Array<{ Key: { Id: string }; Value: { Key: string; ParentKey: string } }>;
102
+ }
103
+
104
+ function createProjections(readModels: (new (...args: unknown[]) => unknown)[], globalForHandlers: (new (...args: unknown[]) => unknown)[] = []) {
105
+ const registerMock = vi.fn().mockResolvedValue(undefined);
106
+ const registerManyMock = vi.fn().mockResolvedValue(undefined);
107
+ const connection = {
108
+ readModels: { registerMany: registerManyMock },
109
+ projections: { register: registerMock }
110
+ } as unknown as ChronicleConnection;
111
+
112
+ const clientArtifacts: IClientArtifactsProvider = {
113
+ eventTypes: [],
114
+ readModels: readModels as unknown as IClientArtifactsProvider['readModels'],
115
+ reactors: [],
116
+ reducers: [],
117
+ seeders: [],
118
+ constraints: [],
119
+ projections: [],
120
+ webhooks: [],
121
+ eventTypeMigrations: [],
122
+ globalForHandlers: globalForHandlers as unknown as IClientArtifactsProvider['globalForHandlers']
123
+ };
124
+
125
+ const projections = new Projections('test-store', 'test-namespace', connection, clientArtifacts, 'test-sink');
126
+ return { projections, registerMock };
127
+ }
128
+
129
+ function definitionsFor(registerMock: ReturnType<typeof vi.fn>): Map<string, CapturedDefinition> {
130
+ const byIdentifier = new Map<string, CapturedDefinition>();
131
+ for (const call of registerMock.mock.calls) {
132
+ const request = call[0] as { Projections: CapturedDefinition[] };
133
+ for (const definition of request.Projections) {
134
+ byIdentifier.set(definition.Identifier, definition);
135
+ }
136
+ }
137
+ return byIdentifier;
138
+ }
139
+
140
+ describe('Projections', () => {
141
+ describe('when registering a variant that enters on its own event', () => {
142
+ it('should keep the entering event as a create-or-update From handler', async () => {
143
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem]);
144
+
145
+ await projections.register();
146
+
147
+ const definitions = definitionsFor(registerMock);
148
+ const backlogItem = definitions.get('BacklogItem')!;
149
+ const issueCreated = backlogItem.From.find(entry => entry.Key.Id === 'IssueCreated');
150
+ expect(issueCreated).toBeDefined();
151
+ expect(issueCreated!.Value.Properties.title).toBe('title');
152
+ });
153
+ });
154
+
155
+ describe('when a variant projects from an event that is not its entering event', () => {
156
+ it('should reclassify it into an update-only self-referential join', async () => {
157
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem]);
158
+
159
+ await projections.register();
160
+
161
+ const definitions = definitionsFor(registerMock);
162
+ const pullRequestItem = definitions.get('PullRequestItem')!;
163
+
164
+ expect(pullRequestItem.From.some(entry => entry.Key.Id === 'BuildCompleted')).toBe(false);
165
+
166
+ const buildCompletedJoin = pullRequestItem.Join.find(entry => entry.Key.Id === 'BuildCompleted');
167
+ expect(buildCompletedJoin).toBeDefined();
168
+ expect(buildCompletedJoin!.Value.On).toBe('id');
169
+ expect(buildCompletedJoin!.Value.Key).toBe('$eventSourceId');
170
+ expect(buildCompletedJoin!.Value.Properties.buildStatus).toBe('buildStatus');
171
+ });
172
+ });
173
+
174
+ describe('when two variants of the same identity are registered together', () => {
175
+ it('should cross-wire mutual exclusion between them', async () => {
176
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem]);
177
+
178
+ await projections.register();
179
+
180
+ const definitions = definitionsFor(registerMock);
181
+ const backlogItem = definitions.get('BacklogItem')!;
182
+ const pullRequestItem = definitions.get('PullRequestItem')!;
183
+
184
+ const backlogRemovedByPullRequest = backlogItem.RemovedWith.find(entry => entry.Key.Id === 'PullRequestCreated');
185
+ expect(backlogRemovedByPullRequest).toBeDefined();
186
+
187
+ const pullRequestRemovedByBacklog = pullRequestItem.RemovedWith.find(entry => entry.Key.Id === 'IssueCreated');
188
+ expect(pullRequestRemovedByBacklog).toBeDefined();
189
+ });
190
+
191
+ it('should not cross-wire a variant of a different identity', async () => {
192
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem, OtherVariant]);
193
+
194
+ await projections.register();
195
+
196
+ const definitions = definitionsFor(registerMock);
197
+ const otherVariant = definitions.get('OtherVariant')!;
198
+
199
+ expect(otherVariant.RemovedWith.length).toBe(0);
200
+ });
201
+ });
202
+
203
+ describe('when a globalFor shared handler applies to a group', () => {
204
+ it('should merge its mapping into every variant', async () => {
205
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem], [TitleHandler]);
206
+
207
+ await projections.register();
208
+
209
+ const definitions = definitionsFor(registerMock);
210
+ const backlogItem = definitions.get('BacklogItem')!;
211
+ const pullRequestItem = definitions.get('PullRequestItem')!;
212
+
213
+ const backlogTitleChanged = backlogItem.Join.find(entry => entry.Key.Id === 'TitleChanged');
214
+ expect(backlogTitleChanged).toBeDefined();
215
+ expect(backlogTitleChanged!.Value.Properties.title).toBe('title');
216
+
217
+ const pullRequestTitleChanged = pullRequestItem.Join.find(entry => entry.Key.Id === 'TitleChanged');
218
+ expect(pullRequestTitleChanged).toBeDefined();
219
+ expect(pullRequestTitleChanged!.Value.Properties.title).toBe('title');
220
+ });
221
+
222
+ it('should never register the shared handler as its own projection', async () => {
223
+ const { projections, registerMock } = createProjections([BacklogItem, PullRequestItem], [TitleHandler]);
224
+
225
+ await projections.register();
226
+
227
+ const definitions = definitionsFor(registerMock);
228
+ expect(definitions.has('TitleHandler')).toBe(false);
229
+ });
230
+ });
231
+
232
+ describe('when a variant does not declare an entersOn event', () => {
233
+ it('should throw VariantMustDeclareEntersOnEvent', async () => {
234
+ const { projections } = createProjections([UndeclaredVariant]);
235
+
236
+ await expect(projections.register()).rejects.toThrow(VariantMustDeclareEntersOnEvent);
237
+ });
238
+ });
239
+
240
+ describe('when a globalFor shared handler maps to a property a variant does not have', () => {
241
+ class MismatchedHandler {
242
+ unrelatedProperty!: string;
243
+ }
244
+ setFrom(TitleChanged, 'title')(MismatchedHandler.prototype, 'unrelatedProperty');
245
+ globalFor(WorkItem)(MismatchedHandler);
246
+
247
+ it('should throw GlobalHandlerPropertyNotOnVariant', async () => {
248
+ const { projections } = createProjections([BacklogItem, PullRequestItem], [MismatchedHandler]);
249
+
250
+ await expect(projections.register()).rejects.toThrow(GlobalHandlerPropertyNotOnVariant);
251
+ });
252
+ });
253
+ });
@@ -0,0 +1,16 @@
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
+ /**
5
+ * Error thrown when a read model variant does not declare at least one entersOn event.
6
+ */
7
+ export class VariantMustDeclareEntersOnEvent extends Error {
8
+ /**
9
+ * Creates a new {@link VariantMustDeclareEntersOnEvent}.
10
+ * @param typeName - The name of the variant type missing an entersOn declaration.
11
+ */
12
+ constructor(typeName: string) {
13
+ super(`Read model variant '${typeName}' does not declare an entersOn event. A variant must name at least one event that may create or resurrect it.`);
14
+ this.name = 'VariantMustDeclareEntersOnEvent';
15
+ }
16
+ }