@cratis/chronicle 6.11.0 → 6.13.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 (87) hide show
  1. package/EventStore.ts +4 -0
  2. package/IEventStore.ts +7 -0
  3. package/dist/EventStore.d.ts +2 -0
  4. package/dist/EventStore.d.ts.map +1 -1
  5. package/dist/EventStore.js +3 -0
  6. package/dist/EventStore.js.map +1 -1
  7. package/dist/IEventStore.d.ts +6 -0
  8. package/dist/IEventStore.d.ts.map +1 -1
  9. package/dist/observation/IObservers.d.ts +28 -0
  10. package/dist/observation/IObservers.d.ts.map +1 -0
  11. package/dist/observation/IObservers.js +4 -0
  12. package/dist/observation/IObservers.js.map +1 -0
  13. package/dist/observation/ObserverInformation.d.ts +24 -0
  14. package/dist/observation/ObserverInformation.d.ts.map +1 -0
  15. package/dist/observation/ObserverInformation.js +4 -0
  16. package/dist/observation/ObserverInformation.js.map +1 -0
  17. package/dist/observation/ObserverRemovalOutcome.d.ts +14 -0
  18. package/dist/observation/ObserverRemovalOutcome.d.ts.map +1 -0
  19. package/dist/observation/ObserverRemovalOutcome.js +17 -0
  20. package/dist/observation/ObserverRemovalOutcome.js.map +1 -0
  21. package/dist/observation/ObserverRemovalResult.d.ts +17 -0
  22. package/dist/observation/ObserverRemovalResult.d.ts.map +1 -0
  23. package/dist/observation/ObserverRemovalResult.js +4 -0
  24. package/dist/observation/ObserverRemovalResult.js.map +1 -0
  25. package/dist/observation/ObserverType.d.ts +16 -0
  26. package/dist/observation/ObserverType.d.ts.map +1 -0
  27. package/dist/observation/ObserverType.js +19 -0
  28. package/dist/observation/ObserverType.js.map +1 -0
  29. package/dist/observation/Observers.d.ts +24 -0
  30. package/dist/observation/Observers.d.ts.map +1 -0
  31. package/dist/observation/Observers.js +43 -0
  32. package/dist/observation/Observers.js.map +1 -0
  33. package/dist/observation/index.d.ts +8 -0
  34. package/dist/observation/index.d.ts.map +1 -1
  35. package/dist/observation/index.js +5 -0
  36. package/dist/observation/index.js.map +1 -1
  37. package/dist/observation/toObserverInformation.d.ts +16 -0
  38. package/dist/observation/toObserverInformation.d.ts.map +1 -0
  39. package/dist/observation/toObserverInformation.js +43 -0
  40. package/dist/observation/toObserverInformation.js.map +1 -0
  41. package/dist/observation/toObserverRemovalResult.d.ts +16 -0
  42. package/dist/observation/toObserverRemovalResult.d.ts.map +1 -0
  43. package/dist/observation/toObserverRemovalResult.js +37 -0
  44. package/dist/observation/toObserverRemovalResult.js.map +1 -0
  45. package/dist/projections/Projections.d.ts +1 -1
  46. package/dist/projections/Projections.d.ts.map +1 -1
  47. package/dist/projections/Projections.js +19 -27
  48. package/dist/projections/Projections.js.map +1 -1
  49. package/dist/readModels/ReadModels.d.ts.map +1 -1
  50. package/dist/readModels/ReadModels.js +12 -22
  51. package/dist/readModels/ReadModels.js.map +1 -1
  52. package/dist/readModels/buildReadModelDefinition.d.ts +30 -0
  53. package/dist/readModels/buildReadModelDefinition.d.ts.map +1 -0
  54. package/dist/readModels/buildReadModelDefinition.js +27 -0
  55. package/dist/readModels/buildReadModelDefinition.js.map +1 -0
  56. package/dist/readModels/index.d.ts +1 -0
  57. package/dist/readModels/index.d.ts.map +1 -1
  58. package/dist/readModels/index.js +1 -0
  59. package/dist/readModels/index.js.map +1 -1
  60. package/dist/readModels/indexDecorator.d.ts +16 -0
  61. package/dist/readModels/indexDecorator.d.ts.map +1 -0
  62. package/dist/readModels/indexDecorator.js +55 -0
  63. package/dist/readModels/indexDecorator.js.map +1 -0
  64. package/dist/reducers/Reducers.d.ts.map +1 -1
  65. package/dist/reducers/Reducers.js +9 -20
  66. package/dist/reducers/Reducers.js.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/observation/IObservers.ts +32 -0
  69. package/observation/ObserverInformation.ts +33 -0
  70. package/observation/ObserverRemovalOutcome.ts +19 -0
  71. package/observation/ObserverRemovalResult.ts +22 -0
  72. package/observation/ObserverType.ts +22 -0
  73. package/observation/Observers.spec.ts +115 -0
  74. package/observation/Observers.ts +49 -0
  75. package/observation/index.ts +8 -0
  76. package/observation/toObserverInformation.ts +46 -0
  77. package/observation/toObserverRemovalResult.ts +40 -0
  78. package/package.json +2 -2
  79. package/projections/Projections.ts +20 -27
  80. package/readModels/ReadModels.ts +12 -22
  81. package/readModels/buildReadModelDefinition.ts +38 -0
  82. package/readModels/index.ts +1 -0
  83. package/readModels/indexDecorator.legacy.spec.ts +83 -0
  84. package/readModels/indexDecorator.ts +62 -0
  85. package/readModels/indexRegistration.spec.ts +99 -0
  86. package/readModels/standardFallbacks.standard.spec.ts +31 -0
  87. package/reducers/Reducers.ts +10 -20
