@cratis/chronicle 6.10.0 → 6.12.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 (60) hide show
  1. package/connection/ChronicleConnection.auth.spec.ts +51 -0
  2. package/connection/ChronicleConnection.ts +22 -2
  3. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  4. package/dist/connection/ChronicleConnection.js +26 -2
  5. package/dist/connection/ChronicleConnection.js.map +1 -1
  6. package/dist/eventSequences/AppendResult.d.ts +7 -0
  7. package/dist/eventSequences/AppendResult.d.ts.map +1 -1
  8. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  9. package/dist/eventSequences/EventSequence.js +26 -17
  10. package/dist/eventSequences/EventSequence.js.map +1 -1
  11. package/dist/eventSequences/WaitForCompletionOptions.d.ts +8 -0
  12. package/dist/eventSequences/WaitForCompletionOptions.d.ts.map +1 -0
  13. package/dist/eventSequences/WaitForCompletionOptions.js +4 -0
  14. package/dist/eventSequences/WaitForCompletionOptions.js.map +1 -0
  15. package/dist/eventSequences/index.d.ts +1 -0
  16. package/dist/eventSequences/index.d.ts.map +1 -1
  17. package/dist/eventSequences/index.js.map +1 -1
  18. package/dist/projections/Projections.d.ts +1 -1
  19. package/dist/projections/Projections.d.ts.map +1 -1
  20. package/dist/projections/Projections.js +19 -27
  21. package/dist/projections/Projections.js.map +1 -1
  22. package/dist/readModels/IReadModels.d.ts +3 -2
  23. package/dist/readModels/IReadModels.d.ts.map +1 -1
  24. package/dist/readModels/ReadModels.d.ts +1 -4
  25. package/dist/readModels/ReadModels.d.ts.map +1 -1
  26. package/dist/readModels/ReadModels.js +13 -26
  27. package/dist/readModels/ReadModels.js.map +1 -1
  28. package/dist/readModels/buildReadModelDefinition.d.ts +30 -0
  29. package/dist/readModels/buildReadModelDefinition.d.ts.map +1 -0
  30. package/dist/readModels/buildReadModelDefinition.js +27 -0
  31. package/dist/readModels/buildReadModelDefinition.js.map +1 -0
  32. package/dist/readModels/index.d.ts +1 -0
  33. package/dist/readModels/index.d.ts.map +1 -1
  34. package/dist/readModels/index.js +1 -0
  35. package/dist/readModels/index.js.map +1 -1
  36. package/dist/readModels/indexDecorator.d.ts +16 -0
  37. package/dist/readModels/indexDecorator.d.ts.map +1 -0
  38. package/dist/readModels/indexDecorator.js +55 -0
  39. package/dist/readModels/indexDecorator.js.map +1 -0
  40. package/dist/reducers/Reducers.d.ts.map +1 -1
  41. package/dist/reducers/Reducers.js +9 -20
  42. package/dist/reducers/Reducers.js.map +1 -1
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/eventSequences/AppendResult.ts +8 -0
  45. package/eventSequences/EventSequence.spec.ts +76 -1
  46. package/eventSequences/EventSequence.ts +28 -20
  47. package/eventSequences/WaitForCompletionOptions.ts +11 -0
  48. package/eventSequences/index.ts +1 -0
  49. package/package.json +1 -1
  50. package/projections/Projections.ts +20 -27
  51. package/readModels/IReadModels.ts +3 -2
  52. package/readModels/ReadModels.spec.ts +13 -5
  53. package/readModels/ReadModels.ts +13 -26
  54. package/readModels/buildReadModelDefinition.ts +38 -0
  55. package/readModels/index.ts +1 -0
  56. package/readModels/indexDecorator.legacy.spec.ts +83 -0
  57. package/readModels/indexDecorator.ts +62 -0
  58. package/readModels/indexRegistration.spec.ts +99 -0
  59. package/readModels/standardFallbacks.standard.spec.ts +31 -0
  60. package/reducers/Reducers.ts +10 -20
