@cratis/chronicle 6.1.0 → 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.
- package/artifacts/DefaultClientArtifactsProvider.ts +5 -0
- package/artifacts/IClientArtifactsProvider.ts +3 -0
- package/dist/artifacts/DefaultClientArtifactsProvider.d.ts +2 -0
- package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
- package/dist/artifacts/DefaultClientArtifactsProvider.js +4 -0
- package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
- package/dist/artifacts/IClientArtifactsProvider.d.ts +2 -0
- package/dist/artifacts/IClientArtifactsProvider.d.ts.map +1 -1
- package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts +14 -0
- package/dist/projections/GlobalHandlerPropertyNotOnVariant.d.ts.map +1 -0
- package/dist/projections/GlobalHandlerPropertyNotOnVariant.js +19 -0
- package/dist/projections/GlobalHandlerPropertyNotOnVariant.js.map +1 -0
- package/dist/projections/Projections.d.ts +8 -0
- package/dist/projections/Projections.d.ts.map +1 -1
- package/dist/projections/Projections.js +81 -7
- package/dist/projections/Projections.js.map +1 -1
- package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts +11 -0
- package/dist/projections/VariantMustDeclareEntersOnEvent.d.ts.map +1 -0
- package/dist/projections/VariantMustDeclareEntersOnEvent.js +16 -0
- package/dist/projections/VariantMustDeclareEntersOnEvent.js.map +1 -0
- package/dist/projections/VariantReclassifier.d.ts +63 -0
- package/dist/projections/VariantReclassifier.d.ts.map +1 -0
- package/dist/projections/VariantReclassifier.js +126 -0
- package/dist/projections/VariantReclassifier.js.map +1 -0
- package/dist/projections/declarative/IProjectionBuilderFor.d.ts +20 -0
- package/dist/projections/declarative/IProjectionBuilderFor.d.ts.map +1 -1
- package/dist/projections/declarative/ProjectionBuilderFor.d.ts +17 -1
- package/dist/projections/declarative/ProjectionBuilderFor.d.ts.map +1 -1
- package/dist/projections/declarative/ProjectionBuilderFor.js +34 -0
- package/dist/projections/declarative/ProjectionBuilderFor.js.map +1 -1
- package/dist/projections/index.d.ts +2 -0
- package/dist/projections/index.d.ts.map +1 -1
- package/dist/projections/index.js +2 -0
- package/dist/projections/index.js.map +1 -1
- package/dist/projections/modelBound/entersOn.d.ts +25 -0
- package/dist/projections/modelBound/entersOn.d.ts.map +1 -0
- package/dist/projections/modelBound/entersOn.js +28 -0
- package/dist/projections/modelBound/entersOn.js.map +1 -0
- package/dist/projections/modelBound/globalFor.d.ts +24 -0
- package/dist/projections/modelBound/globalFor.d.ts.map +1 -0
- package/dist/projections/modelBound/globalFor.js +31 -0
- package/dist/projections/modelBound/globalFor.js.map +1 -0
- package/dist/projections/modelBound/index.d.ts +6 -0
- package/dist/projections/modelBound/index.d.ts.map +1 -1
- package/dist/projections/modelBound/index.js +3 -0
- package/dist/projections/modelBound/index.js.map +1 -1
- package/dist/projections/modelBound/variantOf.d.ts +24 -0
- package/dist/projections/modelBound/variantOf.d.ts.map +1 -0
- package/dist/projections/modelBound/variantOf.js +27 -0
- package/dist/projections/modelBound/variantOf.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/DecoratorType.d.ts +3 -1
- package/dist/types/DecoratorType.d.ts.map +1 -1
- package/dist/types/DecoratorType.js +2 -0
- package/dist/types/DecoratorType.js.map +1 -1
- package/events/for_EventContext/when_receiving/with_kernel_metadata.spec.ts +1 -1
- package/package.json +2 -2
- package/projections/GlobalHandlerPropertyNotOnVariant.ts +19 -0
- package/projections/Projections.childrenAndNested.spec.ts +2 -1
- package/projections/Projections.declarativeVariants.spec.ts +157 -0
- package/projections/Projections.modelBoundCompleteness.spec.ts +2 -1
- package/projections/Projections.operationalSurface.spec.ts +2 -1
- package/projections/Projections.spec.ts +2 -1
- package/projections/Projections.ts +98 -9
- package/projections/Projections.variants.spec.ts +253 -0
- package/projections/VariantMustDeclareEntersOnEvent.ts +16 -0
- package/projections/VariantReclassifier.ts +177 -0
- package/projections/declarative/IProjectionBuilderFor.ts +22 -0
- package/projections/declarative/ProjectionBuilderFor.ts +40 -1
- package/projections/index.ts +2 -0
- package/projections/modelBound/entersOn.ts +39 -0
- package/projections/modelBound/globalFor.ts +41 -0
- package/projections/modelBound/index.ts +6 -0
- package/projections/modelBound/variantOf.ts +38 -0
- package/readModels/ReadModels.spec.ts +2 -1
- package/reducers/Reducers.spec.ts +2 -1
- 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
|
|
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):
|
|
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
|
-
|
|
409
|
-
|
|
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):
|
|
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:
|
|
544
|
-
Join:
|
|
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
|
-
|
|
561
|
-
|
|
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
|
+
}
|