@cratis/chronicle 3.1.1 → 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 (194) hide show
  1. package/EventStore.ts +1 -1
  2. package/compliance/ComplianceContracts.ts +59 -0
  3. package/compliance/IPIIManager.ts +12 -0
  4. package/compliance/PIIManager.spec.ts +50 -0
  5. package/compliance/PIIManager.ts +9 -0
  6. package/compliance/PIINotSupportedOnEventSourceId.ts +21 -0
  7. package/compliance/index.ts +2 -0
  8. package/compliance/pii.spec.ts +37 -0
  9. package/compliance/pii.ts +16 -1
  10. package/compliance/subject.spec.ts +45 -0
  11. package/compliance/subject.ts +64 -0
  12. package/dist/EventStore.js +1 -1
  13. package/dist/EventStore.js.map +1 -1
  14. package/dist/compliance/ComplianceContracts.d.ts +31 -0
  15. package/dist/compliance/ComplianceContracts.d.ts.map +1 -1
  16. package/dist/compliance/ComplianceContracts.js +52 -0
  17. package/dist/compliance/ComplianceContracts.js.map +1 -1
  18. package/dist/compliance/IPIIManager.d.ts +11 -0
  19. package/dist/compliance/IPIIManager.d.ts.map +1 -1
  20. package/dist/compliance/PIIManager.d.ts +2 -0
  21. package/dist/compliance/PIIManager.d.ts.map +1 -1
  22. package/dist/compliance/PIIManager.js +8 -0
  23. package/dist/compliance/PIIManager.js.map +1 -1
  24. package/dist/compliance/PIINotSupportedOnEventSourceId.d.ts +11 -0
  25. package/dist/compliance/PIINotSupportedOnEventSourceId.d.ts.map +1 -0
  26. package/dist/compliance/PIINotSupportedOnEventSourceId.js +19 -0
  27. package/dist/compliance/PIINotSupportedOnEventSourceId.js.map +1 -0
  28. package/dist/compliance/index.d.ts +2 -0
  29. package/dist/compliance/index.d.ts.map +1 -1
  30. package/dist/compliance/index.js +2 -0
  31. package/dist/compliance/index.js.map +1 -1
  32. package/dist/compliance/pii.d.ts.map +1 -1
  33. package/dist/compliance/pii.js +13 -1
  34. package/dist/compliance/pii.js.map +1 -1
  35. package/dist/compliance/subject.d.ts +39 -0
  36. package/dist/compliance/subject.d.ts.map +1 -0
  37. package/dist/compliance/subject.js +58 -0
  38. package/dist/compliance/subject.js.map +1 -0
  39. package/dist/eventSequences/AppendOptions.d.ts +8 -0
  40. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  41. package/dist/eventSequences/EventForEventSourceId.d.ts +7 -0
  42. package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
  43. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  44. package/dist/eventSequences/EventSequence.js +17 -6
  45. package/dist/eventSequences/EventSequence.js.map +1 -1
  46. package/dist/events/EventContext.d.ts +3 -0
  47. package/dist/events/EventContext.d.ts.map +1 -1
  48. package/dist/events/Tag.d.ts +10 -0
  49. package/dist/events/Tag.d.ts.map +1 -0
  50. package/dist/events/Tag.js +16 -0
  51. package/dist/events/Tag.js.map +1 -0
  52. package/dist/events/filterEventsByTagDecorator.d.ts +35 -0
  53. package/dist/events/filterEventsByTagDecorator.d.ts.map +1 -0
  54. package/dist/events/filterEventsByTagDecorator.js +48 -0
  55. package/dist/events/filterEventsByTagDecorator.js.map +1 -0
  56. package/dist/events/index.d.ts +4 -0
  57. package/dist/events/index.d.ts.map +1 -1
  58. package/dist/events/index.js +4 -0
  59. package/dist/events/index.js.map +1 -1
  60. package/dist/events/mergeTags.d.ts +12 -0
  61. package/dist/events/mergeTags.d.ts.map +1 -0
  62. package/dist/events/mergeTags.js +26 -0
  63. package/dist/events/mergeTags.js.map +1 -0
  64. package/dist/events/tagDecorator.d.ts +38 -0
  65. package/dist/events/tagDecorator.d.ts.map +1 -0
  66. package/dist/events/tagDecorator.js +54 -0
  67. package/dist/events/tagDecorator.js.map +1 -0
  68. package/dist/observation/ObserverRunningState.d.ts +9 -15
  69. package/dist/observation/ObserverRunningState.d.ts.map +1 -1
  70. package/dist/observation/ObserverRunningState.js +8 -14
  71. package/dist/observation/ObserverRunningState.js.map +1 -1
  72. package/dist/observation/index.d.ts +1 -0
  73. package/dist/observation/index.d.ts.map +1 -1
  74. package/dist/observation/index.js +1 -0
  75. package/dist/observation/index.js.map +1 -1
  76. package/dist/observation/toObserverRunningState.d.ts +9 -0
  77. package/dist/observation/toObserverRunningState.d.ts.map +1 -0
  78. package/dist/observation/toObserverRunningState.js +26 -0
  79. package/dist/observation/toObserverRunningState.js.map +1 -0
  80. package/dist/projections/IProjections.d.ts +71 -1
  81. package/dist/projections/IProjections.d.ts.map +1 -1
  82. package/dist/projections/ProjectionQueryResult.d.ts +8 -0
  83. package/dist/projections/ProjectionQueryResult.d.ts.map +1 -0
  84. package/dist/projections/ProjectionQueryResult.js +4 -0
  85. package/dist/projections/ProjectionQueryResult.js.map +1 -0
  86. package/dist/projections/ProjectionState.d.ts +18 -0
  87. package/dist/projections/ProjectionState.d.ts.map +1 -0
  88. package/dist/projections/ProjectionState.js +4 -0
  89. package/dist/projections/ProjectionState.js.map +1 -0
  90. package/dist/projections/Projections.d.ts +46 -1
  91. package/dist/projections/Projections.d.ts.map +1 -1
  92. package/dist/projections/Projections.js +153 -6
  93. package/dist/projections/Projections.js.map +1 -1
  94. package/dist/projections/UnableToQueryProjection.d.ts +12 -0
  95. package/dist/projections/UnableToQueryProjection.d.ts.map +1 -0
  96. package/dist/projections/UnableToQueryProjection.js +17 -0
  97. package/dist/projections/UnableToQueryProjection.js.map +1 -0
  98. package/dist/projections/index.d.ts +3 -0
  99. package/dist/projections/index.d.ts.map +1 -1
  100. package/dist/projections/index.js +1 -0
  101. package/dist/projections/index.js.map +1 -1
  102. package/dist/projections/modelBound/eventSequence.d.ts +22 -0
  103. package/dist/projections/modelBound/eventSequence.d.ts.map +1 -0
  104. package/dist/projections/modelBound/eventSequence.js +34 -0
  105. package/dist/projections/modelBound/eventSequence.js.map +1 -0
  106. package/dist/projections/modelBound/fromAll.d.ts +26 -0
  107. package/dist/projections/modelBound/fromAll.d.ts.map +1 -0
  108. package/dist/projections/modelBound/fromAll.js +31 -0
  109. package/dist/projections/modelBound/fromAll.js.map +1 -0
  110. package/dist/projections/modelBound/index.d.ts +4 -0
  111. package/dist/projections/modelBound/index.d.ts.map +1 -1
  112. package/dist/projections/modelBound/index.js +3 -0
  113. package/dist/projections/modelBound/index.js.map +1 -1
  114. package/dist/projections/modelBound/noAutoMap.d.ts +26 -0
  115. package/dist/projections/modelBound/noAutoMap.d.ts.map +1 -0
  116. package/dist/projections/modelBound/noAutoMap.js +44 -0
  117. package/dist/projections/modelBound/noAutoMap.js.map +1 -0
  118. package/dist/reactors/Reactors.d.ts.map +1 -1
  119. package/dist/reactors/Reactors.js +7 -3
  120. package/dist/reactors/Reactors.js.map +1 -1
  121. package/dist/readModels/MaterializedReadModels.d.ts +0 -1
  122. package/dist/readModels/MaterializedReadModels.d.ts.map +1 -1
  123. package/dist/readModels/MaterializedReadModels.js +2 -8
  124. package/dist/readModels/MaterializedReadModels.js.map +1 -1
  125. package/dist/readModels/ReadModelSubjectResolver.d.ts +22 -0
  126. package/dist/readModels/ReadModelSubjectResolver.d.ts.map +1 -0
  127. package/dist/readModels/ReadModelSubjectResolver.js +38 -0
  128. package/dist/readModels/ReadModelSubjectResolver.js.map +1 -0
  129. package/dist/readModels/ReadModels.d.ts.map +1 -1
  130. package/dist/readModels/ReadModels.js +7 -7
  131. package/dist/readModels/ReadModels.js.map +1 -1
  132. package/dist/readModels/index.d.ts +1 -0
  133. package/dist/readModels/index.d.ts.map +1 -1
  134. package/dist/readModels/index.js +1 -0
  135. package/dist/readModels/index.js.map +1 -1
  136. package/dist/reducers/Reducers.d.ts.map +1 -1
  137. package/dist/reducers/Reducers.js +21 -3
  138. package/dist/reducers/Reducers.js.map +1 -1
  139. package/dist/schemas/JsonSchema.d.ts +14 -0
  140. package/dist/schemas/JsonSchema.d.ts.map +1 -1
  141. package/dist/schemas/JsonSchemaGenerator.d.ts +61 -5
  142. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  143. package/dist/schemas/JsonSchemaGenerator.js +130 -26
  144. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  145. package/dist/schemas/index.d.ts +1 -1
  146. package/dist/schemas/index.d.ts.map +1 -1
  147. package/dist/seeding/EventSeeding.d.ts.map +1 -1
  148. package/dist/seeding/EventSeeding.js +3 -1
  149. package/dist/seeding/EventSeeding.js.map +1 -1
  150. package/dist/tsconfig.tsbuildinfo +1 -1
  151. package/eventSequences/AppendOptions.ts +9 -0
  152. package/eventSequences/EventForEventSourceId.ts +9 -0
  153. package/eventSequences/EventSequence.ts +20 -6
  154. package/events/EventContext.ts +4 -0
  155. package/events/Tag.ts +14 -0
  156. package/events/filterEventsByTagDecorator.ts +51 -0
  157. package/events/index.ts +4 -0
  158. package/events/mergeTags.spec.ts +50 -0
  159. package/events/mergeTags.ts +27 -0
  160. package/events/tagDecorator.spec.ts +115 -0
  161. package/events/tagDecorator.ts +59 -0
  162. package/observation/ObserverRunningState.ts +9 -18
  163. package/observation/index.ts +1 -0
  164. package/observation/toObserverRunningState.ts +27 -0
  165. package/package.json +8 -8
  166. package/projections/IProjections.ts +81 -1
  167. package/projections/ProjectionQueryResult.ts +10 -0
  168. package/projections/ProjectionState.ts +25 -0
  169. package/projections/Projections.childrenAndNested.spec.ts +1 -1
  170. package/projections/Projections.modelBoundCompleteness.spec.ts +224 -0
  171. package/projections/Projections.operationalSurface.spec.ts +257 -0
  172. package/projections/Projections.spec.ts +1 -1
  173. package/projections/Projections.ts +180 -5
  174. package/projections/UnableToQueryProjection.ts +17 -0
  175. package/projections/index.ts +3 -0
  176. package/projections/modelBound/eventSequence.ts +38 -0
  177. package/projections/modelBound/fromAll.ts +42 -0
  178. package/projections/modelBound/index.ts +4 -0
  179. package/projections/modelBound/noAutoMap.ts +47 -0
  180. package/reactors/Reactors.ts +7 -3
  181. package/readModels/MaterializedReadModels.spec.ts +98 -0
  182. package/readModels/MaterializedReadModels.ts +2 -8
  183. package/readModels/ReadModelSubjectResolver.spec.ts +84 -0
  184. package/readModels/ReadModelSubjectResolver.ts +44 -0
  185. package/readModels/ReadModels.spec.ts +118 -0
  186. package/readModels/ReadModels.ts +7 -7
  187. package/readModels/index.ts +1 -0
  188. package/reducers/Reducers.spec.ts +80 -0
  189. package/reducers/Reducers.ts +22 -3
  190. package/schemas/JsonSchema.ts +16 -0
  191. package/schemas/JsonSchemaGenerator.spec.ts +195 -0
  192. package/schemas/JsonSchemaGenerator.ts +145 -27
  193. package/schemas/index.ts +1 -1
  194. package/seeding/EventSeeding.ts +3 -1
