@cratis/chronicle 6.6.0 → 6.7.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 +12 -1
- package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
- package/dist/artifacts/DefaultClientArtifactsProvider.js +14 -1
- package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
- package/dist/events/eventTypeDecorator.d.ts +6 -1
- package/dist/events/eventTypeDecorator.d.ts.map +1 -1
- package/dist/events/eventTypeDecorator.js.map +1 -1
- package/dist/projections/Projections.d.ts.map +1 -1
- package/dist/projections/Projections.js +31 -14
- package/dist/projections/Projections.js.map +1 -1
- package/dist/projections/modelBound/fromEvent.d.ts.map +1 -1
- package/dist/projections/modelBound/fromEvent.js +2 -0
- package/dist/projections/modelBound/fromEvent.js.map +1 -1
- package/dist/readModels/ReadModels.d.ts +1 -0
- package/dist/readModels/ReadModels.d.ts.map +1 -1
- package/dist/readModels/ReadModels.js +22 -16
- package/dist/readModels/ReadModels.js.map +1 -1
- package/dist/readModels/assertUniqueReadModelIds.d.ts +4 -0
- package/dist/readModels/assertUniqueReadModelIds.d.ts.map +1 -0
- package/dist/readModels/assertUniqueReadModelIds.js +16 -0
- package/dist/readModels/assertUniqueReadModelIds.js.map +1 -0
- package/dist/readModels/readModel.d.ts +5 -0
- package/dist/readModels/readModel.d.ts.map +1 -1
- package/dist/readModels/readModel.js +14 -0
- package/dist/readModels/readModel.js.map +1 -1
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +11 -1
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/TypeDiscoverer.d.ts +2 -0
- package/dist/types/TypeDiscoverer.d.ts.map +1 -1
- package/dist/types/TypeDiscoverer.js +39 -2
- package/dist/types/TypeDiscoverer.js.map +1 -1
- package/events/constructorRoundTrip.legacy.spec.ts +47 -0
- package/events/constructorRoundTrip.standard.spec.ts +47 -0
- package/events/eventTypeDecorator.ts +6 -1
- package/package.json +1 -1
- package/projections/Projections.ts +31 -16
- package/projections/modelBound/fromEvent.ts +3 -0
- package/readModels/ReadModels.ts +23 -17
- package/readModels/assertUniqueReadModelIds.ts +18 -0
- package/readModels/discovery.spec.ts +94 -0
- package/readModels/readModel.ts +14 -0
- package/reducers/Reducers.spec.ts +18 -0
- package/reducers/Reducers.ts +10 -1
- package/types/TypeDiscoverer.ts +38 -2
|
@@ -18,7 +18,11 @@ import { FailedPartition } from '../observation/FailedPartition.js';
|
|
|
18
18
|
import { FailedPartitions } from '../observation/FailedPartitions.js';
|
|
19
19
|
import { toObserverRunningState } from '../observation/toObserverRunningState.js';
|
|
20
20
|
import { getReadModelMetadata } from '../readModels/index.js';
|
|
21
|
+
import { getReadModelId } from '../readModels/readModel.js';
|
|
22
|
+
import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
|
|
23
|
+
import { JsonSchemaGenerator } from '../schemas/index.js';
|
|
21
24
|
import { TypeIntrospector } from '../types/index.js';
|
|
25
|
+
import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
|
|
22
26
|
import { IProjections } from './IProjections.js';
|
|
23
27
|
import { getProjectionMetadata } from './declarative/projection.js';
|
|
24
28
|
import { ProjectionBuilderFor } from './declarative/ProjectionBuilderFor.js';
|
|
@@ -109,7 +113,7 @@ export class Projections implements IProjections {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
for (const type of readModelTypes) {
|
|
112
|
-
if (!hasFromEventMetadata(type)) {
|
|
116
|
+
if (!hasFromEventMetadata(type) && !hasModelBoundProperties(type)) {
|
|
113
117
|
continue;
|
|
114
118
|
}
|
|
115
119
|
|
|
@@ -136,6 +140,10 @@ export class Projections implements IProjections {
|
|
|
136
140
|
|
|
137
141
|
this._logger.info('Registering projections', { declarativeCount: this._declarative.size, modelBoundCount: this._modelBound.size });
|
|
138
142
|
|
|
143
|
+
const inferred = this._clientArtifacts.projections
|
|
144
|
+
.map(type => getProjectionMetadata(type)?.readModelType)
|
|
145
|
+
.filter((type): type is Constructor => type !== undefined);
|
|
146
|
+
assertUniqueReadModelIds([...this._clientArtifacts.readModels, ...inferred]);
|
|
139
147
|
const builtProjections: BuiltProjection[] = [
|
|
140
148
|
...Array.from(this._declarative.values()).map(type => this.buildDeclarativeDefinition(type)),
|
|
141
149
|
...Array.from(this._modelBound.values()).map(type => this.buildModelBoundDefinition(type))
|
|
@@ -349,7 +357,14 @@ export class Projections implements IProjections {
|
|
|
349
357
|
|
|
350
358
|
for (const projection of projections) {
|
|
351
359
|
const readModelIdentifier = projection.ReadModel;
|
|
352
|
-
if (!readModelIdentifier
|
|
360
|
+
if (!readModelIdentifier) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const existing = byReadModel.get(readModelIdentifier);
|
|
364
|
+
if (existing) {
|
|
365
|
+
if (existing.ObserverIdentifier !== projection.Identifier) {
|
|
366
|
+
throw new Error(`Read model id '${readModelIdentifier}' has multiple projections.`);
|
|
367
|
+
}
|
|
353
368
|
continue;
|
|
354
369
|
}
|
|
355
370
|
|
|
@@ -380,14 +395,16 @@ export class Projections implements IProjections {
|
|
|
380
395
|
}
|
|
381
396
|
|
|
382
397
|
private getReadModelSchema(readModelIdentifier: string): string {
|
|
383
|
-
|
|
398
|
+
const types = [
|
|
399
|
+
...this._clientArtifacts.readModels,
|
|
400
|
+
...this._clientArtifacts.projections
|
|
401
|
+
.map(projectionType => getProjectionMetadata(projectionType)?.readModelType)
|
|
402
|
+
.filter((type): type is Constructor => type !== undefined)
|
|
403
|
+
];
|
|
404
|
+
for (const type of types) {
|
|
384
405
|
const metadata = getReadModelMetadata(type);
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (metadata.id.value === readModelIdentifier || type.name === readModelIdentifier) {
|
|
390
|
-
return JSON.stringify(metadata.schema);
|
|
406
|
+
if (getReadModelId(type) === readModelIdentifier) {
|
|
407
|
+
return JSON.stringify(metadata?.schema ?? JsonSchemaGenerator.generate(type));
|
|
391
408
|
}
|
|
392
409
|
}
|
|
393
410
|
|
|
@@ -409,8 +426,7 @@ export class Projections implements IProjections {
|
|
|
409
426
|
if (explicitReadModelIdentifier === type.name) {
|
|
410
427
|
// Use explicit readModelType from decorator if provided
|
|
411
428
|
if (metadata.readModelType) {
|
|
412
|
-
|
|
413
|
-
definition.ReadModel = rm?.id.value ?? (metadata.readModelType as Function).name;
|
|
429
|
+
definition.ReadModel = getReadModelId(metadata.readModelType);
|
|
414
430
|
} else {
|
|
415
431
|
const inferredReadModelIdentifier = this.inferReadModelIdentifier(builder.getMappedReadModelProperties());
|
|
416
432
|
if (inferredReadModelIdentifier) {
|
|
@@ -651,13 +667,12 @@ export class Projections implements IProjections {
|
|
|
651
667
|
}
|
|
652
668
|
|
|
653
669
|
private resolveModelBoundMetadata(type: Constructor): ResolvedModelBoundMetadata | undefined {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (readModelMetadata && hasFromEventMetadata(type)) {
|
|
670
|
+
if (hasFromEventMetadata(type) || hasModelBoundProperties(type)) {
|
|
671
|
+
const identifier = getReadModelId(type);
|
|
657
672
|
return {
|
|
658
|
-
id: new ProjectionId(
|
|
673
|
+
id: new ProjectionId(identifier),
|
|
659
674
|
eventSequenceId: getEventSequenceMetadata(type),
|
|
660
|
-
readModelIdentifier:
|
|
675
|
+
readModelIdentifier: identifier
|
|
661
676
|
};
|
|
662
677
|
}
|
|
663
678
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import 'reflect-metadata';
|
|
5
5
|
import { FromEventMetadata } from './FromEventMetadata.js';
|
|
6
6
|
import { FromEventOptions } from './FromEventOptions.js';
|
|
7
|
+
import { DecoratorType, TypeDiscoverer } from '../../types/index.js';
|
|
8
|
+
import type { Constructor } from '@cratis/fundamentals';
|
|
7
9
|
|
|
8
10
|
const METADATA_KEY = 'chronicle:projection:fromEvent';
|
|
9
11
|
|
|
@@ -18,6 +20,7 @@ export function fromEvent(eventType: Function, options?: FromEventOptions): Clas
|
|
|
18
20
|
const existing: FromEventMetadata[] = Reflect.getMetadata(METADATA_KEY, target) ?? [];
|
|
19
21
|
const metadata: FromEventMetadata = { eventType, ...options };
|
|
20
22
|
Reflect.defineMetadata(METADATA_KEY, [...existing, metadata], target);
|
|
23
|
+
TypeDiscoverer.default.register(DecoratorType.ReadModel, target as Constructor);
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
|
package/readModels/ReadModels.ts
CHANGED
|
@@ -17,11 +17,13 @@ import { ensureQuerySuccess } from '../connection/callResults.js';
|
|
|
17
17
|
import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
|
|
18
18
|
import { getProjectionMetadata } from '../projections/declarative/projection.js';
|
|
19
19
|
import { hasFromEventMetadata } from '../projections/modelBound/fromEvent.js';
|
|
20
|
+
import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
|
|
20
21
|
import { isPassive } from '../projections/modelBound/passive.js';
|
|
21
22
|
import { getReducerMetadata } from '../reducers/reducer.js';
|
|
22
23
|
import { JsonSchemaGenerator } from '../schemas/index.js';
|
|
23
24
|
import { WellKnownSinks } from '../sinks/index.js';
|
|
24
|
-
import { getReadModelMetadata } from './readModel.js';
|
|
25
|
+
import { getReadModelMetadata, getReadModelId } from './readModel.js';
|
|
26
|
+
import { assertUniqueReadModelIds } from './assertUniqueReadModelIds.js';
|
|
25
27
|
import type { IMaterializedReadModels } from './IMaterializedReadModels.js';
|
|
26
28
|
import { MaterializedReadModels } from './MaterializedReadModels.js';
|
|
27
29
|
import { ReadModelSubjectResolver } from './ReadModelSubjectResolver.js';
|
|
@@ -239,6 +241,7 @@ export class ReadModels implements IReadModels {
|
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
private resolveReadModels<TReadModel>(readModelType?: Constructor<TReadModel>): ResolvedReadModel[] {
|
|
244
|
+
assertUniqueReadModelIds(this._clientArtifacts.readModels);
|
|
242
245
|
const resolved = new Map<string, ResolvedReadModel>();
|
|
243
246
|
|
|
244
247
|
for (const projectionType of this._clientArtifacts.projections) {
|
|
@@ -251,9 +254,8 @@ export class ReadModels implements IReadModels {
|
|
|
251
254
|
continue;
|
|
252
255
|
}
|
|
253
256
|
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
resolved.set(identifier, {
|
|
257
|
+
const identifier = getReadModelId(metadata.readModelType);
|
|
258
|
+
this.addResolved(resolved, {
|
|
257
259
|
type: metadata.readModelType,
|
|
258
260
|
identifier,
|
|
259
261
|
eventSequenceId: metadata.eventSequenceId ?? EventSequenceId.eventLog.value,
|
|
@@ -265,7 +267,7 @@ export class ReadModels implements IReadModels {
|
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
for (const modelBoundType of this._clientArtifacts.readModels) {
|
|
268
|
-
if (!hasFromEventMetadata(modelBoundType)) {
|
|
270
|
+
if (!hasFromEventMetadata(modelBoundType) && !hasModelBoundProperties(modelBoundType)) {
|
|
269
271
|
continue;
|
|
270
272
|
}
|
|
271
273
|
|
|
@@ -273,18 +275,14 @@ export class ReadModels implements IReadModels {
|
|
|
273
275
|
continue;
|
|
274
276
|
}
|
|
275
277
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
resolved.set(metadata.id.value, {
|
|
278
|
+
const identifier = getReadModelId(modelBoundType);
|
|
279
|
+
this.addResolved(resolved, {
|
|
282
280
|
type: modelBoundType,
|
|
283
|
-
identifier
|
|
281
|
+
identifier,
|
|
284
282
|
eventSequenceId: EventSequenceId.eventLog.value,
|
|
285
283
|
observerType: ContractReadModelObserverType.Projection,
|
|
286
|
-
observerIdentifier:
|
|
287
|
-
schema:
|
|
284
|
+
observerIdentifier: identifier,
|
|
285
|
+
schema: this.getReadModelSchema(modelBoundType, identifier),
|
|
288
286
|
isActive: !isPassive(modelBoundType)
|
|
289
287
|
});
|
|
290
288
|
}
|
|
@@ -299,9 +297,8 @@ export class ReadModels implements IReadModels {
|
|
|
299
297
|
continue;
|
|
300
298
|
}
|
|
301
299
|
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
resolved.set(identifier, {
|
|
300
|
+
const identifier = getReadModelId(metadata.readModel);
|
|
301
|
+
this.addResolved(resolved, {
|
|
305
302
|
type: metadata.readModel,
|
|
306
303
|
identifier,
|
|
307
304
|
eventSequenceId: metadata.eventSequenceId ?? EventSequenceId.eventLog.value,
|
|
@@ -315,6 +312,15 @@ export class ReadModels implements IReadModels {
|
|
|
315
312
|
return Array.from(resolved.values());
|
|
316
313
|
}
|
|
317
314
|
|
|
315
|
+
private addResolved(models: Map<string, ResolvedReadModel>, model: ResolvedReadModel): void {
|
|
316
|
+
const existing = models.get(model.identifier);
|
|
317
|
+
if (existing && (existing.type !== model.type || existing.observerType !== model.observerType ||
|
|
318
|
+
existing.observerIdentifier !== model.observerIdentifier)) {
|
|
319
|
+
throw new Error(`Read model id '${model.identifier}' has multiple observers or model types.`);
|
|
320
|
+
}
|
|
321
|
+
models.set(model.identifier, model);
|
|
322
|
+
}
|
|
323
|
+
|
|
318
324
|
private resolveReadModel<TReadModel>(readModelType: Constructor<TReadModel>): ResolvedReadModel {
|
|
319
325
|
const [resolved] = this.resolveReadModels(readModelType);
|
|
320
326
|
if (!resolved) {
|
|
@@ -0,0 +1,18 @@
|
|
|
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 { Constructor } from '@cratis/fundamentals';
|
|
5
|
+
import { getReadModelId } from './readModel.js';
|
|
6
|
+
|
|
7
|
+
/** Refuses to register two different model types under the same storage identity. */
|
|
8
|
+
export function assertUniqueReadModelIds(types: Iterable<Constructor>): void {
|
|
9
|
+
const byId = new Map<string, Constructor>();
|
|
10
|
+
for (const type of types) {
|
|
11
|
+
const id = getReadModelId(type);
|
|
12
|
+
const previous = byId.get(id);
|
|
13
|
+
if (previous && previous !== type) {
|
|
14
|
+
throw new Error(`Read model id '${id}' is shared by '${previous.name}' and '${type.name}'.`);
|
|
15
|
+
}
|
|
16
|
+
byId.set(id, type);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { field } from '@cratis/fundamentals';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { DefaultClientArtifactsProvider } from '../artifacts/DefaultClientArtifactsProvider.js';
|
|
7
|
+
import type { ChronicleConnection } from '../connection/index.js';
|
|
8
|
+
import { projection } from '../projections/declarative/projection.js';
|
|
9
|
+
import { fromEvent } from '../projections/modelBound/fromEvent.js';
|
|
10
|
+
import { setFrom } from '../projections/modelBound/setFrom.js';
|
|
11
|
+
import { Projections } from '../projections/Projections.js';
|
|
12
|
+
import { eventType } from '../events/eventTypeDecorator.js';
|
|
13
|
+
import { TypeDiscoverer } from '../types/TypeDiscoverer.js';
|
|
14
|
+
import { reducer } from '../reducers/reducer.js';
|
|
15
|
+
import { ReadModels } from './ReadModels.js';
|
|
16
|
+
import { readModel } from './readModel.js';
|
|
17
|
+
|
|
18
|
+
@eventType('discovery-event')
|
|
19
|
+
class Event { @field(String) value!: string; }
|
|
20
|
+
@fromEvent(Event)
|
|
21
|
+
class ModelBound {
|
|
22
|
+
@field(String) value!: string;
|
|
23
|
+
}
|
|
24
|
+
class Projected {
|
|
25
|
+
static readonly readModelId = 'projected-custom';
|
|
26
|
+
@field(String) value!: string;
|
|
27
|
+
}
|
|
28
|
+
@projection('projected-observer', Projected)
|
|
29
|
+
class ProjectedObserver {
|
|
30
|
+
define() {}
|
|
31
|
+
}
|
|
32
|
+
class Reduced {
|
|
33
|
+
@field(Number) count!: number;
|
|
34
|
+
}
|
|
35
|
+
@reducer('reduced-observer', undefined, Reduced)
|
|
36
|
+
class ReducedObserver {}
|
|
37
|
+
@readModel('legacy-id')
|
|
38
|
+
@fromEvent(Event)
|
|
39
|
+
class Legacy {
|
|
40
|
+
@field(String) value!: string;
|
|
41
|
+
}
|
|
42
|
+
class MappedOnly {
|
|
43
|
+
@field(String)
|
|
44
|
+
@setFrom(Event)
|
|
45
|
+
value!: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createConnection() {
|
|
49
|
+
const registerMany = vi.fn().mockResolvedValue({});
|
|
50
|
+
const register = vi.fn().mockResolvedValue({});
|
|
51
|
+
const connection = { readModels: { registerMany }, projections: { register } } as unknown as ChronicleConnection;
|
|
52
|
+
return { connection, registerMany, register };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('when discovering read models from observers', () => {
|
|
56
|
+
it('should discover and register an exported property-bound model without a class decorator', async () => {
|
|
57
|
+
const discoverer = new TypeDiscoverer(async () => ['virtual.ts'], async () => ({ MappedOnly }));
|
|
58
|
+
await discoverer.discover('virtual');
|
|
59
|
+
const provider = new DefaultClientArtifactsProvider(discoverer);
|
|
60
|
+
expect(provider.readModels).toContain(MappedOnly);
|
|
61
|
+
const { connection, registerMany, register } = createConnection();
|
|
62
|
+
const projections = new Projections('store', 'Default', connection, provider, 'sink');
|
|
63
|
+
await projections.register();
|
|
64
|
+
const definition = registerMany.mock.calls[0][0].ReadModels.find(
|
|
65
|
+
(model: { Type: { Identifier: string } }) => model.Type.Identifier === 'MappedOnly');
|
|
66
|
+
expect(definition).toBeDefined();
|
|
67
|
+
expect(JSON.parse(definition.Schema).properties.value.type).toBe('string');
|
|
68
|
+
expect(register.mock.calls.some(call => call[0].Projections[0].ReadModel === 'MappedOnly')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('should deduplicate inferred types and retain decorated and custom identifiers', async () => {
|
|
71
|
+
const provider = DefaultClientArtifactsProvider.default;
|
|
72
|
+
for (const type of [ModelBound, Projected, Reduced, Legacy]) {
|
|
73
|
+
expect(provider.readModels.filter(model => model === type)).toHaveLength(1);
|
|
74
|
+
}
|
|
75
|
+
const { connection, registerMany } = createConnection();
|
|
76
|
+
await new ReadModels('store', 'Default', connection, provider, 'sink').register();
|
|
77
|
+
const definitions = registerMany.mock.calls[0][0].ReadModels;
|
|
78
|
+
const byId = new Map(definitions.map((definition: { Type: { Identifier: string } }) => [definition.Type.Identifier, definition]));
|
|
79
|
+
for (const id of ['ModelBound', 'projected-custom', 'Reduced', 'legacy-id']) {
|
|
80
|
+
expect(byId.has(id)).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
expect(JSON.parse(byId.get('projected-custom').Schema).properties.value.type).toBe('string');
|
|
83
|
+
expect(JSON.parse(byId.get('Reduced').Schema).properties.count.type).toBe('number');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reject two distinct models with the same identifier before registration', async () => {
|
|
87
|
+
class First { static readonly readModelId = 'collision'; }
|
|
88
|
+
class Second { static readonly readModelId = 'collision'; }
|
|
89
|
+
const provider = { ...DefaultClientArtifactsProvider.default, readModels: [First, Second], projections: [], reducers: [] };
|
|
90
|
+
const { connection, registerMany } = createConnection();
|
|
91
|
+
await expect(new ReadModels('store', 'Default', connection, provider, 'sink').register()).rejects.toThrow(/collision/);
|
|
92
|
+
expect(registerMany).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
package/readModels/readModel.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface ReadModelMetadata {
|
|
|
30
30
|
* TypeScript decorator that marks a class as a read model and captures reflection metadata.
|
|
31
31
|
* @param id - The unique identifier for the read model. Defaults to the class name if omitted.
|
|
32
32
|
* @returns A class decorator.
|
|
33
|
+
* @deprecated Read models are discovered from @projection(..., ReadModelType),
|
|
34
|
+
* @reducer(..., ReadModelType), or @fromEvent on the model. For a custom identifier,
|
|
35
|
+
* declare `static readonly readModelId = 'custom-id'` on the model instead.
|
|
33
36
|
*/
|
|
34
37
|
export function readModel(id: string = ''): ChronicleClassDecorator {
|
|
35
38
|
return (target: object, context?: ClassDecoratorContext) => {
|
|
@@ -83,3 +86,14 @@ export function getReadModelMetadata(target: Function): ReadModelMetadata | unde
|
|
|
83
86
|
export function isReadModel(target: Function): boolean {
|
|
84
87
|
return Reflect.hasMetadata(READ_MODEL_METADATA_KEY, target);
|
|
85
88
|
}
|
|
89
|
+
|
|
90
|
+
/** Resolves the compatible identifier of a decorated or inferred read model. */
|
|
91
|
+
export function getReadModelId(target: Function): string {
|
|
92
|
+
const decorated = getReadModelMetadata(target);
|
|
93
|
+
if (decorated) return decorated.id.value;
|
|
94
|
+
const explicit = (target as unknown as { readModelId?: unknown }).readModelId;
|
|
95
|
+
if (explicit !== undefined && (typeof explicit !== 'string' || !explicit.trim())) {
|
|
96
|
+
throw new TypeError(`Read model '${target.name}' must have a non-empty string readModelId.`);
|
|
97
|
+
}
|
|
98
|
+
return explicit as string | undefined ?? target.name;
|
|
99
|
+
}
|
|
@@ -89,6 +89,24 @@ function createConnection() {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
describe('Reducers', () => {
|
|
92
|
+
describe('when a reducer produces a model without @readModel', () => {
|
|
93
|
+
class NamedState {
|
|
94
|
+
static readonly readModelId = 'existing-state-id';
|
|
95
|
+
count = 0;
|
|
96
|
+
}
|
|
97
|
+
class NamedReducer {}
|
|
98
|
+
reducer('named-reducer', undefined, NamedState)(NamedReducer);
|
|
99
|
+
|
|
100
|
+
it('should register the model under its explicit identifier with a schema from its type', async () => {
|
|
101
|
+
const { connection } = createConnection();
|
|
102
|
+
const registerMany = vi.spyOn(connection.readModels, 'registerMany');
|
|
103
|
+
const reducers = new Reducers(createArtifacts([NamedReducer]), connection, 'store', 'Default', new ConnectionLifecycle(), 'sink');
|
|
104
|
+
await reducers.register();
|
|
105
|
+
const definition = registerMany.mock.calls[0][0].ReadModels[0];
|
|
106
|
+
expect(definition.Type.Identifier).toBe('existing-state-id');
|
|
107
|
+
expect(JSON.parse(definition.Schema).properties.count.type).toBe('number');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
92
110
|
describe('when registering an active reducer (the default)', () => {
|
|
93
111
|
class SomeReducer {}
|
|
94
112
|
reducer('some-active-reducer')(SomeReducer);
|
package/reducers/Reducers.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
|
|
|
18
18
|
import { IReducers } from './IReducers.js';
|
|
19
19
|
import { getReducerMetadata } from './reducer.js';
|
|
20
20
|
import { getReadModelMetadata } from '../readModels/index.js';
|
|
21
|
+
import { getReadModelId } from '../readModels/readModel.js';
|
|
22
|
+
import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
|
|
21
23
|
import { JsonSchemaGenerator } from '../schemas/index.js';
|
|
22
24
|
|
|
23
25
|
/** Expression used to partition reducer observations by event source ID. */
|
|
@@ -151,6 +153,7 @@ export class Reducers implements IReducers {
|
|
|
151
153
|
await this.discover();
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
assertUniqueReadModelIds(this._clientArtifacts.readModels);
|
|
154
157
|
await this.registerReadModels();
|
|
155
158
|
|
|
156
159
|
this._logger.info('Registering reducers', { count: this._reducers.size });
|
|
@@ -188,6 +191,12 @@ export class Reducers implements IReducers {
|
|
|
188
191
|
};
|
|
189
192
|
});
|
|
190
193
|
|
|
194
|
+
const identifiers = new Set<string>();
|
|
195
|
+
for (const definition of readModels) {
|
|
196
|
+
const identifier = definition.Type.Identifier;
|
|
197
|
+
if (identifiers.has(identifier)) throw new Error(`Read model id '${identifier}' has multiple reducers.`);
|
|
198
|
+
identifiers.add(identifier);
|
|
199
|
+
}
|
|
191
200
|
this._logger.info('Registering read models for reducers', { count: readModels.length });
|
|
192
201
|
await this._connection.readModels.registerMany({
|
|
193
202
|
EventStore: this._eventStoreName,
|
|
@@ -281,7 +290,7 @@ export class Reducers implements IReducers {
|
|
|
281
290
|
private getReducerReadModelIdentifier(reducerType: Constructor): string {
|
|
282
291
|
const metadata = getReducerMetadata(reducerType);
|
|
283
292
|
if (metadata?.readModel) {
|
|
284
|
-
return
|
|
293
|
+
return getReadModelId(metadata.readModel);
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
return (reducerType as Function).name;
|
package/types/TypeDiscoverer.ts
CHANGED
|
@@ -4,10 +4,23 @@
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { DecoratorType } from './DecoratorType.js';
|
|
6
6
|
import { Constructor } from '@cratis/fundamentals';
|
|
7
|
+
import { TypeIntrospector } from './TypeIntrospector.js';
|
|
8
|
+
import { hasPropertyMetadata } from './propertyDecoratorMetadata.js';
|
|
7
9
|
|
|
8
10
|
type GlobFunction = (pattern: string | string[]) => Promise<string[]>;
|
|
9
11
|
type FileImporter = (filePath: string) => Promise<unknown>;
|
|
10
12
|
|
|
13
|
+
const modelBoundPropertyKeys = [
|
|
14
|
+
'setFrom', 'setFromContext', 'setValue', 'addFrom', 'subtractFrom',
|
|
15
|
+
'increment', 'decrement', 'count', 'childrenFrom', 'join', 'fromEvery', 'fromAll'
|
|
16
|
+
].map(name => `chronicle:projection:${name}`);
|
|
17
|
+
|
|
18
|
+
/** Identifies models whose event mappings live on their properties rather than on @fromEvent. */
|
|
19
|
+
export function hasModelBoundProperties(type: Function): boolean {
|
|
20
|
+
return TypeIntrospector.getTrackedProperties(type).some(property =>
|
|
21
|
+
modelBoundPropertyKeys.some(key => hasPropertyMetadata(key, type.prototype, property)));
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
/**
|
|
12
25
|
* Encapsulates discovery and registry operations for decorator-based artifacts.
|
|
13
26
|
*/
|
|
@@ -38,7 +51,15 @@ export class TypeDiscoverer {
|
|
|
38
51
|
const patterns = Array.isArray(pattern) ? pattern : [pattern];
|
|
39
52
|
const files = await this._glob(patterns);
|
|
40
53
|
for (const file of files) {
|
|
41
|
-
await this._importFile(path.resolve(file));
|
|
54
|
+
const module = await this._importFile(path.resolve(file));
|
|
55
|
+
if (module && typeof module === 'object') {
|
|
56
|
+
for (const type of Object.values(module)) {
|
|
57
|
+
if (typeof type !== 'function' || !type.prototype) continue;
|
|
58
|
+
if (hasModelBoundProperties(type)) {
|
|
59
|
+
this.register(DecoratorType.ReadModel, type as Constructor);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
|
@@ -51,7 +72,22 @@ export class TypeDiscoverer {
|
|
|
51
72
|
register(decoratorType: DecoratorType, type: Constructor, name?: string): void {
|
|
52
73
|
const discoveredName = name ?? type.name;
|
|
53
74
|
const typesForDecorator = TypeDiscoverer._registeredTypes.get(decoratorType) ?? new Map<string, Constructor>();
|
|
54
|
-
|
|
75
|
+
if (decoratorType === DecoratorType.ReadModel) {
|
|
76
|
+
const previous = Array.from(typesForDecorator).find(([, registered]) => registered === type);
|
|
77
|
+
if (previous && previous[0] !== discoveredName) {
|
|
78
|
+
// A named @readModel overrides the type-name alias registered by an observer.
|
|
79
|
+
if (previous[0] !== type.name) return;
|
|
80
|
+
typesForDecorator.delete(previous[0]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let key = discoveredName;
|
|
84
|
+
if (decoratorType === DecoratorType.ReadModel && typesForDecorator.has(key) && typesForDecorator.get(key) !== type &&
|
|
85
|
+
!(type.prototype instanceof typesForDecorator.get(key)!)) {
|
|
86
|
+
let suffix = 2;
|
|
87
|
+
while (typesForDecorator.has(`${discoveredName}#${suffix}`)) suffix++;
|
|
88
|
+
key = `${discoveredName}#${suffix}`;
|
|
89
|
+
}
|
|
90
|
+
typesForDecorator.set(key, type);
|
|
55
91
|
TypeDiscoverer._registeredTypes.set(decoratorType, typesForDecorator);
|
|
56
92
|
}
|
|
57
93
|
|