@cratis/chronicle 6.5.1 → 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.
Files changed (78) hide show
  1. package/ChronicleClient.ts +1 -1
  2. package/ChronicleOptions.ts +9 -1
  3. package/EventStore.ts +4 -2
  4. package/artifacts/DefaultClientArtifactsProvider.ts +12 -1
  5. package/dist/ChronicleClient.js +1 -1
  6. package/dist/ChronicleClient.js.map +1 -1
  7. package/dist/ChronicleOptions.d.ts +4 -0
  8. package/dist/ChronicleOptions.d.ts.map +1 -1
  9. package/dist/ChronicleOptions.js +5 -1
  10. package/dist/ChronicleOptions.js.map +1 -1
  11. package/dist/EventStore.d.ts +2 -1
  12. package/dist/EventStore.d.ts.map +1 -1
  13. package/dist/EventStore.js +2 -2
  14. package/dist/EventStore.js.map +1 -1
  15. package/dist/artifacts/DefaultClientArtifactsProvider.d.ts.map +1 -1
  16. package/dist/artifacts/DefaultClientArtifactsProvider.js +14 -1
  17. package/dist/artifacts/DefaultClientArtifactsProvider.js.map +1 -1
  18. package/dist/events/eventTypeDecorator.d.ts +6 -1
  19. package/dist/events/eventTypeDecorator.d.ts.map +1 -1
  20. package/dist/events/eventTypeDecorator.js.map +1 -1
  21. package/dist/projections/Projections.d.ts.map +1 -1
  22. package/dist/projections/Projections.js +31 -14
  23. package/dist/projections/Projections.js.map +1 -1
  24. package/dist/projections/modelBound/fromEvent.d.ts.map +1 -1
  25. package/dist/projections/modelBound/fromEvent.js +2 -0
  26. package/dist/projections/modelBound/fromEvent.js.map +1 -1
  27. package/dist/reactors/ReactorResultHandler.d.ts +4 -0
  28. package/dist/reactors/ReactorResultHandler.d.ts.map +1 -0
  29. package/dist/reactors/ReactorResultHandler.js +4 -0
  30. package/dist/reactors/ReactorResultHandler.js.map +1 -0
  31. package/dist/reactors/ReactorSideEffects.d.ts +4 -0
  32. package/dist/reactors/ReactorSideEffects.d.ts.map +1 -1
  33. package/dist/reactors/ReactorSideEffects.js +8 -0
  34. package/dist/reactors/ReactorSideEffects.js.map +1 -1
  35. package/dist/reactors/Reactors.d.ts +3 -1
  36. package/dist/reactors/Reactors.d.ts.map +1 -1
  37. package/dist/reactors/Reactors.js +5 -6
  38. package/dist/reactors/Reactors.js.map +1 -1
  39. package/dist/reactors/index.d.ts +1 -0
  40. package/dist/reactors/index.d.ts.map +1 -1
  41. package/dist/readModels/ReadModels.d.ts +1 -0
  42. package/dist/readModels/ReadModels.d.ts.map +1 -1
  43. package/dist/readModels/ReadModels.js +22 -16
  44. package/dist/readModels/ReadModels.js.map +1 -1
  45. package/dist/readModels/assertUniqueReadModelIds.d.ts +4 -0
  46. package/dist/readModels/assertUniqueReadModelIds.d.ts.map +1 -0
  47. package/dist/readModels/assertUniqueReadModelIds.js +16 -0
  48. package/dist/readModels/assertUniqueReadModelIds.js.map +1 -0
  49. package/dist/readModels/readModel.d.ts +5 -0
  50. package/dist/readModels/readModel.d.ts.map +1 -1
  51. package/dist/readModels/readModel.js +14 -0
  52. package/dist/readModels/readModel.js.map +1 -1
  53. package/dist/reducers/Reducers.d.ts.map +1 -1
  54. package/dist/reducers/Reducers.js +11 -1
  55. package/dist/reducers/Reducers.js.map +1 -1
  56. package/dist/tsconfig.tsbuildinfo +1 -1
  57. package/dist/types/TypeDiscoverer.d.ts +2 -0
  58. package/dist/types/TypeDiscoverer.d.ts.map +1 -1
  59. package/dist/types/TypeDiscoverer.js +39 -2
  60. package/dist/types/TypeDiscoverer.js.map +1 -1
  61. package/events/constructorRoundTrip.legacy.spec.ts +47 -0
  62. package/events/constructorRoundTrip.standard.spec.ts +47 -0
  63. package/events/eventTypeDecorator.ts +6 -1
  64. package/package.json +1 -1
  65. package/projections/Projections.ts +31 -16
  66. package/projections/modelBound/fromEvent.ts +3 -0
  67. package/reactors/ReactorResultHandler.ts +8 -0
  68. package/reactors/ReactorSideEffects.ts +11 -0
  69. package/reactors/Reactors.ts +6 -11
  70. package/reactors/dispatchReactorSideEffects.spec.ts +41 -0
  71. package/reactors/index.ts +1 -0
  72. package/readModels/ReadModels.ts +23 -17
  73. package/readModels/assertUniqueReadModelIds.ts +18 -0
  74. package/readModels/discovery.spec.ts +94 -0
  75. package/readModels/readModel.ts +14 -0
  76. package/reducers/Reducers.spec.ts +18 -0
  77. package/reducers/Reducers.ts +10 -1
  78. 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 || byReadModel.has(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
- for (const type of this._clientArtifacts.readModels) {
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 (!metadata) {
386
- continue;
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
- const rm = getReadModelMetadata(metadata.readModelType);
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
- const readModelMetadata = getReadModelMetadata(type);
655
-
656
- if (readModelMetadata && hasFromEventMetadata(type)) {
670
+ if (hasFromEventMetadata(type) || hasModelBoundProperties(type)) {
671
+ const identifier = getReadModelId(type);
657
672
  return {
658
- id: new ProjectionId(readModelMetadata.id.value),
673
+ id: new ProjectionId(identifier),
659
674
  eventSequenceId: getEventSequenceMetadata(type),
660
- readModelIdentifier: readModelMetadata.id.value
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
 
@@ -0,0 +1,8 @@
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 { EventContext } from '../events/EventContext.js';
5
+
6
+ /** Optional application hook for a reactor's returned result. Return true only when fully handled; throw on failure. */
7
+ export type ReactorResultHandler = (result: unknown, context: EventContext, reactorType: Function,
8
+ eventStore: string, namespace: string) => boolean | Promise<boolean>;
@@ -4,6 +4,8 @@
4
4
  import { hasEventType } from '../events/eventTypeDecorator.js';
5
5
  import type { EventForEventSourceId } from '../eventSequences/EventForEventSourceId.js';
6
6
  import type { IEventLog } from '../eventSequences/IEventLog.js';
7
+ import type { EventContext } from '../events/EventContext.js';
8
+ import type { ReactorResultHandler } from './ReactorResultHandler.js';
7
9
 
8
10
  /**
9
11
  * Represents the outcome of appending a reactor handler's returned side-effect events.
@@ -18,6 +20,15 @@ export interface ReactorSideEffectResult {
18
20
 
19
21
  const noSideEffects: ReactorSideEffectResult = { isSuccess: true, errors: [] };
20
22
 
23
+ /** Dispatch to the optional application hook first, preserving the SDK event append when it declines. */
24
+ export async function dispatchReactorSideEffects(eventLog: IEventLog, result: unknown, context: EventContext,
25
+ reactorType: Function, eventStore: string, namespace: string, handler?: ReactorResultHandler): Promise<void> {
26
+ if (await handler?.(result, context, reactorType, eventStore, namespace)) return;
27
+ const appended = await appendReactorSideEffects(eventLog, result, context.eventSourceId,
28
+ context.eventStreamType ?? 'Default', context.eventStreamId ?? context.eventSourceId);
29
+ if (!appended.isSuccess) throw new Error(`Reactor side effect failed to append: ${appended.errors.join('; ')}`);
30
+ }
31
+
21
32
  /**
22
33
  * Determines whether a value is shaped like an {@link EventForEventSourceId} — a plain object
23
34
  * carrying its own `eventSourceId` and `event` — rather than an event object returned directly.
@@ -16,8 +16,9 @@ import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
16
16
  import type { IEventLog } from '../eventSequences/IEventLog.js';
17
17
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
18
18
  import { IReactors } from './IReactors.js';
19
- import { appendReactorSideEffects } from './ReactorSideEffects.js';
19
+ import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
20
20
  import { getReactorMetadata } from './reactor.js';
21
+ import type { ReactorResultHandler } from './ReactorResultHandler.js';
21
22
 
22
23
  /** Expression used to partition reactor observations by event source ID. */
23
24
  const EVENT_SOURCE_ID_KEY = '$eventSourceId';
@@ -118,7 +119,8 @@ export class Reactors implements IReactors {
118
119
  private readonly _eventStoreName: string,
119
120
  private readonly _namespace: string,
120
121
  lifecycle: ConnectionLifecycle,
121
- private readonly _eventLog: IEventLog
122
+ private readonly _eventLog: IEventLog,
123
+ private readonly _resultHandler?: ReactorResultHandler
122
124
  ) {
123
125
  this._lifecycle = lifecycle;
124
126
  lifecycle.onDisconnected(async () => {
@@ -303,15 +305,8 @@ export class Reactors implements IReactors {
303
305
  });
304
306
 
305
307
  const handlerResult = await reactorInstance[entry.methodName](content, context);
306
- const sideEffectResult = await appendReactorSideEffects(
307
- this._eventLog,
308
- handlerResult,
309
- event.Context!.EventSourceId,
310
- event.Context!.EventStreamType,
311
- event.Context!.EventStreamId);
312
- if (!sideEffectResult.isSuccess) {
313
- throw new Error(`Reactor side effect failed to append: ${sideEffectResult.errors.join('; ')}`);
314
- }
308
+ await dispatchReactorSideEffects(this._eventLog, handlerResult, context, reactorType as Function,
309
+ this._eventStoreName, this._namespace, this._resultHandler);
315
310
 
316
311
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
317
312
  } catch (err) {
@@ -0,0 +1,41 @@
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 { describe, expect, it, vi } from 'vitest';
5
+ import type { IEventLog } from '../eventSequences/IEventLog.js';
6
+ import type { EventContext } from '../events/EventContext.js';
7
+ import { eventType } from '../events/eventTypeDecorator.js';
8
+ import { dispatchReactorSideEffects } from './ReactorSideEffects.js';
9
+
10
+ class Triggered {}
11
+ eventType('bb382cad-9aa9-4da6-a6de-90b9867d1aa0')(Triggered);
12
+ const context = { eventSourceId: 'id', eventStreamId: 'id', eventStreamType: 'Default' } as EventContext;
13
+ const reactor = class Reactor {};
14
+
15
+ function fixture() {
16
+ const appendMany = vi.fn().mockResolvedValue([{ isSuccess: true, errors: [], constraintViolations: [] }]);
17
+ return { appendMany, eventLog: { appendMany } as unknown as IEventLog };
18
+ }
19
+
20
+ describe('when dispatching a reactor result', () => {
21
+ it('should let the application handle the result before an event append', async () => {
22
+ const { eventLog, appendMany } = fixture();
23
+ const handler = vi.fn().mockResolvedValue(true);
24
+ const result = new Triggered();
25
+ await dispatchReactorSideEffects(eventLog, result, context, reactor, 'store', 'tenant', handler);
26
+ expect(handler).toHaveBeenCalledWith(result, context, reactor, 'store', 'tenant');
27
+ expect(appendMany).not.toHaveBeenCalled();
28
+ });
29
+ it('should append events when the application declines', async () => {
30
+ const { eventLog, appendMany } = fixture();
31
+ await dispatchReactorSideEffects(eventLog, new Triggered(), context, reactor, 'store', 'tenant', () => false);
32
+ expect(appendMany).toHaveBeenCalledTimes(1);
33
+ });
34
+ it('should propagate a handler failure without appending events', async () => {
35
+ const { eventLog, appendMany } = fixture();
36
+ await expect(dispatchReactorSideEffects(eventLog, new Triggered(), context, reactor, 'store', 'tenant', () => {
37
+ throw new Error('Command failed');
38
+ })).rejects.toThrow('Command failed');
39
+ expect(appendMany).not.toHaveBeenCalled();
40
+ });
41
+ });
package/reactors/index.ts CHANGED
@@ -6,3 +6,4 @@ export { reactor, getReactorMetadata, isReactor } from './reactor.js';
6
6
  export type { ReactorMetadata } from './reactor.js';
7
7
  export type { IReactors } from './IReactors.js';
8
8
  export { Reactors } from './Reactors.js';
9
+ export type { ReactorResultHandler } from './ReactorResultHandler.js';
@@ -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,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
+ });
@@ -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);
@@ -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 getReadModelMetadata(metadata.readModel)?.id.value ?? metadata.readModel.name;
293
+ return getReadModelId(metadata.readModel);
285
294
  }
286
295
 
287
296
  return (reducerType as Function).name;
@@ -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
- typesForDecorator.set(discoveredName, type);
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