@cratis/chronicle 3.1.0 → 4.0.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 (217) hide show
  1. package/ChronicleClient.ts +4 -3
  2. package/EventStore.ts +4 -3
  3. package/README.md +40 -28
  4. package/compliance/ComplianceContracts.ts +59 -0
  5. package/compliance/IPIIManager.ts +12 -0
  6. package/compliance/PIIManager.spec.ts +50 -0
  7. package/compliance/PIIManager.ts +9 -0
  8. package/compliance/PIINotSupportedOnEventSourceId.ts +21 -0
  9. package/compliance/index.ts +2 -0
  10. package/compliance/pii.spec.ts +37 -0
  11. package/compliance/pii.ts +16 -1
  12. package/compliance/subject.spec.ts +45 -0
  13. package/compliance/subject.ts +64 -0
  14. package/connection/callResults.spec.ts +90 -0
  15. package/connection/callResults.ts +112 -0
  16. package/dist/ChronicleClient.d.ts.map +1 -1
  17. package/dist/ChronicleClient.js +4 -3
  18. package/dist/ChronicleClient.js.map +1 -1
  19. package/dist/EventStore.d.ts.map +1 -1
  20. package/dist/EventStore.js +4 -3
  21. package/dist/EventStore.js.map +1 -1
  22. package/dist/compliance/ComplianceContracts.d.ts +31 -0
  23. package/dist/compliance/ComplianceContracts.d.ts.map +1 -1
  24. package/dist/compliance/ComplianceContracts.js +52 -0
  25. package/dist/compliance/ComplianceContracts.js.map +1 -1
  26. package/dist/compliance/IPIIManager.d.ts +11 -0
  27. package/dist/compliance/IPIIManager.d.ts.map +1 -1
  28. package/dist/compliance/PIIManager.d.ts +2 -0
  29. package/dist/compliance/PIIManager.d.ts.map +1 -1
  30. package/dist/compliance/PIIManager.js +8 -0
  31. package/dist/compliance/PIIManager.js.map +1 -1
  32. package/dist/compliance/PIINotSupportedOnEventSourceId.d.ts +11 -0
  33. package/dist/compliance/PIINotSupportedOnEventSourceId.d.ts.map +1 -0
  34. package/dist/compliance/PIINotSupportedOnEventSourceId.js +19 -0
  35. package/dist/compliance/PIINotSupportedOnEventSourceId.js.map +1 -0
  36. package/dist/compliance/index.d.ts +2 -0
  37. package/dist/compliance/index.d.ts.map +1 -1
  38. package/dist/compliance/index.js +2 -0
  39. package/dist/compliance/index.js.map +1 -1
  40. package/dist/compliance/pii.d.ts.map +1 -1
  41. package/dist/compliance/pii.js +13 -1
  42. package/dist/compliance/pii.js.map +1 -1
  43. package/dist/compliance/subject.d.ts +39 -0
  44. package/dist/compliance/subject.d.ts.map +1 -0
  45. package/dist/compliance/subject.js +58 -0
  46. package/dist/compliance/subject.js.map +1 -0
  47. package/dist/connection/callResults.d.ts +69 -0
  48. package/dist/connection/callResults.d.ts.map +1 -0
  49. package/dist/connection/callResults.js +74 -0
  50. package/dist/connection/callResults.js.map +1 -0
  51. package/dist/eventSequences/AppendOptions.d.ts +8 -0
  52. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  53. package/dist/eventSequences/EventForEventSourceId.d.ts +7 -0
  54. package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
  55. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  56. package/dist/eventSequences/EventSequence.js +17 -6
  57. package/dist/eventSequences/EventSequence.js.map +1 -1
  58. package/dist/events/EventContext.d.ts +3 -0
  59. package/dist/events/EventContext.d.ts.map +1 -1
  60. package/dist/events/Tag.d.ts +10 -0
  61. package/dist/events/Tag.d.ts.map +1 -0
  62. package/dist/events/Tag.js +16 -0
  63. package/dist/events/Tag.js.map +1 -0
  64. package/dist/events/constraints/Constraints.js +3 -3
  65. package/dist/events/constraints/Constraints.js.map +1 -1
  66. package/dist/events/filterEventsByTagDecorator.d.ts +35 -0
  67. package/dist/events/filterEventsByTagDecorator.d.ts.map +1 -0
  68. package/dist/events/filterEventsByTagDecorator.js +48 -0
  69. package/dist/events/filterEventsByTagDecorator.js.map +1 -0
  70. package/dist/events/index.d.ts +4 -0
  71. package/dist/events/index.d.ts.map +1 -1
  72. package/dist/events/index.js +4 -0
  73. package/dist/events/index.js.map +1 -1
  74. package/dist/events/mergeTags.d.ts +12 -0
  75. package/dist/events/mergeTags.d.ts.map +1 -0
  76. package/dist/events/mergeTags.js +26 -0
  77. package/dist/events/mergeTags.js.map +1 -0
  78. package/dist/events/tagDecorator.d.ts +38 -0
  79. package/dist/events/tagDecorator.d.ts.map +1 -0
  80. package/dist/events/tagDecorator.js +54 -0
  81. package/dist/events/tagDecorator.js.map +1 -0
  82. package/dist/jobs/IJobs.d.ts +4 -4
  83. package/dist/jobs/IJobs.d.ts.map +1 -1
  84. package/dist/jobs/Jobs.d.ts +4 -4
  85. package/dist/jobs/Jobs.d.ts.map +1 -1
  86. package/dist/jobs/Jobs.js +12 -10
  87. package/dist/jobs/Jobs.js.map +1 -1
  88. package/dist/observation/ObserverRunningState.d.ts +9 -15
  89. package/dist/observation/ObserverRunningState.d.ts.map +1 -1
  90. package/dist/observation/ObserverRunningState.js +8 -14
  91. package/dist/observation/ObserverRunningState.js.map +1 -1
  92. package/dist/observation/index.d.ts +1 -0
  93. package/dist/observation/index.d.ts.map +1 -1
  94. package/dist/observation/index.js +1 -0
  95. package/dist/observation/index.js.map +1 -1
  96. package/dist/observation/toObserverRunningState.d.ts +9 -0
  97. package/dist/observation/toObserverRunningState.d.ts.map +1 -0
  98. package/dist/observation/toObserverRunningState.js +26 -0
  99. package/dist/observation/toObserverRunningState.js.map +1 -0
  100. package/dist/projections/IProjections.d.ts +71 -1
  101. package/dist/projections/IProjections.d.ts.map +1 -1
  102. package/dist/projections/ProjectionQueryResult.d.ts +8 -0
  103. package/dist/projections/ProjectionQueryResult.d.ts.map +1 -0
  104. package/dist/projections/ProjectionQueryResult.js +4 -0
  105. package/dist/projections/ProjectionQueryResult.js.map +1 -0
  106. package/dist/projections/ProjectionState.d.ts +18 -0
  107. package/dist/projections/ProjectionState.d.ts.map +1 -0
  108. package/dist/projections/ProjectionState.js +4 -0
  109. package/dist/projections/ProjectionState.js.map +1 -0
  110. package/dist/projections/Projections.d.ts +46 -1
  111. package/dist/projections/Projections.d.ts.map +1 -1
  112. package/dist/projections/Projections.js +153 -6
  113. package/dist/projections/Projections.js.map +1 -1
  114. package/dist/projections/UnableToQueryProjection.d.ts +12 -0
  115. package/dist/projections/UnableToQueryProjection.d.ts.map +1 -0
  116. package/dist/projections/UnableToQueryProjection.js +17 -0
  117. package/dist/projections/UnableToQueryProjection.js.map +1 -0
  118. package/dist/projections/index.d.ts +3 -0
  119. package/dist/projections/index.d.ts.map +1 -1
  120. package/dist/projections/index.js +1 -0
  121. package/dist/projections/index.js.map +1 -1
  122. package/dist/projections/modelBound/eventSequence.d.ts +22 -0
  123. package/dist/projections/modelBound/eventSequence.d.ts.map +1 -0
  124. package/dist/projections/modelBound/eventSequence.js +34 -0
  125. package/dist/projections/modelBound/eventSequence.js.map +1 -0
  126. package/dist/projections/modelBound/fromAll.d.ts +26 -0
  127. package/dist/projections/modelBound/fromAll.d.ts.map +1 -0
  128. package/dist/projections/modelBound/fromAll.js +31 -0
  129. package/dist/projections/modelBound/fromAll.js.map +1 -0
  130. package/dist/projections/modelBound/index.d.ts +4 -0
  131. package/dist/projections/modelBound/index.d.ts.map +1 -1
  132. package/dist/projections/modelBound/index.js +3 -0
  133. package/dist/projections/modelBound/index.js.map +1 -1
  134. package/dist/projections/modelBound/noAutoMap.d.ts +26 -0
  135. package/dist/projections/modelBound/noAutoMap.d.ts.map +1 -0
  136. package/dist/projections/modelBound/noAutoMap.js +44 -0
  137. package/dist/projections/modelBound/noAutoMap.js.map +1 -0
  138. package/dist/reactors/Reactors.d.ts.map +1 -1
  139. package/dist/reactors/Reactors.js +7 -3
  140. package/dist/reactors/Reactors.js.map +1 -1
  141. package/dist/readModels/MaterializedReadModels.d.ts +0 -1
  142. package/dist/readModels/MaterializedReadModels.d.ts.map +1 -1
  143. package/dist/readModels/MaterializedReadModels.js +2 -8
  144. package/dist/readModels/MaterializedReadModels.js.map +1 -1
  145. package/dist/readModels/ReadModelSubjectResolver.d.ts +22 -0
  146. package/dist/readModels/ReadModelSubjectResolver.d.ts.map +1 -0
  147. package/dist/readModels/ReadModelSubjectResolver.js +38 -0
  148. package/dist/readModels/ReadModelSubjectResolver.js.map +1 -0
  149. package/dist/readModels/ReadModels.d.ts.map +1 -1
  150. package/dist/readModels/ReadModels.js +7 -7
  151. package/dist/readModels/ReadModels.js.map +1 -1
  152. package/dist/readModels/index.d.ts +1 -0
  153. package/dist/readModels/index.d.ts.map +1 -1
  154. package/dist/readModels/index.js +1 -0
  155. package/dist/readModels/index.js.map +1 -1
  156. package/dist/reducers/Reducers.d.ts.map +1 -1
  157. package/dist/reducers/Reducers.js +21 -3
  158. package/dist/reducers/Reducers.js.map +1 -1
  159. package/dist/schemas/JsonSchema.d.ts +14 -0
  160. package/dist/schemas/JsonSchema.d.ts.map +1 -1
  161. package/dist/schemas/JsonSchemaGenerator.d.ts +61 -5
  162. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  163. package/dist/schemas/JsonSchemaGenerator.js +130 -26
  164. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  165. package/dist/schemas/index.d.ts +1 -1
  166. package/dist/schemas/index.d.ts.map +1 -1
  167. package/dist/seeding/EventSeeding.d.ts.map +1 -1
  168. package/dist/seeding/EventSeeding.js +3 -1
  169. package/dist/seeding/EventSeeding.js.map +1 -1
  170. package/dist/tsconfig.tsbuildinfo +1 -1
  171. package/eventSequences/AppendOptions.ts +9 -0
  172. package/eventSequences/EventForEventSourceId.ts +9 -0
  173. package/eventSequences/EventSequence.ts +20 -6
  174. package/events/EventContext.ts +4 -0
  175. package/events/Tag.ts +14 -0
  176. package/events/constraints/Constraints.ts +3 -3
  177. package/events/filterEventsByTagDecorator.ts +51 -0
  178. package/events/index.ts +4 -0
  179. package/events/mergeTags.spec.ts +50 -0
  180. package/events/mergeTags.ts +27 -0
  181. package/events/tagDecorator.spec.ts +115 -0
  182. package/events/tagDecorator.ts +59 -0
  183. package/jobs/IJobs.ts +4 -4
  184. package/jobs/Jobs.ts +16 -14
  185. package/observation/ObserverRunningState.ts +9 -18
  186. package/observation/index.ts +1 -0
  187. package/observation/toObserverRunningState.ts +27 -0
  188. package/package.json +25 -10
  189. package/projections/IProjections.ts +81 -1
  190. package/projections/ProjectionQueryResult.ts +10 -0
  191. package/projections/ProjectionState.ts +25 -0
  192. package/projections/Projections.childrenAndNested.spec.ts +1 -1
  193. package/projections/Projections.modelBoundCompleteness.spec.ts +224 -0
  194. package/projections/Projections.operationalSurface.spec.ts +257 -0
  195. package/projections/Projections.spec.ts +1 -1
  196. package/projections/Projections.ts +180 -5
  197. package/projections/UnableToQueryProjection.ts +17 -0
  198. package/projections/index.ts +3 -0
  199. package/projections/modelBound/eventSequence.ts +38 -0
  200. package/projections/modelBound/fromAll.ts +42 -0
  201. package/projections/modelBound/index.ts +4 -0
  202. package/projections/modelBound/noAutoMap.ts +47 -0
  203. package/reactors/Reactors.ts +7 -3
  204. package/readModels/MaterializedReadModels.spec.ts +98 -0
  205. package/readModels/MaterializedReadModels.ts +2 -8
  206. package/readModels/ReadModelSubjectResolver.spec.ts +84 -0
  207. package/readModels/ReadModelSubjectResolver.ts +44 -0
  208. package/readModels/ReadModels.spec.ts +118 -0
  209. package/readModels/ReadModels.ts +7 -7
  210. package/readModels/index.ts +1 -0
  211. package/reducers/Reducers.spec.ts +80 -0
  212. package/reducers/Reducers.ts +22 -3
  213. package/schemas/JsonSchema.ts +16 -0
  214. package/schemas/JsonSchemaGenerator.spec.ts +195 -0
  215. package/schemas/JsonSchemaGenerator.ts +145 -27
  216. package/schemas/index.ts +1 -1
  217. package/seeding/EventSeeding.ts +3 -1
