@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.
Files changed (69) hide show
  1. package/README.md +3 -1
  2. package/artifacts/DefaultClientArtifactsProvider.ts +12 -1
  3. package/artifacts/validateArtifactSchemas.spec.ts +3 -3
  4. package/compliance/pii.ts +2 -2
  5. package/compliance/subject.ts +1 -1
  6. package/confidentiality/encrypted.ts +1 -1
  7. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
  8. package/dist/artifacts/DefaultClientArtifactsProvider.js +14 -1
  9. package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
  10. package/dist/compliance/pii.d.ts +2 -2
  11. package/dist/compliance/pii.js +2 -2
  12. package/dist/compliance/subject.d.ts +1 -1
  13. package/dist/compliance/subject.js +1 -1
  14. package/dist/confidentiality/encrypted.d.ts +1 -1
  15. package/dist/confidentiality/encrypted.js +1 -1
  16. package/dist/events/eventTypeDecorator.d.ts +6 -1
  17. package/dist/events/eventTypeDecorator.d.ts.map +1 -1
  18. package/dist/events/eventTypeDecorator.js.map +1 -1
  19. package/dist/projections/Projections.d.ts.map +1 -1
  20. package/dist/projections/Projections.js +32 -15
  21. package/dist/projections/Projections.js.map +1 -1
  22. package/dist/projections/modelBound/fromEvent.d.ts.map +1 -1
  23. package/dist/projections/modelBound/fromEvent.js +2 -0
  24. package/dist/projections/modelBound/fromEvent.js.map +1 -1
  25. package/dist/readModels/ReadModels.d.ts +1 -0
  26. package/dist/readModels/ReadModels.d.ts.map +1 -1
  27. package/dist/readModels/ReadModels.js +23 -17
  28. package/dist/readModels/ReadModels.js.map +1 -1
  29. package/dist/readModels/assertUniqueReadModelIds.d.ts +4 -0
  30. package/dist/readModels/assertUniqueReadModelIds.d.ts.map +1 -0
  31. package/dist/readModels/assertUniqueReadModelIds.js +16 -0
  32. package/dist/readModels/assertUniqueReadModelIds.js.map +1 -0
  33. package/dist/readModels/readModel.d.ts +5 -0
  34. package/dist/readModels/readModel.d.ts.map +1 -1
  35. package/dist/readModels/readModel.js +14 -0
  36. package/dist/readModels/readModel.js.map +1 -1
  37. package/dist/reducers/Reducers.d.ts.map +1 -1
  38. package/dist/reducers/Reducers.js +12 -2
  39. package/dist/reducers/Reducers.js.map +1 -1
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/dist/types/TypeDiscoverer.d.ts +2 -0
  42. package/dist/types/TypeDiscoverer.d.ts.map +1 -1
  43. package/dist/types/TypeDiscoverer.js +39 -2
  44. package/dist/types/TypeDiscoverer.js.map +1 -1
  45. package/events/constructorRoundTrip.legacy.spec.ts +47 -0
  46. package/events/constructorRoundTrip.standard.spec.ts +47 -0
  47. package/events/eventTypeDecorator.ts +6 -1
  48. package/package.json +1 -1
  49. package/projections/Projections.childrenAndNested.spec.ts +0 -2
  50. package/projections/Projections.modelBoundCompleteness.spec.ts +0 -7
  51. package/projections/Projections.operationalSurface.spec.ts +0 -4
  52. package/projections/Projections.spec.ts +0 -2
  53. package/projections/Projections.ts +32 -17
  54. package/projections/Projections.variants.spec.ts +0 -5
  55. package/projections/modelBound/fromEvent.ts +3 -0
  56. package/readModels/MaterializedReadModels.spec.ts +0 -5
  57. package/readModels/ReadModels.spec.ts +0 -9
  58. package/readModels/ReadModels.ts +24 -18
  59. package/readModels/assertUniqueReadModelIds.ts +18 -0
  60. package/readModels/discovery.spec.ts +113 -0
  61. package/readModels/readModel.ts +14 -0
  62. package/readModels/standardFallbacks.standard.spec.ts +1 -1
  63. package/reducers/Reducers.spec.ts +18 -0
  64. package/reducers/Reducers.ts +11 -2
  65. package/schemas/JsonSchemaGenerator.confidentiality.spec.ts +5 -14
  66. package/schemas/JsonSchemaGenerator.spec.ts +3 -13
  67. package/schemas/decorators.legacy.spec.ts +2 -3
  68. package/schemas/decorators.standard.spec.ts +12 -10
  69. package/types/TypeDiscoverer.ts +38 -2
@@ -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 readModelMetadata = getReadModelMetadata(metadata.readModelType);
255
- const identifier = readModelMetadata?.id.value ?? metadata.readModelType.name;
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 metadata = getReadModelMetadata(modelBoundType);
277
- if (!metadata) {
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: metadata.id.value,
281
+ identifier,
284
282
  eventSequenceId: EventSequenceId.eventLog.value,
285
283
  observerType: ContractReadModelObserverType.Projection,
286
- observerIdentifier: metadata.id.value,
287
- schema: JSON.stringify(metadata.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 readModelMetadata = getReadModelMetadata(metadata.readModel);
303
- const identifier = readModelMetadata?.id.value ?? metadata.readModel.name;
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 decorated and discoverable.`);
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
+ });
@@ -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('standard read models without @readModel', () => {
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);
@@ -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
- // No @readModel() decorator — generate from instance
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 getReadModelMetadata(metadata.readModel)?.id.value ?? metadata.readModel.name;
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 { getReadModelMetadata, readModel } from '../readModels/readModel.js';
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 getReadModelMetadata(target)!.schema;
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(() => { readModel()(ConflictedValue); }).toThrow(PIIAndEncryptedCombinedNotSupported);
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(() => { readModel()(MixedClass); }).toThrow(PIIAndEncryptedCombinedNotSupported);
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 { getReadModelMetadata, readModel } from '../readModels/readModel.js';
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
- // Order matters here in a way it would not for a real `@decorator` stack: class decorators
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 getReadModelMetadata(target)!.schema;
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 { getReadModelMetadata, readModel } from '../readModels/readModel.js';
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(getReadModelMetadata(LegacyModel)?.schema.properties?.count.type).toBe('number');
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 { getReadModelMetadata, readModel } from '../readModels/readModel.js';
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(getReadModelMetadata(StandardModel)?.schema.properties?.count.type).toBe('number');
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(getReadModelMetadata(StandardModel)?.schema.properties?.contact.compliance).toEqual([{ metadataType: 'PII', details: 'personal' }]);
164
- expect(getReadModelMetadata(StandardModel)?.schema.properties?.secret.security).toEqual([{ metadataType: 'EncryptedSubject', details: '' }]);
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(getReadModelMetadata(ReplacedModel)!.schema.properties?.amount.type).toBe('number');
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(TypeDiscoverer.default.getTypeByDecoratorTypeAndName(DecoratorType.ReadModel, 'replaced-model')).toBe(ReplacedModel);
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(getReadModelMetadata(EmptyModel)!.schema.properties).toEqual({});
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