@cratis/chronicle 6.6.0 → 6.7.1
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/README.md +3 -1
- package/artifacts/DefaultClientArtifactsProvider.ts +12 -1
- package/artifacts/validateArtifactSchemas.spec.ts +3 -3
- package/compliance/pii.ts +2 -2
- package/compliance/subject.ts +1 -1
- package/confidentiality/encrypted.ts +1 -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/compliance/pii.d.ts +2 -2
- package/dist/compliance/pii.js +2 -2
- package/dist/compliance/subject.d.ts +1 -1
- package/dist/compliance/subject.js +1 -1
- package/dist/confidentiality/encrypted.d.ts +1 -1
- package/dist/confidentiality/encrypted.js +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 +32 -15
- 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 +23 -17
- 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 +12 -2
- 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.childrenAndNested.spec.ts +0 -2
- package/projections/Projections.modelBoundCompleteness.spec.ts +0 -7
- package/projections/Projections.operationalSurface.spec.ts +0 -4
- package/projections/Projections.spec.ts +0 -2
- package/projections/Projections.ts +32 -17
- package/projections/Projections.variants.spec.ts +0 -5
- package/projections/modelBound/fromEvent.ts +3 -0
- package/readModels/MaterializedReadModels.spec.ts +0 -5
- package/readModels/ReadModels.spec.ts +0 -9
- package/readModels/ReadModels.ts +24 -18
- package/readModels/assertUniqueReadModelIds.ts +18 -0
- package/readModels/discovery.spec.ts +113 -0
- package/readModels/readModel.ts +14 -0
- package/readModels/standardFallbacks.standard.spec.ts +1 -1
- package/reducers/Reducers.spec.ts +18 -0
- package/reducers/Reducers.ts +11 -2
- package/schemas/JsonSchemaGenerator.confidentiality.spec.ts +5 -14
- package/schemas/JsonSchemaGenerator.spec.ts +3 -13
- package/schemas/decorators.legacy.spec.ts +2 -3
- package/schemas/decorators.standard.spec.ts +12 -10
- package/types/TypeDiscoverer.ts +38 -2
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,10 +312,19 @@ 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) {
|
|
321
|
-
throw new Error(`Unknown read model '${readModelType.name}'. Make sure it is
|
|
327
|
+
throw new Error(`Unknown read model '${readModelType.name}'. Make sure it is discoverable through a projection, reducer, or model-bound mapping.`);
|
|
322
328
|
}
|
|
323
329
|
|
|
324
330
|
return 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,113 @@
|
|
|
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 custom identifiers', async () => {
|
|
71
|
+
const provider = DefaultClientArtifactsProvider.default;
|
|
72
|
+
for (const type of [ModelBound, Projected, Reduced]) {
|
|
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']) {
|
|
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 preserve the deprecated decorator identifier, schema, and discovery', async () => {
|
|
87
|
+
const provider = DefaultClientArtifactsProvider.default;
|
|
88
|
+
expect(provider.readModels).toContain(Legacy);
|
|
89
|
+
const { connection, registerMany } = createConnection();
|
|
90
|
+
await new ReadModels('store', 'Default', connection, provider, 'sink').register();
|
|
91
|
+
const definition = registerMany.mock.calls[0][0].ReadModels.find(
|
|
92
|
+
(model: { Type: { Identifier: string } }) => model.Type.Identifier === 'legacy-id');
|
|
93
|
+
expect(definition).toBeDefined();
|
|
94
|
+
expect(JSON.parse(definition.Schema).properties.value.type).toBe('string');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should explain how to discover an unknown model', async () => {
|
|
98
|
+
class Unknown {}
|
|
99
|
+
const provider = { ...DefaultClientArtifactsProvider.default, readModels: [], projections: [], reducers: [] };
|
|
100
|
+
const { connection } = createConnection();
|
|
101
|
+
await expect(new ReadModels('store', 'Default', connection, provider, 'sink').findInstanceById(Unknown, 'key'))
|
|
102
|
+
.rejects.toThrow('Make sure it is discoverable through a projection, reducer, or model-bound mapping.');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should reject two distinct models with the same identifier before registration', async () => {
|
|
106
|
+
class First { static readonly readModelId = 'collision'; }
|
|
107
|
+
class Second { static readonly readModelId = 'collision'; }
|
|
108
|
+
const provider = { ...DefaultClientArtifactsProvider.default, readModels: [First, Second], projections: [], reducers: [] };
|
|
109
|
+
const { connection, registerMany } = createConnection();
|
|
110
|
+
await expect(new ReadModels('store', 'Default', connection, provider, 'sink').register()).rejects.toThrow(/collision/);
|
|
111
|
+
expect(registerMany).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
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
|
+
}
|
|
@@ -21,7 +21,7 @@ const artifacts = {
|
|
|
21
21
|
constraints: [], projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
|
|
22
22
|
} as IClientArtifactsProvider;
|
|
23
23
|
|
|
24
|
-
describe('
|
|
24
|
+
describe('inferred read models with standard decorators', () => {
|
|
25
25
|
it('rejects an untyped array before registering a reducer read model', async () => {
|
|
26
26
|
const registerMany = vi.fn();
|
|
27
27
|
const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
|
|
@@ -89,6 +89,24 @@ function createConnection() {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
describe('Reducers', () => {
|
|
92
|
+
describe('when a reducer produces an inferred model', () => {
|
|
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,
|
|
@@ -204,7 +213,7 @@ export class Reducers implements IReducers {
|
|
|
204
213
|
if (readModelMeta?.schema) {
|
|
205
214
|
return JSON.stringify(readModelMeta.schema);
|
|
206
215
|
}
|
|
207
|
-
//
|
|
216
|
+
// Generate the schema directly from the inferred model type.
|
|
208
217
|
return JSON.stringify(JsonSchemaGenerator.generate(metadata.readModel));
|
|
209
218
|
}
|
|
210
219
|
|
|
@@ -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;
|
|
@@ -8,14 +8,13 @@ import { pii } from '../compliance/pii.js';
|
|
|
8
8
|
import { encrypted } from '../confidentiality/encrypted.js';
|
|
9
9
|
import { EncryptionScope } from '../confidentiality/EncryptionScope.js';
|
|
10
10
|
import { PIIAndEncryptedCombinedNotSupported } from '../confidentiality/PIIAndEncryptedCombinedNotSupported.js';
|
|
11
|
-
import {
|
|
11
|
+
import { JsonSchemaGenerator } from './JsonSchemaGenerator.js';
|
|
12
12
|
import { JsonSchema } from './JsonSchema.js';
|
|
13
13
|
|
|
14
|
-
// See JsonSchemaGenerator.spec.ts for why decorators are applied as plain function calls here
|
|
15
|
-
// rather than `@decorator` syntax, and why order matters.
|
|
14
|
+
// See JsonSchemaGenerator.spec.ts for why decorators are applied as plain function calls here.
|
|
16
15
|
|
|
17
16
|
function schemaFor(target: Function): JsonSchema {
|
|
18
|
-
return
|
|
17
|
+
return JsonSchemaGenerator.generate(target);
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
describe('JsonSchemaGenerator - security metadata', () => {
|
|
@@ -26,7 +25,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
26
25
|
}
|
|
27
26
|
encrypted()(PartnerCredentials);
|
|
28
27
|
|
|
29
|
-
readModel()(PartnerCredentials);
|
|
30
28
|
const schema = schemaFor(PartnerCredentials);
|
|
31
29
|
|
|
32
30
|
it('should mark every one of its own properties as EncryptedSubject', () => {
|
|
@@ -46,7 +44,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
46
44
|
name = '';
|
|
47
45
|
}
|
|
48
46
|
encrypted(EncryptionScope.Subject, 'Partner API key')(Integration.prototype, 'apiKey');
|
|
49
|
-
readModel()(Integration);
|
|
50
47
|
const schema = schemaFor(Integration);
|
|
51
48
|
|
|
52
49
|
it('should mark the decorated property as EncryptedSubject', () => {
|
|
@@ -67,7 +64,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
67
64
|
value = '';
|
|
68
65
|
}
|
|
69
66
|
encrypted(scope)(Secret.prototype, 'value');
|
|
70
|
-
readModel()(Secret);
|
|
71
67
|
const schema = schemaFor(Secret);
|
|
72
68
|
|
|
73
69
|
it(`should resolve to metadataType ${expectedMetadataType}`, () => {
|
|
@@ -88,7 +84,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
88
84
|
class Configuration {
|
|
89
85
|
key: ApiKey = new ApiKey('');
|
|
90
86
|
}
|
|
91
|
-
readModel()(Configuration);
|
|
92
87
|
const schema = schemaFor(Configuration);
|
|
93
88
|
|
|
94
89
|
it('should mark the concept-typed property as EncryptedNamespace', () => {
|
|
@@ -105,7 +100,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
105
100
|
contact: ContactDetails = new ContactDetails();
|
|
106
101
|
}
|
|
107
102
|
encrypted(EncryptionScope.Subject, 'Vendor contact information')(Vendor.prototype, 'contact');
|
|
108
|
-
readModel()(Vendor);
|
|
109
103
|
const schema = schemaFor(Vendor);
|
|
110
104
|
|
|
111
105
|
it('should push the security metadata down onto every leaf property', () => {
|
|
@@ -130,7 +124,6 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
130
124
|
tokens: Token[] = [];
|
|
131
125
|
}
|
|
132
126
|
field(Array, { enumerable: true, genericArguments: [Token] })(Session.prototype, 'tokens');
|
|
133
|
-
readModel()(Session);
|
|
134
127
|
const schema = schemaFor(Session);
|
|
135
128
|
|
|
136
129
|
it('should carry the element concept security metadata onto the item schema', () => {
|
|
@@ -150,7 +143,7 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
150
143
|
encrypted()(ConflictedValue.prototype, 'value');
|
|
151
144
|
|
|
152
145
|
it('should throw PIIAndEncryptedCombinedNotSupported when generating the schema', () => {
|
|
153
|
-
expect(() =>
|
|
146
|
+
expect(() => schemaFor(ConflictedValue)).toThrow(PIIAndEncryptedCombinedNotSupported);
|
|
154
147
|
});
|
|
155
148
|
});
|
|
156
149
|
|
|
@@ -162,7 +155,7 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
162
155
|
encrypted()(MixedClass.prototype, 'value');
|
|
163
156
|
|
|
164
157
|
it('should throw PIIAndEncryptedCombinedNotSupported when generating the schema', () => {
|
|
165
|
-
expect(() =>
|
|
158
|
+
expect(() => schemaFor(MixedClass)).toThrow(PIIAndEncryptedCombinedNotSupported);
|
|
166
159
|
});
|
|
167
160
|
});
|
|
168
161
|
|
|
@@ -171,13 +164,11 @@ describe('JsonSchemaGenerator - security metadata', () => {
|
|
|
171
164
|
ssn = '';
|
|
172
165
|
}
|
|
173
166
|
pii()(OnlyPii.prototype, 'ssn');
|
|
174
|
-
readModel()(OnlyPii);
|
|
175
167
|
|
|
176
168
|
class OnlyEncrypted {
|
|
177
169
|
apiKey = '';
|
|
178
170
|
}
|
|
179
171
|
encrypted()(OnlyEncrypted.prototype, 'apiKey');
|
|
180
|
-
readModel()(OnlyEncrypted);
|
|
181
172
|
|
|
182
173
|
it('should not throw for either type', () => {
|
|
183
174
|
expect(() => schemaFor(OnlyPii)).not.toThrow();
|
|
@@ -6,19 +6,17 @@ import { ConceptAs, field, Guid } from '@cratis/fundamentals';
|
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import { pii } from '../compliance/pii.js';
|
|
8
8
|
import { eventType, getEventTypeJsonSchemaFor } from '../events/eventTypeDecorator.js';
|
|
9
|
-
import {
|
|
9
|
+
import { JsonSchemaGenerator } from './JsonSchemaGenerator.js';
|
|
10
10
|
import { JsonSchema } from './JsonSchema.js';
|
|
11
11
|
|
|
12
12
|
// Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
|
|
13
13
|
// fixtures don't depend on the test runner's decorator-syntax support - they exercise exactly
|
|
14
14
|
// the same decorator functions and metadata storage that `@decorator` syntax would invoke.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
// apply bottom-to-top, so `pii()` must run - and therefore be called - before `readModel()`/
|
|
18
|
-
// `eventType()` so the compliance metadata already exists when the schema is generated.
|
|
16
|
+
// Apply `pii()` before generating the schema so compliance metadata is available.
|
|
19
17
|
|
|
20
18
|
function schemaFor(target: Function): JsonSchema {
|
|
21
|
-
return
|
|
19
|
+
return JsonSchemaGenerator.generate(target);
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
describe('JsonSchemaGenerator', () => {
|
|
@@ -27,7 +25,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
27
25
|
class LegacyModel {
|
|
28
26
|
code = new LegacyCode('A');
|
|
29
27
|
}
|
|
30
|
-
readModel()(LegacyModel);
|
|
31
28
|
expect(schemaFor(LegacyModel).properties?.code.type).toBe('string');
|
|
32
29
|
});
|
|
33
30
|
|
|
@@ -53,7 +50,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
53
50
|
email = '';
|
|
54
51
|
}
|
|
55
52
|
pii()(PersonProfile);
|
|
56
|
-
readModel()(PersonProfile);
|
|
57
53
|
|
|
58
54
|
const schema = schemaFor(PersonProfile);
|
|
59
55
|
|
|
@@ -69,7 +65,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
69
65
|
ssn = '';
|
|
70
66
|
}
|
|
71
67
|
pii('Social security number')(Contact.prototype, 'ssn');
|
|
72
|
-
readModel()(Contact);
|
|
73
68
|
|
|
74
69
|
const schema = schemaFor(Contact);
|
|
75
70
|
|
|
@@ -96,7 +91,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
96
91
|
class Customer {
|
|
97
92
|
email: EmailAddress = new EmailAddress('');
|
|
98
93
|
}
|
|
99
|
-
readModel()(Customer);
|
|
100
94
|
|
|
101
95
|
const schema = schemaFor(Customer);
|
|
102
96
|
|
|
@@ -130,7 +124,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
130
124
|
}
|
|
131
125
|
pii()(Employee.prototype, 'name');
|
|
132
126
|
pii('Every field on this record is personal')(Employee);
|
|
133
|
-
readModel()(Employee);
|
|
134
127
|
|
|
135
128
|
const schema = schemaFor(Employee);
|
|
136
129
|
|
|
@@ -156,7 +149,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
156
149
|
contact: ContactDetails = new ContactDetails();
|
|
157
150
|
}
|
|
158
151
|
pii('Vendor contact information')(Vendor.prototype, 'contact');
|
|
159
|
-
readModel()(Vendor);
|
|
160
152
|
|
|
161
153
|
const schema = schemaFor(Vendor);
|
|
162
154
|
|
|
@@ -182,7 +174,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
182
174
|
codes: RequirementCode[] = [];
|
|
183
175
|
}
|
|
184
176
|
field(Array, { enumerable: true, genericArguments: [RequirementCode] })(Contract.prototype, 'codes');
|
|
185
|
-
readModel()(Contract);
|
|
186
177
|
|
|
187
178
|
const schema = schemaFor(Contract);
|
|
188
179
|
|
|
@@ -211,7 +202,6 @@ describe('JsonSchemaGenerator', () => {
|
|
|
211
202
|
items: PlainItem[] = [];
|
|
212
203
|
}
|
|
213
204
|
field(Array, { enumerable: true, genericArguments: [PlainItem] })(Basket.prototype, 'items');
|
|
214
|
-
readModel()(Basket);
|
|
215
205
|
|
|
216
206
|
const schema = schemaFor(Basket);
|
|
217
207
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ConceptAs, field, Guid } from '@cratis/fundamentals';
|
|
5
5
|
import { describe, expect, it } from 'vitest';
|
|
6
6
|
import { eventType, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
7
|
-
import {
|
|
7
|
+
import { JsonSchemaGenerator } from './JsonSchemaGenerator.js';
|
|
8
8
|
import { expectedProperties } from './decorators.expected.fixture.js';
|
|
9
9
|
import { getProjectionMetadata, projection } from '../projections/declarative/projection.js';
|
|
10
10
|
import { getReducerMetadata, reducer } from '../reducers/reducer.js';
|
|
@@ -35,7 +35,6 @@ class LegacyEvent {
|
|
|
35
35
|
@field(Quantity) quantity!: Quantity;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
@readModel('legacy-model')
|
|
39
38
|
class LegacyModel {
|
|
40
39
|
@field(Number) count!: number;
|
|
41
40
|
}
|
|
@@ -52,7 +51,7 @@ class LegacyReactor {}
|
|
|
52
51
|
describe('legacy decorator syntax', () => {
|
|
53
52
|
it('registers the same schemas as standard decorators', () => {
|
|
54
53
|
expect(getEventTypeMetadata(LegacyEvent)?.eventType.id.value).toBe('legacy-fixture');
|
|
55
|
-
expect(
|
|
54
|
+
expect(JsonSchemaGenerator.generate(LegacyModel).properties?.count.type).toBe('number');
|
|
56
55
|
expect(getEventTypeMetadata(LegacyEvent)?.schema.properties).toEqual(expectedProperties);
|
|
57
56
|
expect(getEventTypeMetadata(LegacyEvent)?.schema.required).toEqual(Object.keys(expectedProperties));
|
|
58
57
|
expect(getProjectionMetadata(LegacyProjection)?.id.value).toBe('legacy-projection');
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ConceptAs, field, Guid, typeKey } from '@cratis/fundamentals';
|
|
5
5
|
import { describe, expect, it } from 'vitest';
|
|
6
6
|
import { eventType, getEventTypeMetadata } from '../events/eventTypeDecorator.js';
|
|
7
|
-
import {
|
|
7
|
+
import { getReadModelId } from '../readModels/readModel.js';
|
|
8
8
|
import { getSetFromMetadata, setFrom } from '../projections/modelBound/setFrom.js';
|
|
9
9
|
import { getProjectionMetadata, projection } from '../projections/declarative/projection.js';
|
|
10
10
|
import { getReducerMetadata, reducer } from '../reducers/reducer.js';
|
|
@@ -39,8 +39,8 @@ class ReplacedEvent {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
@replaceClass
|
|
42
|
-
@readModel('replaced-model')
|
|
43
42
|
class ReplacedModel {
|
|
43
|
+
static readonly readModelId = 'replaced-model';
|
|
44
44
|
@field(Number) amount!: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -57,7 +57,6 @@ class StandardEvent {
|
|
|
57
57
|
@field(Quantity) quantity!: Quantity;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
@readModel('standard-model')
|
|
61
60
|
class StandardModel {
|
|
62
61
|
@field(Number)
|
|
63
62
|
@setFrom(StandardEvent)
|
|
@@ -87,7 +86,6 @@ class ChildMapping extends ParentMapping {
|
|
|
87
86
|
@eventType('empty-schema')
|
|
88
87
|
class MissingMembers {}
|
|
89
88
|
|
|
90
|
-
@readModel('empty-model')
|
|
91
89
|
class EmptyModel {}
|
|
92
90
|
|
|
93
91
|
@eventType('missing-field')
|
|
@@ -145,6 +143,9 @@ class UntypedArray {
|
|
|
145
143
|
@projection('standard-projection', StandardModel)
|
|
146
144
|
class StandardProjection {}
|
|
147
145
|
|
|
146
|
+
@projection('replaced-model-projection', ReplacedModel)
|
|
147
|
+
class ReplacedModelProjection {}
|
|
148
|
+
|
|
148
149
|
@reducer('standard-reducer', undefined, StandardModel)
|
|
149
150
|
class StandardReducer {}
|
|
150
151
|
|
|
@@ -157,20 +158,21 @@ const standardSchema = getEventTypeMetadata(StandardEvent)!.schema;
|
|
|
157
158
|
describe('standard decorator syntax', () => {
|
|
158
159
|
it('registers the event and read model with their declared member types', () => {
|
|
159
160
|
expect(getEventTypeMetadata(StandardEvent)?.eventType.id.value).toBe('standard-fixture');
|
|
160
|
-
expect(
|
|
161
|
+
expect(JsonSchemaGenerator.generate(StandardModel).properties?.count.type).toBe('number');
|
|
161
162
|
expect(getSetFromMetadata(StandardModel.prototype, 'count')).toEqual([{ eventType: StandardEvent, eventPropertyName: undefined }]);
|
|
162
163
|
expect(getSubjectPropertyName(StandardModel)).toBe('ownerId');
|
|
163
|
-
expect(
|
|
164
|
-
expect(
|
|
164
|
+
expect(JsonSchemaGenerator.generate(StandardModel).properties?.contact.compliance).toEqual([{ metadataType: 'PII', details: 'personal' }]);
|
|
165
|
+
expect(JsonSchemaGenerator.generate(StandardModel).properties?.secret.security).toEqual([{ metadataType: 'EncryptedSubject', details: '' }]);
|
|
165
166
|
expect(standardSchema.properties).toEqual(expectedProperties);
|
|
166
167
|
expect(standardSchema.required).toEqual(Object.keys(expectedProperties));
|
|
167
168
|
});
|
|
168
169
|
|
|
169
170
|
it('resolves schemas on an outer decorator replacement class', () => {
|
|
170
171
|
expect(getEventTypeMetadata(ReplacedEvent)!.schema.properties?.value.type).toBe('string');
|
|
171
|
-
expect(
|
|
172
|
+
expect(JsonSchemaGenerator.generate(ReplacedModel).properties?.amount.type).toBe('number');
|
|
173
|
+
expect(getReadModelId(ReplacedModel)).toBe('replaced-model');
|
|
172
174
|
expect(TypeDiscoverer.default.getTypeByDecoratorTypeAndName(DecoratorType.EventType, 'replaced-event')).toBe(ReplacedEvent);
|
|
173
|
-
expect(
|
|
175
|
+
expect(getProjectionMetadata(ReplacedModelProjection)?.readModelType).toBe(ReplacedModel);
|
|
174
176
|
});
|
|
175
177
|
|
|
176
178
|
it('keeps inherited property metadata copy-on-write', () => {
|
|
@@ -205,7 +207,7 @@ describe('standard decorator syntax', () => {
|
|
|
205
207
|
|
|
206
208
|
it('rejects missing member metadata instead of registering an empty schema', () => {
|
|
207
209
|
expect(getEventTypeMetadata(MissingMembers)!.schema.properties).toEqual({});
|
|
208
|
-
expect(
|
|
210
|
+
expect(JsonSchemaGenerator.generate(EmptyModel).properties).toEqual({});
|
|
209
211
|
expect(() => getEventTypeMetadata(MissingField)!.schema).toThrow(/Cannot determine the type of MissingField.value/);
|
|
210
212
|
});
|
|
211
213
|
|