@@ -0,0 +1,47 @@
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 { TypeIntrospector } from '../../types';
6
+
7
+ const CLASS_METADATA_KEY = 'chronicle:projection:noAutoMap:class';
8
+ const PROPERTY_METADATA_KEY = 'chronicle:projection:noAutoMap:property';
9
+
10
+ /**
11
+ * Class or property decorator that disables AutoMap for a model-bound projection.
12
+ * Applied to a class, it prevents AutoMap from mapping any property automatically.
13
+ * Applied to a property, it excludes only that single property from AutoMap while every other
14
+ * property keeps mapping - use it to stop an unrelated event that carries an identically named
15
+ * property from silently overwriting a property whose value is set explicitly (for example via
16
+ * `setFrom`).
17
+ * @param target - The class constructor, or the class prototype when used on a property.
18
+ * @param propertyKey - The property name, when used as a property decorator.
19
+ */
20
+ export function noAutoMap(target: object, propertyKey?: string | symbol): void {
21
+ if (propertyKey !== undefined) {
22
+ const key = propertyKey.toString();
23
+ TypeIntrospector.trackProperty((target as { constructor: Function }).constructor, key);
24
+ Reflect.defineMetadata(PROPERTY_METADATA_KEY, true, target, key);
25
+ } else {
26
+ Reflect.defineMetadata(CLASS_METADATA_KEY, true, target as Function);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Checks whether the given class has AutoMap disabled entirely.
32
+ * @param target - The class constructor.
33
+ * @returns True if the class is marked with {@link noAutoMap}; false otherwise.
34
+ */
35
+ export function isNoAutoMap(target: Function): boolean {
36
+ return Reflect.hasMetadata(CLASS_METADATA_KEY, target);
37
+ }
38
+
39
+ /**
40
+ * Checks whether a specific property is excluded from AutoMap.
41
+ * @param target - The class prototype.
42
+ * @param propertyKey - The property name.
43
+ * @returns True if the property is marked with {@link noAutoMap}; false otherwise.
44
+ */
45
+ export function isPropertyNoAutoMap(target: object, propertyKey: string): boolean {
46
+ return Reflect.hasMetadata(PROPERTY_METADATA_KEY, target, propertyKey);
47
+ }
@@ -12,6 +12,9 @@ import { getEventTypeMetadata } from '../events/eventTypeDecorator';
12
12
  import { EventContext } from '../events/EventContext';
13
13
  import { EventTypeId } from '../events/EventTypeId';
14
14
  import { EventTypeGeneration } from '../events/EventTypeGeneration';
15
+ import { Tag } from '../events/Tag';
16
+ import { getTagsFor } from '../events/tagDecorator';
17
+ import { getFilterTagsFor } from '../events/filterEventsByTagDecorator';
15
18
  import { EventSequenceId } from '../eventSequences/EventSequenceId';
16
19
  import type { IEventLog } from '../eventSequences/IEventLog';
17
20
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle';
@@ -239,9 +242,9 @@ export class Reactors implements IReactors {
239
242
  Key: EVENT_SOURCE_ID_KEY
240
243
  })),