@@ -0,0 +1,46 @@
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 { ObserverInformation as ContractObserverInformation, ObserverType as ContractObserverType } from '@cratis/chronicle.contracts';
5
+ import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
6
+ import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
7
+ import { ObserverInformation } from './ObserverInformation.js';
8
+ import { ObserverType } from './ObserverType.js';
9
+ import { toObserverRunningState } from './toObserverRunningState.js';
10
+
11
+ /**
12
+ * Converts a wire {@link ContractObserverType} into the client {@link ObserverType}.
13
+ * @param type - The wire observer type to convert.
14
+ * @returns The converted client observer type.
15
+ */
16
+ export function toObserverType(type: ContractObserverType): ObserverType {
17
+ switch (type) {
18
+ case ContractObserverType.Reactor:
19
+ return ObserverType.Reactor;
20
+ case ContractObserverType.Projection:
21
+ return ObserverType.Projection;
22
+ case ContractObserverType.Reducer:
23
+ return ObserverType.Reducer;
24
+ case ContractObserverType.External:
25
+ return ObserverType.External;
26
+ default:
27
+ return ObserverType.Unknown;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Converts a wire {@link ContractObserverInformation} into the client {@link ObserverInformation}.
33
+ * @param observer - The wire observer information to convert.
34
+ * @returns The converted client observer information.
35
+ */
36
+ export function toObserverInformation(observer: ContractObserverInformation): ObserverInformation {
37
+ return {
38
+ id: observer.Id,
39
+ eventSequenceId: new EventSequenceId(observer.EventSequenceId),
40
+ type: toObserverType(observer.Type),
41
+ runningState: toObserverRunningState(observer.RunningState),
42
+ lastHandledEventSequenceNumber: new EventSequenceNumber(observer.LastHandledEventSequenceNumber ?? 0n),
43
+ nextEventSequenceNumber: new EventSequenceNumber(observer.NextEventSequenceNumber ?? 0n),
44
+ handledEventCount: observer.HandledEventCount ?? 0n
45
+ };
46
+ }
@@ -0,0 +1,40 @@
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 { ObserverRemovalOutcome as ContractObserverRemovalOutcome, RemoveObserverResponse } from '@cratis/chronicle.contracts';
5
+ import { ObserverRemovalOutcome } from './ObserverRemovalOutcome.js';
6
+ import { ObserverRemovalResult } from './ObserverRemovalResult.js';
7
+
8
+ /**
9
+ * Converts a wire {@link ContractObserverRemovalOutcome} into the client {@link ObserverRemovalOutcome}.
10
+ * @param outcome - The wire outcome to convert.
11
+ * @returns The converted client outcome.
12
+ */
13
+ export function toObserverRemovalOutcome(outcome: ContractObserverRemovalOutcome): ObserverRemovalOutcome {
14
+ switch (outcome) {
15
+ case ContractObserverRemovalOutcome.Removed:
16
+ return ObserverRemovalOutcome.Removed;
17
+ case ContractObserverRemovalOutcome.ObserverNotFound:
18
+ return ObserverRemovalOutcome.ObserverNotFound;
19
+ case ContractObserverRemovalOutcome.ObserverActive:
20
+ return ObserverRemovalOutcome.ObserverActive;
21
+ case ContractObserverRemovalOutcome.ObserverSubscribed:
22
+ return ObserverRemovalOutcome.ObserverSubscribed;
23
+ default:
24
+ throw new Error(`Unknown observer removal outcome: ${outcome}`);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Converts a wire {@link RemoveObserverResponse} into the client {@link ObserverRemovalResult}.
30
+ * @param response - The wire response to convert.
31
+ * @returns The converted client removal result.
32
+ */
33
+ export function toObserverRemovalResult(response: RemoveObserverResponse): ObserverRemovalResult {
34
+ const outcome = toObserverRemovalOutcome(response.Outcome);
35
+ return {
36
+ outcome,
37
+ blockingNamespace: response.BlockingNamespace ?? '',
38
+ isRemoved: outcome === ObserverRemovalOutcome.Removed
39
+ };
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.11.0",
3
+ "version": "6.13.0",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -167,7 +167,7 @@
167
167
  },
168
168
  "dependencies": {
169
169
  "@bufbuild/protobuf": "^2.15.0",
170
- "@cratis/chronicle.contracts": "19.4.0",
170
+ "@cratis/chronicle.contracts": "19.6.1",
171
171
  "@grpc/grpc-js": "^1.14.4",
172
172
  "@opentelemetry/api": "^1.9.1",
173
173
  "glob": "^13.0.6",
@@ -4,12 +4,12 @@
4
4
  import { diag } from '@opentelemetry/api';
5
5
  import {
6
6
  AutoMap,
7
- ProjectionOwner
7
+ ProjectionOwner,
8
+ ReadModelObserverType
8
9
  } from '@cratis/chronicle.contracts';
9
10
  import { Constructor, Guid } from '@cratis/fundamentals';
10
11
  import { IClientArtifactsProvider } from '../artifacts/index.js';
11
12
  import { ChronicleConnection } from '../connection/index.js';
12
- import { toContractsGuid } from '../connection/Guid.js';
13
13
  import { WellKnownSinks } from '../sinks/index.js';
14
14
  import { EventSequenceId } from '../eventSequences/EventSequenceId.js';
15
15
  import { EventSequenceNumber } from '../eventSequences/EventSequenceNumber.js';
@@ -19,6 +19,7 @@ import { FailedPartitions } from '../observation/FailedPartitions.js';
19
19
  import { toObserverRunningState } from '../observation/toObserverRunningState.js';
20
20
  import { getReadModelMetadata } from '../readModels/index.js';
21
21
  import { getReadModelId } from '../readModels/readModel.js';
22
+ import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
22
23
  import { assertUniqueReadModelIds } from '../readModels/assertUniqueReadModelIds.js';
23
24
  import { rootReadModelTypes } from '../readModels/rootReadModelTypes.js';
24
25
  import { JsonSchemaGenerator } from '../schemas/index.js';
@@ -370,33 +371,26 @@ export class Projections implements IProjections {
370
371
  continue;
371
372
  }
372
373
 
373
- byReadModel.set(readModelIdentifier, {
374
- Type: {
375
- Identifier: readModelIdentifier,
376
- Generation: 1
377
- },
378
- ContainerName: readModelIdentifier,
379
- DisplayName: readModelIdentifier,
380
- Sink: {
381
- ConfigurationId: toContractsGuid(Guid.empty),
382
- // Passive projections never write to a materialized sink, so they register with
383
- // the None sink. This lets the kernel fall through to immediate projection when
384
- // resolving the instance by key instead of reading an empty sink and returning null.
385
- TypeId: projection.IsActive === false ? WellKnownSinks.None : this._defaultSinkTypeId
386
- },
387
- Schema: this.getReadModelSchema(readModelIdentifier),
388
- Indexes: [],
389
- ObserverType: 2,
390
- ObserverIdentifier: projection.Identifier,
391
- Owner: 1,
392
- Source: 1
393
- });
374
+ const readModelType = this.getReadModelType(readModelIdentifier);
375
+ byReadModel.set(readModelIdentifier, buildReadModelDefinition({
376
+ identifier: readModelIdentifier,
377
+ type: readModelType,
378
+ schema: readModelType
379
+ ? JSON.stringify(getReadModelMetadata(readModelType)?.schema ?? JsonSchemaGenerator.generate(readModelType))
380
+ : '{}',
381
+ // Passive projections never write to a materialized sink, so they register with
382
+ // the None sink. This lets the kernel fall through to immediate projection when
383
+ // resolving the instance by key instead of reading an empty sink and returning null.
384
+ sinkTypeId: projection.IsActive === false ? WellKnownSinks.None : this._defaultSinkTypeId,
385
+ observerType: ReadModelObserverType.Projection,
386
+ observerIdentifier: projection.Identifier
387
+ }));
394
388
  }
395
389
 
396
390
  return Array.from(byReadModel.values());
397
391
  }
398
392
 
399
- private getReadModelSchema(readModelIdentifier: string): string {
393
+ private getReadModelType(readModelIdentifier: string): Constructor | undefined {
400
394
  const types = [
401
395
  ...rootReadModelTypes(this._clientArtifacts),
402
396
  ...this._clientArtifacts.projections
@@ -404,13 +398,12 @@ export class Projections implements IProjections {
404
398
  .filter((type): type is Constructor => type !== undefined)
405
399
  ];
406
400
  for (const type of types) {
407
- const metadata = getReadModelMetadata(type);
408
401
  if (getReadModelId(type) === readModelIdentifier) {
409
- return JSON.stringify(metadata?.schema ?? JsonSchemaGenerator.generate(type));
402
+ return type;
410
403
  }
411
404
  }
412
405
 
413
- return '{}';
406
+ return undefined;
414
407
  }
415
408
 
416
409
  private buildDeclarativeDefinition(type: Constructor): BuiltProjection {
@@ -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';
@@ -341,26 +341,16 @@ export class ReadModels implements IReadModels {
341
341
 
342
342
 
343
343
  private toDefinition(readModel: ResolvedReadModel) {
344
- return {
345
- Type: {
346
- Identifier: readModel.identifier,
347
- Generation: 1
348
- },
349
- ContainerName: readModel.identifier,
350
- DisplayName: readModel.identifier,
351
- Sink: {
352
- ConfigurationId: toContractsGuid(Guid.empty),
353
- // Passive read models never write to a materialized sink, so they register with the
354
- // None sink and the kernel resolves them via immediate projection instead of an empty sink.
355
- TypeId: readModel.isActive ? this._defaultSinkTypeId : WellKnownSinks.None
356
- },
357
- Schema: readModel.schema,
358
- Indexes: [],
359
- ObserverType: readModel.observerType,
360
- ObserverIdentifier: readModel.observerIdentifier,
361
- Owner: 1,
362
- Source: 1
363
- };
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
+ });
364
354
  }
365
355
 
366
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>();