@@ -50,7 +50,7 @@ describe('ReadModels', () => {
50
50
  class MissingModel { id = ''; }
51
51
  fromEvent(SomeEvent)(MissingModel);
52
52
 
53
- it('should retain the legacy prototype-only instance for getInstanceById', async () => {
53
+ it('should retain a prototype-only instance for getInstanceById when the document is empty', async () => {
54
54
  const { readModels, release } = createReadModels(MissingModel);
55
55
  const instance = await readModels.getInstanceById(MissingModel, 'missing');
56
56
  expect(instance).toBeInstanceOf(MissingModel);
@@ -66,13 +66,21 @@ describe('ReadModels', () => {
66
66
  expect(release).not.toHaveBeenCalled();
67
67
  });
68
68
 
69
- it('should return null when the kernel responds with literal JSON null', async () => {
69
+ it('should return null from findInstanceById when the kernel responds with JSON null', async () => {
70
70
  const { readModels, getInstanceByKey, release } = createReadModels(MissingModel);
71
71
  getInstanceByKey.mockResolvedValue({ ReadModel: 'null' });
72
72
  const instance = await readModels.findInstanceById(MissingModel, 'missing');
73
73
  expect(instance).toBeNull();
74
74
  expect(release).not.toHaveBeenCalled();
75
75
  });
76
+
77
+ it('should retain the constructed default instance from getInstanceById for JSON null', async () => {
78
+ const { readModels, getInstanceByKey } = createReadModels(MissingModel);
79
+ getInstanceByKey.mockResolvedValue({ ReadModel: 'null' });
80
+ const instance = await readModels.getInstanceById(MissingModel, 'missing');
81
+ expect(instance).toBeInstanceOf(MissingModel);
82
+ expect(instance.id).toBe('');
83
+ });
76
84
  });
77
85
 
78
86
  describe('when a read model instance exists for a key', () => {
@@ -86,9 +94,9 @@ describe('ReadModels', () => {
86
94
  const instance = await readModels.findInstanceById(ExistingModel, 'found');
87
95
  expect(instance).toBeInstanceOf(ExistingModel);
88
96
  expect(instance?.id).toBe('found');
89
- const legacyInstance = await readModels.getInstanceById(ExistingModel, 'found');
90
- expect(legacyInstance).toBeInstanceOf(ExistingModel);
91
- expect(legacyInstance.id).toBe('found');
97
+ const canonicalInstance = await readModels.getInstanceById(ExistingModel, 'found');
98
+ expect(canonicalInstance).toBeInstanceOf(ExistingModel);
99
+ expect(canonicalInstance.id).toBe('found');
92
100
  });
93
101
  });
94
102
 
@@ -9,9 +9,8 @@ import {
9
9
  ReadModelObserverType as ContractReadModelObserverType
10
10
  } from '@cratis/chronicle.contracts';
11
11
  import type { Constructor } from '@cratis/fundamentals';
12
- import { Guid, JsonSerializer } from '@cratis/fundamentals';
12
+ import { JsonSerializer } from '@cratis/fundamentals';
13
13
  import { IClientArtifactsProvider } from '../artifacts/index.js';
14
- import { toContractsGuid } from '../connection/Guid.js';
15
14
  import { ChronicleConnection } from '../connection/index.js';
16
15
  import { ensureQuerySuccess } from '../connection/callResults.js';
17
16
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
@@ -23,6 +22,7 @@ import { getReducerMetadata } from '../reducers/reducer.js';
23
22
  import { JsonSchemaGenerator } from '../schemas/index.js';
24
23
  import { WellKnownSinks } from '../sinks/index.js';
25
24
  import { getReadModelMetadata, getReadModelId } from './readModel.js';
25
+ import { buildReadModelDefinition } from './buildReadModelDefinition.js';
26
26
  import { assertUniqueReadModelIds } from './assertUniqueReadModelIds.js';
27
27
  import { rootReadModelTypes } from './rootReadModelTypes.js';
28
28
  import type { IMaterializedReadModels } from './IMaterializedReadModels.js';
@@ -78,10 +78,7 @@ export class ReadModels implements IReadModels {
78
78
  });
79
79
  }
80
80
 
81
- /**
82
- * @inheritdoc
83
- * @deprecated Use {@link findInstanceById} to distinguish an absent instance from a stored one.
84
- */
81
+ /** @inheritdoc */
85
82
  async getInstanceById<TReadModel>(readModelType: Constructor<TReadModel>, key: string, sessionId?: string): Promise<TReadModel> {
86
83
  const readModel = this.resolveReadModel(readModelType);
87
84
  const response = await this._connection.readModels.getInstanceByKey({
@@ -344,26 +341,16 @@ export class ReadModels implements IReadModels {
344
341
 
345
342
 
346
343
  private toDefinition(readModel: ResolvedReadModel) {
347
- return {
348
- Type: {
349
- Identifier: readModel.identifier,
350
- Generation: 1
351
- },
352
- ContainerName: readModel.identifier,
353
- DisplayName: readModel.identifier,
354
- Sink: {
355
- ConfigurationId: toContractsGuid(Guid.empty),
356
- // Passive read models never write to a materialized sink, so they register with the
357
- // None sink and the kernel resolves them via immediate projection instead of an empty sink.
358
- TypeId: readModel.isActive ? this._defaultSinkTypeId : WellKnownSinks.None
359
- },
360
- Schema: readModel.schema,
361
- Indexes: [],
362
- ObserverType: readModel.observerType,
363
- ObserverIdentifier: readModel.observerIdentifier,
364
- Owner: 1,
365
- Source: 1
366
- };
344
+ return buildReadModelDefinition({
345
+ identifier: readModel.identifier,
346
+ type: readModel.type,
347
+ schema: readModel.schema,
348
+ // Passive read models never write to a materialized sink, so they register with the
349
+ // None sink and the kernel resolves them via immediate projection instead of an empty sink.
350
+ sinkTypeId: readModel.isActive ? this._defaultSinkTypeId : WellKnownSinks.None,
351
+ observerType: readModel.observerType,
352
+ observerIdentifier: readModel.observerIdentifier
353
+ });
367
354
  }
368
355
 
369
356
  private getReadModelSchema(readModelType: Constructor, identifier: string): string {
@@ -0,0 +1,38 @@
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 { ReadModelObserverType } from '@cratis/chronicle.contracts';
5
+ import { Guid } from '@cratis/fundamentals';
6
+ import { toContractsGuid } from '../connection/Guid.js';
7
+ import { getIndexesForType } from './indexDecorator.js';
8
+
9
+ interface ReadModelDefinitionOptions {
10
+ identifier: string;
11
+ schema: string;
12
+ type?: Function;
13
+ sinkTypeId: string;
14
+ observerType: ReadModelObserverType;
15
+ observerIdentifier: string;
16
+ }
17
+
18
+ /** Builds the registration payload shared by projections, reducers, and direct read-model registration. */
19
+ export function buildReadModelDefinition(options: ReadModelDefinitionOptions) {
20
+ return {
21
+ Type: {
22
+ Identifier: options.identifier,
23
+ Generation: 1
24
+ },
25
+ ContainerName: options.identifier,
26
+ DisplayName: options.identifier,
27
+ Sink: {
28
+ ConfigurationId: toContractsGuid(Guid.empty),
29
+ TypeId: options.sinkTypeId
30
+ },
31
+ Schema: options.schema,
32
+ Indexes: options.type ? getIndexesForType(options.type) : [],
33
+ ObserverType: options.observerType,
34
+ ObserverIdentifier: options.observerIdentifier,
35
+ Owner: 1,
36
+ Source: 1
37
+ };
38
+ }
@@ -2,6 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  export { ReadModelId } from './ReadModelId.js';
5
+ export { index } from './indexDecorator.js';
5
6
  export type { IReadModels } from './IReadModels.js';
6
7
  export type { IMaterializedReadModels } from './IMaterializedReadModels.js';
7
8
  export { ReadModels } from './ReadModels.js';
@@ -0,0 +1,83 @@
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 type { ChronicleConnection } from '../connection/ChronicleConnection.js';
7
+ import type { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
8
+ import { projection } from '../projections/declarative/projection.js';
9
+ import { reducer } from '../reducers/reducer.js';
10
+ import { ReadModels } from './ReadModels.js';
11
+ import { index } from './indexDecorator.js';
12
+
13
+ class Address {
14
+ @field(String) @index() zipCode!: string;
15
+ }
16
+
17
+ class Line {
18
+ @field(String) @index() productId!: string;
19
+ @field(Number) quantity!: number;
20
+ }
21
+
22
+ class Order {
23
+ @field(String) id!: string;
24
+ @field(String) @index() customerId!: string;
25
+ @field(String) @index() number!: string;
26
+ @field(Number) total!: number;
27
+ @field(Address) address!: Address;
28
+ @field(Array, { genericArguments: [Line] }) lines!: Line[];
29
+ }
30
+
31
+ class ReducedOrder {
32
+ @field(String) @index() number!: string;
33
+ }
34
+
35
+ @projection('indexed-orders', Order)
36
+ class OrderProjection {
37
+ define(): void {}
38
+ }
39
+
40
+ @reducer('reduced-orders', undefined, ReducedOrder)
41
+ class OrderReducer {}
42
+
43
+ const artifacts = {
44
+ eventTypes: [], readModels: [], reactors: [], reducers: [OrderReducer], seeders: [],
45
+ constraints: [], projections: [OrderProjection], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
46
+ } as IClientArtifactsProvider;
47
+
48
+ describe('read-model index registration with legacy decorators', () => {
49
+ it('sends the .NET-equivalent property paths for projections and reducers', async () => {
50
+ const registerMany = vi.fn().mockResolvedValue({});
51
+ const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
52
+ const readModels = new ReadModels('test', 'Default', connection, artifacts, 'sink');
53
+ await readModels.register();
54
+
55
+ expect(registerMany).toHaveBeenCalledWith(expect.objectContaining({
56
+ ReadModels: [
57
+ expect.objectContaining({
58
+ ObserverIdentifier: 'indexed-orders',
59
+ Indexes: [
60
+ { PropertyPath: 'customerId' },
61
+ { PropertyPath: 'number' },
62
+ { PropertyPath: 'address.zipCode' },
63
+ { PropertyPath: 'lines.productId' }
64
+ ]
65
+ }),
66
+ expect.objectContaining({
67
+ ObserverIdentifier: 'reduced-orders',
68
+ Indexes: [{ PropertyPath: 'number' }]
69
+ })
70
+ ]
71
+ }));
72
+ });
73
+
74
+ it('sends the same indexes when registering a single model', async () => {
75
+ const registerMany = vi.fn().mockResolvedValue({});
76
+ const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
77
+ await new ReadModels('test', 'Default', connection, artifacts, 'sink').register(Order);
78
+ expect(registerMany.mock.calls[0][0].ReadModels[0].Indexes).toEqual([
79
+ { PropertyPath: 'customerId' }, { PropertyPath: 'number' },
80
+ { PropertyPath: 'address.zipCode' }, { PropertyPath: 'lines.productId' }
81
+ ]);
82
+ });
83
+ });
@@ -0,0 +1,62 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { conceptAsTypeKey, type Constructor, Fields, typeKeyOf } from '@cratis/fundamentals';
6
+ import type { IndexDefinition } from '@cratis/chronicle.contracts';
7
+ import { getChildrenFromMetadata } from '../projections/modelBound/childrenFrom.js';
8
+ import { TypeIntrospector } from '../types/TypeIntrospector.js';
9
+ import { ChroniclePropertyDecorator, decorateProperty, hasPropertyMetadata } from '../types/propertyDecoratorMetadata.js';
10
+
11
+ const INDEX_METADATA_KEY = 'chronicle:readModel:index';
12
+
13
+ /**
14
+ * Marks a read-model property for a single ascending, non-unique index.
15
+ * Indexed fields on nested objects or typed collections use dotted property paths.
16
+ * @returns A property decorator.
17
+ */
18
+ export function index(): ChroniclePropertyDecorator {
19
+ return decorateProperty((target: object, propertyKey: string | symbol) => {
20
+ const name = propertyKey.toString();
21
+ TypeIntrospector.trackProperty((target as { constructor: Function }).constructor, name);
22
+ Reflect.defineMetadata(INDEX_METADATA_KEY, true, target, name);
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Collects the indexed paths of a read model, including nested objects and typed collections.
28
+ * @param type - The read-model type to inspect.
29
+ * @returns Index definitions with property paths for the read model.
30
+ */
31
+ export function getIndexesForType(type: Function): IndexDefinition[] {
32
+ const indexes: IndexDefinition[] = [];
33
+ // Match .NET: a nested type shared by multiple properties is visited only on its first path.
34
+ const visited = new Set<Function>();
35
+
36
+ function collect(current: Function, prefix: string): void {
37
+ if (visited.has(current)) return;
38
+ visited.add(current);
39
+
40
+ const fields = Fields.getFieldsForType(current as Constructor);
41
+ for (const [name, memberType] of TypeIntrospector.getMembers(current)) {
42
+ const path = prefix ? `${prefix}.${name}` : name;
43
+ if (hasPropertyMetadata(INDEX_METADATA_KEY, current.prototype, name)) {
44
+ indexes.push({ PropertyPath: path });
45
+ }
46
+
47
+ const nestedType = memberType === Array
48
+ ? getChildrenFromMetadata(current.prototype, name).find(entry => entry.childType)?.childType
49
+ ?? fields.find(field => field.name === name)?.genericArguments?.[0]
50
+ : memberType;
51
+ const isComplex = nestedType && nestedType !== String && nestedType !== Number && nestedType !== Boolean &&
52
+ nestedType !== Date && nestedType !== Array && nestedType !== Object;
53
+ if (isComplex && typeKeyOf(nestedType as Constructor) !== conceptAsTypeKey &&
54
+ typeKeyOf(nestedType as Constructor) !== 'Guid') {
55
+ collect(nestedType, path);
56
+ }
57
+ }
58
+ }
59
+
60
+ collect(type, '');
61
+ return indexes;
62
+ }
@@ -0,0 +1,99 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import 'reflect-metadata';
5
+ import { field } from '@cratis/fundamentals';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import type { IClientArtifactsProvider } from '../artifacts/index.js';
8
+ import type { ChronicleConnection } from '../connection/index.js';
9
+ import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
10
+ import { eventType } from '../events/eventTypeDecorator.js';
11
+ import { Projections } from '../projections/Projections.js';
12
+ import { projection } from '../projections/declarative/projection.js';
13
+ import { fromEvent } from '../projections/modelBound/fromEvent.js';
14
+ import { entersOn } from '../projections/modelBound/entersOn.js';
15
+ import { variantOf } from '../projections/modelBound/variantOf.js';
16
+ import { reducer } from '../reducers/reducer.js';
17
+ import { Reducers } from '../reducers/Reducers.js';
18
+ import { index } from './indexDecorator.js';
19
+
20
+ class IndexedEvent {}
21
+ eventType('indexed-event')(IndexedEvent);
22
+
23
+ class DeclarativeModel {
24
+ @field(String) @index() reference!: string;
25
+ }
26
+ @projection('indexed-declarative', DeclarativeModel)
27
+ class DeclarativeProjection {
28
+ define(): void {}
29
+ }
30
+
31
+ @fromEvent(IndexedEvent)
32
+ class ModelBoundModel {
33
+ @field(String) @index() reference!: string;
34
+ }
35
+
36
+ class VariantIdentity {}
37
+ @variantOf(VariantIdentity, 'id')
38
+ @entersOn(IndexedEvent)
39
+ @fromEvent(IndexedEvent)
40
+ class IndexedVariant {
41
+ @field(String) id!: string;
42
+ @field(String) @index() reference!: string;
43
+ }
44
+
45
+ class ReducedModel {
46
+ @field(String) @index() reference!: string;
47
+ }
48
+ @reducer('indexed-reducer', undefined, ReducedModel)
49
+ class IndexedReducer {}
50
+
51
+ function artifacts(readModels: IClientArtifactsProvider['readModels'] = [], projections: IClientArtifactsProvider['projections'] = [], reducers: IClientArtifactsProvider['reducers'] = []): IClientArtifactsProvider {
52
+ return {
53
+ eventTypes: [], readModels, projections, reducers, reactors: [], seeders: [], constraints: [],
54
+ webhooks: [], eventTypeMigrations: [], globalForHandlers: []
55
+ };
56
+ }
57
+
58
+ function connection() {
59
+ const registerMany = vi.fn().mockResolvedValue({});
60
+ const value = {
61
+ readModels: { registerMany },
62
+ projections: { register: vi.fn().mockResolvedValue({}) },
63
+ reducers: { observe: vi.fn().mockImplementation(async function* () {}) }
64
+ } as unknown as ChronicleConnection;
65
+ return { value, registerMany };
66
+ }
67
+
68
+ function indexesFor(registerMany: ReturnType<typeof vi.fn>, identifier: string) {
69
+ const definitions = registerMany.mock.calls[0][0].ReadModels as Array<{ Type: { Identifier: string }; Indexes: unknown[] }>;
70
+ return definitions.find(definition => definition.Type.Identifier === identifier)?.Indexes;
71
+ }
72
+
73
+ describe('when registering indexed read models through the startup paths', () => {
74
+ it('should send indexes for a declarative projection', async () => {
75
+ const { value, registerMany } = connection();
76
+ await new Projections('store', 'Default', value, artifacts([], [DeclarativeProjection]), 'sink').register();
77
+ expect(indexesFor(registerMany, 'DeclarativeModel')).toEqual([{ PropertyPath: 'reference' }]);
78
+ });
79
+
80
+ it('should send indexes for a model-bound projection', async () => {
81
+ const { value, registerMany } = connection();
82
+ await new Projections('store', 'Default', value, artifacts([ModelBoundModel]), 'sink').register();
83
+ expect(indexesFor(registerMany, 'ModelBoundModel')).toEqual([{ PropertyPath: 'reference' }]);
84
+ });
85
+
86
+ it('should send indexes for a model-bound variant', async () => {
87
+ const { value, registerMany } = connection();
88
+ await new Projections('store', 'Default', value, artifacts([IndexedVariant]), 'sink').register();
89
+ expect(indexesFor(registerMany, 'IndexedVariant')).toEqual([{ PropertyPath: 'reference' }]);
90
+ });
91
+
92
+ it('should send indexes for a reducer read model', async () => {
93
+ const { value, registerMany } = connection();
94
+ const reducers = new Reducers(artifacts([], [], [IndexedReducer]), value, 'store', 'Default', new ConnectionLifecycle(), 'sink');
95
+ await reducers.register();
96
+ reducers.dispose();
97
+ expect(indexesFor(registerMany, 'ReducedModel')).toEqual([{ PropertyPath: 'reference' }]);
98
+ });
99
+ });
@@ -2,6 +2,8 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { field } from '@cratis/fundamentals';
5
+ import { fromEvent } from '../projections/modelBound/fromEvent.js';
6
+ import { index } from './indexDecorator.js';
5
7
  import { describe, expect, it, vi } from 'vitest';
6
8
  import { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
7
9
  import { ChronicleConnection } from '../connection/ChronicleConnection.js';
@@ -16,12 +18,41 @@ class UndecoratedModel {
16
18
  @reducer('undecorated-model', undefined, UndecoratedModel)
17
19
  class ModelReducer {}
18
20
 
21
+ class IndexedEvent { value = ''; }
22
+
23
+ class IndexedLine {
24
+ @field(String) @index() productId!: string;
25
+ @field(Number) quantity!: number;
26
+ }
27
+
28
+ @fromEvent(IndexedEvent)
29
+ class IndexedOrder {
30
+ @field(String) id!: string;
31
+ @field(String) @index() customerId!: string;
32
+ @field(Array, { genericArguments: [IndexedLine] }) lines!: IndexedLine[];
33
+ }
34
+
19
35
  const artifacts = {
20
36
  eventTypes: [], readModels: [], reactors: [], reducers: [ModelReducer], seeders: [],
21
37
  constraints: [], projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
22
38
  } as IClientArtifactsProvider;
23
39
 
24
40
  describe('inferred read models with standard decorators', () => {
41
+ it('registers model-bound indexes on fields and typed collection elements as .NET property paths', async () => {
42
+ const registerMany = vi.fn().mockResolvedValue({});
43
+ const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
44
+ const provider = { ...artifacts, readModels: [IndexedOrder] };
45
+ await new ReadModels('test', 'Default', connection, provider, 'sink').register(IndexedOrder);
46
+ expect(registerMany).toHaveBeenCalledWith(expect.objectContaining({
47
+ ReadModels: [expect.objectContaining({
48
+ Indexes: [
49
+ { PropertyPath: 'customerId' },
50
+ { PropertyPath: 'lines.productId' }
51
+ ]
52
+ })]
53
+ }));
54
+ });
55
+
25
56
  it('rejects an untyped array before registering a reducer read model', async () => {
26
57
  const registerMany = vi.fn();
27
58
  const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
@@ -3,11 +3,10 @@
3
3
 
4
4
  import 'reflect-metadata';
5
5
  import { diag } from '@opentelemetry/api';
6
- import { Constructor, Guid } from '@cratis/fundamentals';
6
+ import { Constructor } from '@cratis/fundamentals';
7
7
  import { ObservationState, ReadModelObserverType, ReducerMessage } from '@cratis/chronicle.contracts';
8
8
  import { IClientArtifactsProvider } from '../artifacts/index.js';
9
9
  import { ChronicleConnection } from '../connection/index.js';
10
- import { toContractsGuid } from '../connection/Guid.js';
11
10
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
12
11
  import { getEventTypeMetadata } from '../events/eventTypeDecorator.js';
13
12
  import { toClientEventContext } from '../events/toClientEventContext.js';
@@ -19,6 +18,7 @@ import { IReducers } from './IReducers.js';
19
18
  import { getReducerMetadata } from './reducer.js';
20
19
  import { getReadModelMetadata } from '../readModels/index.js';
21
20
  import { getReadModelId } from '../readModels/readModel.js';
21
+ import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
22
22
  import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
23
23
  import { JsonSchemaGenerator } from '../schemas/index.js';
24
24
 
@@ -181,24 +181,14 @@ export class Reducers implements IReducers {
181
181
 
182
182
  const readModels = Array.from(this._reducers.entries()).map(([id, reducerType]) => {
183
183
  const readModelName = this.getReducerReadModelIdentifier(reducerType);
184
- return {
185
- Type: {
186
- Identifier: readModelName,
187
- Generation: 1
188
- },
189
- ContainerName: readModelName,
190
- DisplayName: readModelName,
191
- Sink: {
192
- ConfigurationId: toContractsGuid(Guid.empty),
193
- TypeId: this._defaultSinkTypeId
194
- },
195
- Schema: this.getReducerSchema(reducerType, readModelName),
196
- Indexes: [],
197
- ObserverType: ReadModelObserverType.Reducer,
198
- ObserverIdentifier: id,
199
- Owner: 1,
200
- Source: 1
201
- };
184
+ return buildReadModelDefinition({
185
+ identifier: readModelName,
186
+ type: getReducerMetadata(reducerType)?.readModel,
187
+ schema: this.getReducerSchema(reducerType, readModelName),
188
+ sinkTypeId: this._defaultSinkTypeId,
189
+ observerType: ReadModelObserverType.Reducer,
190
+ observerIdentifier: id
191
+ });
202
192
  });
203
193
 
204
194
  const identifiers = new Set<string>();