241
244
  IsReplayable: false,
242
- Tags: [],
245
+ Tags: getTagsFor(reactorType).map(t => t.value),
243
246
  Filters: {
244
- FilterTags: [],
247
+ FilterTags: getFilterTagsFor(reactorType).map(t => t.value),
245
248
  EventSourceType: '',
246
249
  EventStreamType: 'All'
247
250
  }
@@ -303,7 +306,8 @@ export class Reactors implements IReactors {
303
306
  },
304
307
  occurred: new Date(event.Context!.Occurred?.Value ?? ''),
305
308
  correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '',
306
- causation: []
309
+ causation: [],
310
+ tags: (event.Context!.Tags ?? []).map(value => new Tag(value))
307
311
  };
308
312
 
309
313
  this._logger.info('Invoking reactor handler', {
@@ -0,0 +1,98 @@
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 { ChronicleConnection } from '../connection';
8
+ import { pii } from '../compliance/pii';
9
+ import { subject } from '../compliance/subject';
10
+ import { readModel } from './readModel';
11
+ import { MaterializedReadModels } from './MaterializedReadModels';
12
+
13
+ // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
14
+ // fixtures don't depend on the test runner's decorator-syntax support.
15
+
16
+ function createMaterializedReadModels(instancesJson: string[], releaseResponse: Record<string, unknown> = { HasError: false, Payload: '{}' }) {
17
+ const release = vi.fn().mockResolvedValue(releaseResponse);
18
+ const getInstances = vi.fn().mockResolvedValue({ Instances: instancesJson });
19
+ const connection = {
20
+ compliance: { release },
21
+ materializedReadModels: { getInstances }
22
+ } as unknown as ChronicleConnection;
23
+
24
+ const readModels = new MaterializedReadModels('test-store', 'test-namespace', connection);
25
+ return { readModels, release };
26
+ }
27
+
28
+ describe('MaterializedReadModels', () => {
29
+ describe('when a read model has a property decorated with @subject()', () => {
30
+ class Employee {
31
+ id = '';
32
+ personId = '';
33
+ ssn = '';
34
+ }
35
+ field(String)(Employee.prototype, 'id');
36
+ field(String)(Employee.prototype, 'personId');
37
+ field(String)(Employee.prototype, 'ssn');
38
+ pii()(Employee.prototype, 'ssn');
39
+ subject()(Employee.prototype, 'personId');
40
+ readModel('EmployeeWithSubjectMaterialized')(Employee);
41
+
42
+ it('should release using the decorated property as the subject', async () => {
43
+ const json = JSON.stringify({ id: 'employee-1', personId: 'person-42', ssn: '123-45-6789' });
44
+ const { readModels, release } = createMaterializedReadModels([json]);
45
+
46
+ await readModels.getInstances(Employee);
47
+
48
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'person-42' }));
49
+ });
50
+ });
51
+
52
+ describe('when a read model has no property decorated with @subject() but has an id property', () => {
53
+ class Customer {
54
+ id = '';
55
+ ssn = '';
56
+ }
57
+ field(String)(Customer.prototype, 'id');
58
+ field(String)(Customer.prototype, 'ssn');
59
+ pii()(Customer.prototype, 'ssn');
60
+ readModel('CustomerWithIdOnlyMaterialized')(Customer);
61
+
62
+ it('should fall back to the id property as the subject, unchanged from today', async () => {
63
+ const json = JSON.stringify({ id: 'customer-7', ssn: '987-65-4321' });
64
+ const { readModels, release } = createMaterializedReadModels([json]);
65
+
66
+ await readModels.getInstances(Customer);
67
+
68
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-7' }));
69
+ });
70
+ });
71
+
72
+ describe('when a read model has neither a decorated property nor an id property', () => {
73
+ class Anonymous {
74
+ ssn = '';
75
+ }
76
+ field(String)(Anonymous.prototype, 'ssn');
77
+ pii()(Anonymous.prototype, 'ssn');
78
+ readModel('AnonymousMaterialized')(Anonymous);
79
+
80
+ it('should not call release, unchanged from today', async () => {
81
+ const json = JSON.stringify({ ssn: '000-00-0000' });
82
+ const { readModels, release } = createMaterializedReadModels([json]);
83
+
84
+ await readModels.getInstances(Anonymous);
85
+
86
+ expect(release).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should return the instance unreleased', async () => {
90
+ const json = JSON.stringify({ ssn: '000-00-0000' });
91
+ const { readModels } = createMaterializedReadModels([json]);
92
+
93
+ const [instance] = await readModels.getInstances(Anonymous);
94
+
95
+ expect(instance.ssn).toBe('000-00-0000');
96
+ });
97
+ });
98
+ });
@@ -6,6 +6,7 @@ import { JsonSerializer } from '@cratis/fundamentals';
6
6
  import { ChronicleConnection } from '../connection';