@@ -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) });
@@ -1,6 +1,21 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ /**
5
+ * Represents a single compliance classification recorded on a schema node.
6
+ */
7
+ export interface ComplianceSchemaMetadata {
8
+ /**
9
+ * The type of compliance metadata (e.g. 'PII').
10
+ */
11
+ metadataType: string;
12
+
13
+ /**
14
+ * Any additional details - can be empty.
15
+ */
16
+ details: string;
17
+ }
18
+
4
19
  /**
5
20
  * Represents a JSON Schema object.
6
21
  */
@@ -15,4 +30,5 @@ export type JsonSchema = {
15
30
  items?: JsonSchema;
16
31
  additionalProperties?: boolean | JsonSchema;
17
32
  enum?: Array<string | number | boolean | null>;
33
+ compliance?: ComplianceSchemaMetadata[];
18
34
  };
@@ -0,0 +1,195 @@
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 { ConceptAs, field } from '@cratis/fundamentals';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { pii } from '../compliance/pii';
8
+ import { eventType, getEventTypeJsonSchemaFor } from '../events/eventTypeDecorator';
9
+ import { getReadModelMetadata, readModel } from '../readModels/readModel';
10
+ import { JsonSchema } from './JsonSchema';
11
+
12
+ // Decorators are applied as plain function calls (rather than `@decorator` syntax) so these
13
+ // fixtures don't depend on the test runner's decorator-syntax support - they exercise exactly
14
+ // the same decorator functions and metadata storage that `@decorator` syntax would invoke.
15
+ //
16
+ // Order matters here in a way it would not for a real `@decorator` stack: class decorators
17
+ // apply bottom-to-top, so `pii()` must run - and therefore be called - before `readModel()`/
18
+ // `eventType()` so the compliance metadata already exists when the schema is generated.
19
+
20
+ function schemaFor(target: Function): JsonSchema {
21
+ return getReadModelMetadata(target)!.schema;
22
+ }
23
+
24
+ describe('JsonSchemaGenerator', () => {
25
+ describe('when a class is marked @pii() at the class level', () => {
26
+ class PersonProfile {
27
+ name = '';
28
+ email = '';
29
+ }
30
+ pii()(PersonProfile);
31
+ readModel()(PersonProfile);
32
+
33
+ const schema = schemaFor(PersonProfile);
34
+
35
+ it('should mark every one of its own properties as PII', () => {
36
+ expect(schema.properties!.name.compliance).toEqual([{ metadataType: 'PII', details: '' }]);
37
+ expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: '' }]);
38
+ });
39
+ });
40
+
41
+ describe('when only a single property is marked @pii()', () => {
42
+ class Contact {
43
+ name = '';
44
+ ssn = '';
45
+ }
46
+ pii('Social security number')(Contact.prototype, 'ssn');
47
+ readModel()(Contact);
48
+
49
+ const schema = schemaFor(Contact);
50
+
51
+ it('should mark the decorated property as PII', () => {
52
+ expect(schema.properties!.ssn.compliance).toEqual([{ metadataType: 'PII', details: 'Social security number' }]);
53
+ });
54
+
55
+ it('should leave the other property without compliance metadata', () => {
56
+ expect(schema.properties!.name.compliance).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe('when a property is typed as a ConceptAs<T> marked @pii()', () => {
61
+ class EmailAddress extends ConceptAs<string> {
62
+ constructor(value: string) {
63
+ super(value);
64
+ }
65
+ }
66
+ pii('Email address')(EmailAddress);
67
+
68
+ describe('on a read model', () => {
69
+ class Customer {
70
+ email: EmailAddress = new EmailAddress('');
71
+ }
72
+ readModel()(Customer);
73
+
74
+ const schema = schemaFor(Customer);
75
+
76
+ it('should mark the concept-typed property as PII', () => {
77
+ expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Email address' }]);
78
+ });
79
+
80
+ it('should describe the concept as its underlying primitive type', () => {
81
+ expect(schema.properties!.email.type).toBe('string');
82
+ });
83
+ });
84
+
85
+ describe('on an event', () => {
86
+ class CustomerRegistered {
87
+ email: EmailAddress = new EmailAddress('');
88
+ }
89
+ eventType()(CustomerRegistered);
90
+
91
+ const schema = getEventTypeJsonSchemaFor(CustomerRegistered);
92
+
93
+ it('should mark the concept-typed property as PII', () => {
94
+ expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Email address' }]);
95
+ });
96
+ });
97
+ });
98
+
99
+ describe('when a property-level @pii() combines with a class-level @pii()', () => {
100
+ class Employee {
101
+ name = '';
102
+ email = '';
103
+ }
104
+ pii()(Employee.prototype, 'name');
105
+ pii('Every field on this record is personal')(Employee);
106
+ readModel()(Employee);
107
+
108
+ const schema = schemaFor(Employee);
109
+
110
+ it('should not duplicate the compliance entry on the property carrying both markers', () => {
111
+ expect(schema.properties!.name.compliance).toHaveLength(1);
112
+ });
113
+
114
+ it('should keep the property-level details when both sources apply', () => {
115
+ expect(schema.properties!.name.compliance).toEqual([{ metadataType: 'PII', details: '' }]);
116
+ });
117
+
118
+ it('should still mark the property that only carries the class-level marker', () => {
119
+ expect(schema.properties!.email.compliance).toEqual([{ metadataType: 'PII', details: 'Every field on this record is personal' }]);
120
+ });
121
+ });
122
+
123
+ describe('when a property typed as a nested composite value object is marked @pii()', () => {
124
+ class ContactDetails {
125
+ phone = '';
126
+ fax = '';
127
+ }
128
+ class Vendor {
129
+ contact: ContactDetails = new ContactDetails();
130
+ }
131
+ pii('Vendor contact information')(Vendor.prototype, 'contact');
132
+ readModel()(Vendor);
133
+
134
+ const schema = schemaFor(Vendor);
135
+
136
+ it('should push the compliance metadata down onto every leaf property', () => {
137
+ expect(schema.properties!.contact.properties!.phone.compliance).toEqual([{ metadataType: 'PII', details: 'Vendor contact information' }]);
138
+ expect(schema.properties!.contact.properties!.fax.compliance).toEqual([{ metadataType: 'PII', details: 'Vendor contact information' }]);
139
+ });
140
+
141
+ it('should not leave compliance metadata on the container node itself', () => {
142
+ expect(schema.properties!.contact.compliance).toBeUndefined();
143
+ });
144
+ });
145
+
146
+ describe('when an array element is a ConceptAs<T> marked @pii()', () => {
147
+ class RequirementCode extends ConceptAs<string> {
148
+ constructor(value: string) {
149
+ super(value);
150
+ }
151
+ }
152
+ pii('Sensitive requirement code')(RequirementCode);
153
+
154
+ class Contract {
155
+ codes: RequirementCode[] = [];
156
+ }
157
+ field(Array, { enumerable: true, genericArguments: [RequirementCode] })(Contract.prototype, 'codes');
158
+ readModel()(Contract);
159
+
160
+ const schema = schemaFor(Contract);
161
+
162
+ it('should describe the property as an array', () => {
163
+ expect(schema.properties!.codes.type).toBe('array');
164
+ });
165
+
166
+ it('should carry the element concept compliance metadata onto the item schema', () => {
167
+ expect(schema.properties!.codes.items!.compliance).toEqual([{ metadataType: 'PII', details: 'Sensitive requirement code' }]);
168
+ });
169
+
170
+ it('should describe the item as the concept underlying primitive type', () => {
171
+ expect(schema.properties!.codes.items!.type).toBe('string');
172
+ });
173
+
174
+ it('should not leave coarse compliance metadata on the array container itself', () => {
175
+ expect(schema.properties!.codes.compliance).toBeUndefined();
176
+ });
177
+ });
178
+
179
+ describe('when an array element is not a ConceptAs<T>', () => {
180
+ class PlainItem {
181
+ value = '';
182
+ }
183
+ class Basket {
184
+ items: PlainItem[] = [];
185
+ }
186
+ field(Array, { enumerable: true, genericArguments: [PlainItem] })(Basket.prototype, 'items');
187
+ readModel()(Basket);
188
+
189
+ const schema = schemaFor(Basket);
190
+
191
+ it('should fall back to an opaque object item schema', () => {
192
+ expect(schema.properties!.items.items).toEqual({ type: 'object' });
193
+ });
194
+ });
195
+ });