7
7
  import { JsonSchemaGenerator } from '../schemas';
8
8
  import { getReadModelMetadata } from './readModel';
9
+ import { ReadModelSubjectResolver } from './ReadModelSubjectResolver';
9
10
  import type { IMaterializedReadModels } from './IMaterializedReadModels';
10
11
 
11
12
  const defaultTake = 50;
@@ -97,7 +98,7 @@ export class MaterializedReadModels implements IMaterializedReadModels {
97
98
  }
98
99
 
99
100
  private async releaseInstance<TReadModel>(readModelType: Constructor<TReadModel>, instance: TReadModel, schema: string): Promise<TReadModel> {
100
- const subject = this.extractSubject(instance);
101
+ const subject = ReadModelSubjectResolver.resolveFrom(readModelType, instance);
101
102
  if (!subject) {
102
103
  return instance;
103
104
  }
@@ -141,11 +142,4 @@ export class MaterializedReadModels implements IMaterializedReadModels {
141
142
  }
142
143
  }
143
144
 
144
- private extractSubject<TReadModel>(instance: TReadModel): string | undefined {
145
- const anyInstance = instance as Record<string, unknown>;
146
- if (anyInstance.id !== undefined && anyInstance.id !== null) {
147
- return String(anyInstance.id);
148
- }
149
- return undefined;
150
- }
151
145
  }
@@ -0,0 +1,84 @@
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 { describe, expect, it } from 'vitest';
6
+ import { subject } from '../compliance/subject';
7
+ import { ReadModelSubjectResolver } from './ReadModelSubjectResolver';
8
+
9
+ // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
10
+ // fixtures don't depend on the test runner's decorator-syntax support.
11
+
12
+ describe('ReadModelSubjectResolver', () => {
13
+ describe('when the read model has a property decorated with @subject()', () => {
14
+ class Employee {
15
+ id = '';
16
+ personId = '';
17
+ }
18
+ subject()(Employee.prototype, 'personId');
19
+
20
+ it('should resolve to the decorated property value', () => {
21
+ const instance = new Employee();
22
+ instance.id = 'employee-1';
23
+ instance.personId = 'person-42';
24
+
25
+ expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).toBe('person-42');
26
+ });
27
+
28
+ it('should take precedence over the id property', () => {
29
+ const instance = new Employee();
30
+ instance.id = 'employee-1';
31
+ instance.personId = 'person-42';
32
+
33
+ expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).not.toBe('employee-1');
34
+ });
35
+
36
+ it('should fall back to id when the decorated property has no value', () => {
37
+ const instance = new Employee();
38
+ instance.id = 'employee-1';
39
+ instance.personId = '';
40
+
41
+ expect(ReadModelSubjectResolver.resolveFrom(Employee, instance)).toBe('employee-1');
42
+ });
43
+ });
44
+
45
+ describe('when the read model has no property decorated with @subject()', () => {
46
+ class Customer {
47
+ id = '';
48
+ name = '';
49
+ }
50
+
51
+ it('should fall back to the id property', () => {
52
+ const instance = new Customer();
53
+ instance.id = 'customer-7';
54
+
55
+ expect(ReadModelSubjectResolver.resolveFrom(Customer, instance)).toBe('customer-7');
56
+ });
57
+ });
58
+
59
+ describe('when the read model has neither a decorated property nor an id property', () => {
60
+ class Anonymous {
61
+ name = '';
62
+ }
63
+
64
+ it('should not resolve a subject', () => {
65
+ const instance = new Anonymous();
66
+
67
+ expect(ReadModelSubjectResolver.resolveFrom(Anonymous, instance)).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('when the instance does not exist', () => {
72
+ class Customer {
73
+ id = '';
74
+ }
75
+
76
+ it('should not resolve a subject for null', () => {
77
+ expect(ReadModelSubjectResolver.resolveFrom(Customer, null as unknown as Customer)).toBeUndefined();
78
+ });
79
+
80
+ it('should not resolve a subject for undefined', () => {
81
+ expect(ReadModelSubjectResolver.resolveFrom(Customer, undefined as unknown as Customer)).toBeUndefined();
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,44 @@
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 { getSubjectPropertyName } from '../compliance/subject';
6
+
7
+ /**
8
+ * Resolves the compliance subject - the natural person a read model's Personal Identifiable
9
+ * Information (PII) belongs to - from a read model instance.
10
+ */
11
+ export class ReadModelSubjectResolver {
12
+ /**
13
+ * Attempts to derive the compliance subject from a read model instance.
14
+ *
15
+ * Resolution order:
16
+ * 1. The property decorated with `@subject()` on {@link readModelType}, when it has a value.
17
+ * 2. The `id` property, by convention - kept so read models that predate `@subject()`
18
+ * continue to resolve exactly as before.
19
+ * @param readModelType - The read model type to resolve subject metadata for.
20
+ * @param instance - The read model instance to inspect, or undefined/null for a read model
21
+ * that does not exist.
22
+ * @returns The resolved subject, or undefined when neither source yields a value.
23
+ */
24
+ static resolveFrom<TReadModel>(readModelType: Constructor<TReadModel>, instance: TReadModel): string | undefined {
25
+ if (instance === undefined || instance === null) {
26
+ return undefined;
27
+ }
28
+
29
+ const anyInstance = instance as Record<string, unknown>;
30
+ const subjectProperty = getSubjectPropertyName(readModelType);
31
+ const explicitSubject = subjectProperty ? ReadModelSubjectResolver.toSubject(anyInstance[subjectProperty]) : undefined;
32
+
33
+ return explicitSubject ?? ReadModelSubjectResolver.toSubject(anyInstance.id);
34
+ }
35
+
36
+ private static toSubject(value: unknown): string | undefined {
37
+ if (value === undefined || value === null) {
38
+ return undefined;
39
+ }
40
+
41
+ const stringValue = String(value);
42
+ return stringValue.length > 0 ? stringValue : undefined;
43
+ }
44
+ }
@@ -0,0 +1,118 @@
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 type { Constructor } from '@cratis/fundamentals';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import type { IClientArtifactsProvider } from '../artifacts';
8
+ import type { ChronicleConnection } from '../connection';
9
+ import { subject } from '../compliance/subject';
10
+ import { fromEvent } from '../projections/modelBound/fromEvent';
11
+ import { readModel } from './readModel';
12
+ import { ReadModels } from './ReadModels';
13
+
14
+ // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
15
+ // fixtures don't depend on the test runner's decorator-syntax support.
16
+
17
+ class SomeEvent {
18
+ value = '';
19
+ }
20
+
21
+ function createReadModels(readModelType: Constructor, releaseResponse: Record<string, unknown> = { HasError: false, Payload: '{}' }) {
22
+ const release = vi.fn().mockResolvedValue(releaseResponse);
23
+ const connection = {
24
+ compliance: { release }
25
+ } as unknown as ChronicleConnection;
26
+
27
+ const clientArtifacts = {
28
+ eventTypes: [],
29
+ readModels: [readModelType],
30
+ reactors: [],
31
+ reducers: [],
32
+ seeders: [],
33
+ constraints: [],
34
+ projections: [],
35
+ webhooks: [],
36
+ eventTypeMigrations: []
37
+ } as IClientArtifactsProvider;
38
+
39
+ const readModels = new ReadModels('test-store', 'test-namespace', connection, clientArtifacts, 'default-sink');
40
+ return { readModels, release };
41
+ }
42
+
43
+ describe('ReadModels', () => {
44
+ describe('when releasing a read model with a property decorated with @subject()', () => {
45
+ class Employee {
46
+ id = '';
47
+ personId = '';
48
+ }
49
+ subject()(Employee.prototype, 'personId');
50
+ fromEvent(SomeEvent)(Employee);
51
+ readModel('EmployeeWithSubject')(Employee);
52
+
53
+ it('should release using the decorated property as the subject', async () => {
54
+ const { readModels, release } = createReadModels(Employee);
55
+ const instance = new Employee();
56
+ instance.id = 'employee-1';
57
+ instance.personId = 'person-42';
58
+
59
+ await readModels.release(Employee, instance);
60
+
61
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'person-42' }));
62
+ });
63
+ });
64
+
65
+ describe('when releasing a read model without @subject() but with an id property', () => {
66
+ class Customer {
67
+ id = '';
68
+ }
69
+ fromEvent(SomeEvent)(Customer);
70
+ readModel('CustomerWithIdOnly')(Customer);
71
+
72
+ it('should fall back to the id property as the subject, unchanged from today', async () => {
73
+ const { readModels, release } = createReadModels(Customer);
74
+ const instance = new Customer();
75
+ instance.id = 'customer-7';
76
+
77
+ await readModels.release(Customer, instance);
78
+
79
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-7' }));
80
+ });
81
+ });
82
+
83
+ describe('when releasing a read model with neither @subject() nor an id property', () => {
84
+ class Anonymous {
85
+ name = '';
86
+ }
87
+ fromEvent(SomeEvent)(Anonymous);
88
+ readModel('AnonymousReadModel')(Anonymous);
89
+
90
+ it('should throw, same as today', async () => {
91
+ const { readModels } = createReadModels(Anonymous);
92
+ const instance = new Anonymous();
93
+
94
+ await expect(readModels.release(Anonymous, instance)).rejects.toThrow(/subject/);
95
+ });
96
+ });
97
+
98
+ describe('when releasing many read model instances', () => {
99
+ class Customer {
100
+ id = '';
101
+ }
102
+ fromEvent(SomeEvent)(Customer);
103
+ readModel('CustomerForReleaseMany')(Customer);
104
+
105
+ it('should release each instance using its own resolved subject', async () => {
106
+ const { readModels, release } = createReadModels(Customer);
107
+ const first = new Customer();
108
+ first.id = 'customer-1';
109
+ const second = new Customer();
110
+ second.id = 'customer-2';
111
+
112
+ await readModels.releaseMany(Customer, [first, second]);
113
+
114
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-1' }));
115
+ expect(release).toHaveBeenCalledWith(expect.objectContaining({ Subject: 'customer-2' }));
116
+ });
117
+ });
118
+ });
@@ -23,6 +23,7 @@ import { WellKnownSinks } from '../sinks';
23
23
  import { getReadModelMetadata } from './readModel';
24
24
  import type { IMaterializedReadModels } from './IMaterializedReadModels';
25
25
  import { MaterializedReadModels } from './MaterializedReadModels';
26
+ import { ReadModelSubjectResolver } from './ReadModelSubjectResolver';
26
27
  import type { IReadModels } from './IReadModels';
27
28
  import type { ReadModelChangeset } from './ReadModelChangeset';
28
29
  import type { ReadModelSnapshot } from './ReadModelSnapshot';
@@ -173,7 +174,7 @@ export class ReadModels implements IReadModels {
173
174
  const readModel = this.resolveReadModel(readModelType);
174
175
  const schema = this.getReadModelSchema(readModelType, readModel.identifier);
175
176
  const payload = JsonSerializer.serialize(instance);
176
- const subject = this.extractSubject(instance);
177
+ const subject = this.extractSubject(readModelType, instance);
177
178
 
178
179
  const response = await this._connection.compliance.release({
179
180
  EventStore: this._eventStore,
@@ -345,12 +346,11 @@ export class ReadModels implements IReadModels {
345
346
  }
346
347
  }
347
348
 
348
- private extractSubject<TReadModel>(instance: TReadModel): string {
349
- // By convention, use the 'id' property as the subject
350
- const anyInstance = instance as any;
351
- if (anyInstance.id !== undefined && anyInstance.id !== null) {
352
- return String(anyInstance.id);
349
+ private extractSubject<TReadModel>(readModelType: Constructor<TReadModel>, instance: TReadModel): string {
350
+ const subject = ReadModelSubjectResolver.resolveFrom(readModelType, instance);
351
+ if (subject !== undefined) {
352
+ return subject;
353
353
  }
354
- throw new Error('Read model instance must have an "id" property to serve as the subject for PII release');
354
+ throw new Error('Read model instance must have a property decorated with @subject() or an "id" property to serve as the subject for PII release');
355
355
  }
356
356
  }
@@ -6,6 +6,7 @@ export type { IReadModels } from './IReadModels';
6
6
  export type { IMaterializedReadModels } from './IMaterializedReadModels';
7
7
  export { ReadModels } from './ReadModels';
8
8
  export { MaterializedReadModels } from './MaterializedReadModels';
9
+ export { ReadModelSubjectResolver } from './ReadModelSubjectResolver';
9
10
  export type { ReadModelChangeset } from './ReadModelChangeset';
10
11
  export type { ReadModelSnapshot } from './ReadModelSnapshot';
11
12
  export { readModel, getReadModelMetadata, isReadModel } from './readModel';
@@ -6,11 +6,48 @@ import type { Constructor } from '@cratis/fundamentals';
6
6
  import type { IClientArtifactsProvider } from '../artifacts';
7
7
  import type { ChronicleConnection } from '../connection';
8
8
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle';
9
+ import { eventType, getEventTypeFor } from '../events/eventTypeDecorator';
10
+ import type { EventContext } from '../events/EventContext';
11
+ import { filterEventsByTag } from '../events/filterEventsByTagDecorator';
12
+ import { tag } from '../events/tagDecorator';
9
13
  import { reducer } from './reducer';
10
14
  import { Reducers } from './Reducers';
11
15
 
12
16
  const flush = () => new Promise(resolve => setTimeout(resolve, 0));
13
17
 
18
+ class ReducersSomeEventHappened {
19
+ constructor(readonly value: string = '') {}
20
+ }
21
+ eventType('d2b2b2b2-2222-4a3c-9d3f-6f2f4a3c9d3f')(ReducersSomeEventHappened);
22
+
23
+ class SomeTaggedReducerState {
24
+ count = 0;
25
+ }
26
+
27
+ class SomeTaggedReducer {
28
+ reducersSomeEventHappened(): SomeTaggedReducerState {
29
+ return { count: 1 };
30
+ }
31
+ }
32
+ reducer('some-tagged-reducer', undefined, SomeTaggedReducerState)(SomeTaggedReducer);
33
+ tag('Analytics', 'Reporting')(SomeTaggedReducer);
34
+ filterEventsByTag('vip')(SomeTaggedReducer);
35
+ filterEventsByTag('priority')(SomeTaggedReducer);
36
+
37
+ const receivedContexts: EventContext[] = [];
38
+
39
+ class CapturingReducerState {
40
+ count = 0;
41
+ }
42
+
43
+ class CapturingReducer {
44
+ reducersSomeEventHappened(_event: ReducersSomeEventHappened, current: CapturingReducerState | undefined, context: EventContext): CapturingReducerState {
45
+ receivedContexts.push(context);
46
+ return { count: (current?.count ?? 0) + 1 };
47
+ }
48
+ }
49
+ reducer('capturing-reducer', undefined, CapturingReducerState)(CapturingReducer);
50
+
14
51
  /**
15
52
  * A minimal client artifacts provider exposing only the reducer types under test —
16
53
  * Reducers.ts only reads .reducers and .eventTypes off this during discovery.
@@ -84,4 +121,47 @@ describe('Reducers', () => {
84
121
  expect(message.Content.Value0.Reducer.IsActive).toBe(false);
85
122
  });
86
123
  });
124
+
125
+ describe('when registering a reducer that is tagged and filters by tag', () => {
126
+ type TaggedRegistration = {
127
+ Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } };
128
+ };
129
+
130
+ const register = async () => {
131
+ const { connection, registrationMessages } = createConnection();
132
+ const reducers = new Reducers(createArtifacts([SomeTaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink');
133
+
134
+ await reducers.register();
135
+ await flush();
136
+
137
+ return (registrationMessages[0] as TaggedRegistration).Content.Value0.Reducer;
138
+ };
139
+
140
+ it('should carry the tags the reducer is labeled with', async () => {
141
+ expect((await register()).Tags).toEqual(['Analytics', 'Reporting']);
142
+ });
143
+
144
+ it('should carry every tag it filters events by', async () => {
145
+ expect((await register()).Filters.FilterTags).toEqual(['vip', 'priority']);
146
+ });
147
+ });
148
+
149
+ describe('when registering a reducer that is neither tagged nor filtered', () => {
150
+ class SomeUntaggedReducer {}
151
+ reducer('some-untagged-reducer')(SomeUntaggedReducer);
152
+
153
+ it('should send empty tag lists rather than omitting them', async () => {
154
+ const { connection, registrationMessages } = createConnection();
155
+ const reducers = new Reducers(createArtifacts([SomeUntaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink');
156
+
157
+ await reducers.register();
158
+ await flush();
159
+
160
+ const message = registrationMessages[0] as {
161
+ Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } };
162
+ };
163
+ expect(message.Content.Value0.Reducer.Tags).toEqual([]);
164
+ expect(message.Content.Value0.Reducer.Filters.FilterTags).toEqual([]);
165
+ });
166
+ });
87
167
  });
@@ -10,6 +10,12 @@ import { ChronicleConnection } from '../connection';
10
10
  import { toContractsGuid } from '../connection/Guid';
11
11
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle';
12
12
  import { getEventTypeMetadata } from '../events/eventTypeDecorator';
13
+ import { EventContext } from '../events/EventContext';
14
+ import { EventTypeId } from '../events/EventTypeId';
15
+ import { EventTypeGeneration } from '../events/EventTypeGeneration';
16
+ import { Tag } from '../events/Tag';
17
+ import { getTagsFor } from '../events/tagDecorator';
18
+ import { getFilterTagsFor } from '../events/filterEventsByTagDecorator';
13
19
  import { EventSequenceId } from '../eventSequences/EventSequenceId';
14
20
  import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle';
15
21
  import { IReducers } from './IReducers';
@@ -310,9 +316,9 @@ export class Reducers implements IReducers {
310
316
  })),
311
317
  ReadModel: readModelName,
312
318
  IsActive: isActive,
313
- Tags: [],
319
+ Tags: getTagsFor(reducerType).map(t => t.value),
314
320
  Filters: {
315
- FilterTags: [],
321
+ FilterTags: getFilterTagsFor(reducerType).map(t => t.value),
316
322
  EventSourceType: '',
317
323
  EventStreamType: 'All'
318
324
  }
@@ -369,6 +375,19 @@ export class Reducers implements IReducers {
369
375
  }
370
376
 
371
377
  const content = JSON.parse(event.Content) as Record<string, unknown>;
378
+ const context: EventContext = {
379
+ sequenceNumber: event.Context!.SequenceNumber,
380
+ eventSourceId: event.Context!.EventSourceId,
381
+ eventType: {
382
+ id: new EventTypeId(event.Context!.EventType!.Id),
383
+ generation: new EventTypeGeneration(event.Context!.EventType!.Generation),
384
+ tombstone: event.Context!.EventType!.Tombstone
385
+ },
386
+ occurred: new Date(event.Context!.Occurred?.Value ?? ''),
387
+ correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '',
388
+ causation: [],
389
+ tags: (event.Context!.Tags ?? []).map(value => new Tag(value))
390
+ };
372
391
 
373
392
  this._logger.info('Invoking reducer handler', {
374
393
  reducerId: id,
@@ -378,7 +397,7 @@ export class Reducers implements IReducers {
378
397
  hasState: currentState !== undefined
379
398
  });
380
399
 
381
- currentState = await reducerInstance[entry.methodName](content, currentState);
400
+ currentState = await reducerInstance[entry.methodName](content, currentState, context);
382
401
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
383
402
  } catch (err) {
384
403
  this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });