@cratis/chronicle 6.3.2 → 6.5.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 (228) hide show
  1. package/ChronicleClient.ts +1 -1
  2. package/EventStore.ts +7 -2
  3. package/README.md +13 -2
  4. package/artifacts/validateArtifactSchemas.spec.ts +75 -0
  5. package/artifacts/validateArtifactSchemas.ts +39 -0
  6. package/auditing/AmbientContext.spec.ts +57 -0
  7. package/auditing/CausationManager.ts +13 -15
  8. package/compliance/pii.ts +7 -6
  9. package/compliance/subject.ts +6 -5
  10. package/confidentiality/encrypted.ts +7 -6
  11. package/correlation/CorrelationIdManager.ts +5 -0
  12. package/dist/ChronicleClient.js +1 -1
  13. package/dist/ChronicleClient.js.map +1 -1
  14. package/dist/EventStore.d.ts +3 -1
  15. package/dist/EventStore.d.ts.map +1 -1
  16. package/dist/EventStore.js +6 -2
  17. package/dist/EventStore.js.map +1 -1
  18. package/dist/artifacts/validateArtifactSchemas.d.ts +4 -0
  19. package/dist/artifacts/validateArtifactSchemas.d.ts.map +1 -0
  20. package/dist/artifacts/validateArtifactSchemas.js +38 -0
  21. package/dist/artifacts/validateArtifactSchemas.js.map +1 -0
  22. package/dist/auditing/CausationManager.d.ts +3 -1
  23. package/dist/auditing/CausationManager.d.ts.map +1 -1
  24. package/dist/auditing/CausationManager.js +9 -15
  25. package/dist/auditing/CausationManager.js.map +1 -1
  26. package/dist/compliance/pii.d.ts +2 -1
  27. package/dist/compliance/pii.d.ts.map +1 -1
  28. package/dist/compliance/pii.js +6 -5
  29. package/dist/compliance/pii.js.map +1 -1
  30. package/dist/compliance/subject.d.ts +2 -1
  31. package/dist/compliance/subject.d.ts.map +1 -1
  32. package/dist/compliance/subject.js +5 -4
  33. package/dist/compliance/subject.js.map +1 -1
  34. package/dist/confidentiality/encrypted.d.ts +2 -1
  35. package/dist/confidentiality/encrypted.d.ts.map +1 -1
  36. package/dist/confidentiality/encrypted.js +6 -5
  37. package/dist/confidentiality/encrypted.js.map +1 -1
  38. package/dist/correlation/CorrelationIdManager.d.ts +2 -0
  39. package/dist/correlation/CorrelationIdManager.d.ts.map +1 -1
  40. package/dist/correlation/CorrelationIdManager.js +4 -0
  41. package/dist/correlation/CorrelationIdManager.js.map +1 -1
  42. package/dist/eventSequences/AppendOptions.d.ts +3 -4
  43. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  44. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  45. package/dist/eventSequences/EventSequence.js +19 -14
  46. package/dist/eventSequences/EventSequence.js.map +1 -1
  47. package/dist/eventSequences/EventSequenceNumber.d.ts +3 -1
  48. package/dist/eventSequences/EventSequenceNumber.d.ts.map +1 -1
  49. package/dist/eventSequences/EventSequenceNumber.js +3 -1
  50. package/dist/eventSequences/EventSequenceNumber.js.map +1 -1
  51. package/dist/events/eventTypeDecorator.d.ts +8 -7
  52. package/dist/events/eventTypeDecorator.d.ts.map +1 -1
  53. package/dist/events/eventTypeDecorator.js +22 -5
  54. package/dist/events/eventTypeDecorator.js.map +1 -1
  55. package/dist/events/index.d.ts +2 -0
  56. package/dist/events/index.d.ts.map +1 -1
  57. package/dist/events/index.js +2 -0
  58. package/dist/events/index.js.map +1 -1
  59. package/dist/events/isRegisteredEvent.d.ts +4 -0
  60. package/dist/events/isRegisteredEvent.d.ts.map +1 -0
  61. package/dist/events/isRegisteredEvent.js +9 -0
  62. package/dist/events/isRegisteredEvent.js.map +1 -0
  63. package/dist/identity/IdentityProvider.d.ts +2 -0
  64. package/dist/identity/IdentityProvider.d.ts.map +1 -1
  65. package/dist/identity/IdentityProvider.js +4 -0
  66. package/dist/identity/IdentityProvider.js.map +1 -1
  67. package/dist/projections/modelBound/addFrom.d.ts +2 -1
  68. package/dist/projections/modelBound/addFrom.d.ts.map +1 -1
  69. package/dist/projections/modelBound/addFrom.js +4 -3
  70. package/dist/projections/modelBound/addFrom.js.map +1 -1
  71. package/dist/projections/modelBound/childrenFrom.d.ts +2 -1
  72. package/dist/projections/modelBound/childrenFrom.d.ts.map +1 -1
  73. package/dist/projections/modelBound/childrenFrom.js +4 -3
  74. package/dist/projections/modelBound/childrenFrom.js.map +1 -1
  75. package/dist/projections/modelBound/clearWith.d.ts +2 -1
  76. package/dist/projections/modelBound/clearWith.d.ts.map +1 -1
  77. package/dist/projections/modelBound/clearWith.js +4 -3
  78. package/dist/projections/modelBound/clearWith.js.map +1 -1
  79. package/dist/projections/modelBound/count.d.ts +2 -1
  80. package/dist/projections/modelBound/count.d.ts.map +1 -1
  81. package/dist/projections/modelBound/count.js +4 -3
  82. package/dist/projections/modelBound/count.js.map +1 -1
  83. package/dist/projections/modelBound/decrement.d.ts +2 -1
  84. package/dist/projections/modelBound/decrement.d.ts.map +1 -1
  85. package/dist/projections/modelBound/decrement.js +4 -3
  86. package/dist/projections/modelBound/decrement.js.map +1 -1
  87. package/dist/projections/modelBound/fromAll.d.ts +2 -1
  88. package/dist/projections/modelBound/fromAll.d.ts.map +1 -1
  89. package/dist/projections/modelBound/fromAll.js +4 -3
  90. package/dist/projections/modelBound/fromAll.js.map +1 -1
  91. package/dist/projections/modelBound/fromEvery.d.ts +2 -1
  92. package/dist/projections/modelBound/fromEvery.d.ts.map +1 -1
  93. package/dist/projections/modelBound/fromEvery.js +4 -3
  94. package/dist/projections/modelBound/fromEvery.js.map +1 -1
  95. package/dist/projections/modelBound/increment.d.ts +2 -1
  96. package/dist/projections/modelBound/increment.d.ts.map +1 -1
  97. package/dist/projections/modelBound/increment.js +4 -3
  98. package/dist/projections/modelBound/increment.js.map +1 -1
  99. package/dist/projections/modelBound/join.d.ts +2 -1
  100. package/dist/projections/modelBound/join.d.ts.map +1 -1
  101. package/dist/projections/modelBound/join.js +4 -3
  102. package/dist/projections/modelBound/join.js.map +1 -1
  103. package/dist/projections/modelBound/nested.d.ts +1 -6
  104. package/dist/projections/modelBound/nested.d.ts.map +1 -1
  105. package/dist/projections/modelBound/nested.js +6 -2
  106. package/dist/projections/modelBound/nested.js.map +1 -1
  107. package/dist/projections/modelBound/noAutoMap.d.ts +4 -11
  108. package/dist/projections/modelBound/noAutoMap.d.ts.map +1 -1
  109. package/dist/projections/modelBound/noAutoMap.js +6 -2
  110. package/dist/projections/modelBound/noAutoMap.js.map +1 -1
  111. package/dist/projections/modelBound/removedWith.d.ts +2 -1
  112. package/dist/projections/modelBound/removedWith.d.ts.map +1 -1
  113. package/dist/projections/modelBound/removedWith.js +4 -3
  114. package/dist/projections/modelBound/removedWith.js.map +1 -1
  115. package/dist/projections/modelBound/removedWithJoin.d.ts +2 -1
  116. package/dist/projections/modelBound/removedWithJoin.d.ts.map +1 -1
  117. package/dist/projections/modelBound/removedWithJoin.js +4 -3
  118. package/dist/projections/modelBound/removedWithJoin.js.map +1 -1
  119. package/dist/projections/modelBound/setFrom.d.ts +2 -1
  120. package/dist/projections/modelBound/setFrom.d.ts.map +1 -1
  121. package/dist/projections/modelBound/setFrom.js +4 -3
  122. package/dist/projections/modelBound/setFrom.js.map +1 -1
  123. package/dist/projections/modelBound/setFromContext.d.ts +3 -2
  124. package/dist/projections/modelBound/setFromContext.d.ts.map +1 -1
  125. package/dist/projections/modelBound/setFromContext.js +4 -3
  126. package/dist/projections/modelBound/setFromContext.js.map +1 -1
  127. package/dist/projections/modelBound/setValue.d.ts +2 -1
  128. package/dist/projections/modelBound/setValue.d.ts.map +1 -1
  129. package/dist/projections/modelBound/setValue.js +4 -3
  130. package/dist/projections/modelBound/setValue.js.map +1 -1
  131. package/dist/projections/modelBound/subtractFrom.d.ts +2 -1
  132. package/dist/projections/modelBound/subtractFrom.d.ts.map +1 -1
  133. package/dist/projections/modelBound/subtractFrom.js +4 -3
  134. package/dist/projections/modelBound/subtractFrom.js.map +1 -1
  135. package/dist/readModels/IReadModels.d.ts +10 -1
  136. package/dist/readModels/IReadModels.d.ts.map +1 -1
  137. package/dist/readModels/MaterializedReadModels.js +2 -2
  138. package/dist/readModels/MaterializedReadModels.js.map +1 -1
  139. package/dist/readModels/ReadModels.d.ts +6 -1
  140. package/dist/readModels/ReadModels.d.ts.map +1 -1
  141. package/dist/readModels/ReadModels.js +28 -2
  142. package/dist/readModels/ReadModels.js.map +1 -1
  143. package/dist/readModels/readModel.d.ts +2 -1
  144. package/dist/readModels/readModel.d.ts.map +1 -1
  145. package/dist/readModels/readModel.js +22 -5
  146. package/dist/readModels/readModel.js.map +1 -1
  147. package/dist/schemas/JsonSchemaGenerator.d.ts +6 -1
  148. package/dist/schemas/JsonSchemaGenerator.d.ts.map +1 -1
  149. package/dist/schemas/JsonSchemaGenerator.js +41 -31
  150. package/dist/schemas/JsonSchemaGenerator.js.map +1 -1
  151. package/dist/schemas/createDeferredSchema.d.ts +7 -0
  152. package/dist/schemas/createDeferredSchema.d.ts.map +1 -0
  153. package/dist/schemas/createDeferredSchema.js +19 -0
  154. package/dist/schemas/createDeferredSchema.js.map +1 -0
  155. package/dist/schemas/index.d.ts +1 -0
  156. package/dist/schemas/index.d.ts.map +1 -1
  157. package/dist/schemas/index.js.map +1 -1
  158. package/dist/schemas/jsonSchemaProperty.d.ts +2 -1
  159. package/dist/schemas/jsonSchemaProperty.d.ts.map +1 -1
  160. package/dist/schemas/jsonSchemaProperty.js +3 -2
  161. package/dist/schemas/jsonSchemaProperty.js.map +1 -1
  162. package/dist/tsconfig.tsbuildinfo +1 -1
  163. package/dist/types/TypeIntrospector.d.ts.map +1 -1
  164. package/dist/types/TypeIntrospector.js +9 -3
  165. package/dist/types/TypeIntrospector.js.map +1 -1
  166. package/dist/types/index.d.ts +2 -0
  167. package/dist/types/index.d.ts.map +1 -1
  168. package/dist/types/propertyDecoratorMetadata.d.ts +17 -0
  169. package/dist/types/propertyDecoratorMetadata.d.ts.map +1 -0
  170. package/dist/types/propertyDecoratorMetadata.js +76 -0
  171. package/dist/types/propertyDecoratorMetadata.js.map +1 -0
  172. package/dist/types/standardDecoratorMetadata.d.ts +9 -0
  173. package/dist/types/standardDecoratorMetadata.d.ts.map +1 -0
  174. package/dist/types/standardDecoratorMetadata.js +20 -0
  175. package/dist/types/standardDecoratorMetadata.js.map +1 -0
  176. package/eventSequences/AppendOptions.ts +3 -4
  177. package/eventSequences/EventSequence.spec.ts +59 -0
  178. package/eventSequences/EventSequence.ts +21 -15
  179. package/eventSequences/EventSequenceNumber.ts +4 -1
  180. package/eventSequences/for_EventSequence/when_appending/with_metadata.spec.ts +2 -1
  181. package/events/classDecorators.standard.spec.ts +77 -0
  182. package/events/eventTypeDecorator.ts +31 -13
  183. package/events/index.ts +3 -0
  184. package/events/isRegisteredEvent.spec.ts +26 -0
  185. package/events/isRegisteredEvent.ts +12 -0
  186. package/identity/IdentityProvider.ts +5 -0
  187. package/package.json +3 -1
  188. package/projections/modelBound/addFrom.ts +5 -4
  189. package/projections/modelBound/childrenFrom.ts +5 -4
  190. package/projections/modelBound/clearWith.ts +5 -4
  191. package/projections/modelBound/count.ts +5 -4
  192. package/projections/modelBound/decorators.standard.spec.ts +71 -0
  193. package/projections/modelBound/decrement.ts +5 -4
  194. package/projections/modelBound/fromAll.ts +5 -4
  195. package/projections/modelBound/fromEvery.ts +5 -4
  196. package/projections/modelBound/increment.ts +5 -4
  197. package/projections/modelBound/join.ts +5 -4
  198. package/projections/modelBound/nested.ts +9 -2
  199. package/projections/modelBound/noAutoMap.ts +11 -2
  200. package/projections/modelBound/removedWith.ts +5 -4
  201. package/projections/modelBound/removedWithJoin.ts +5 -4
  202. package/projections/modelBound/setFrom.ts +5 -4
  203. package/projections/modelBound/setFromContext.ts +7 -6
  204. package/projections/modelBound/setValue.ts +5 -4
  205. package/projections/modelBound/subtractFrom.ts +5 -4
  206. package/readModels/IReadModels.ts +11 -1
  207. package/readModels/MaterializedReadModels.spec.ts +17 -6
  208. package/readModels/MaterializedReadModels.ts +2 -2
  209. package/readModels/ReadModels.spec.ts +96 -3
  210. package/readModels/ReadModels.ts +32 -2
  211. package/readModels/readModel.ts +24 -6
  212. package/readModels/standardFallbacks.standard.spec.ts +39 -0
  213. package/schemas/JsonSchemaGenerator.confidentiality.spec.ts +2 -0
  214. package/schemas/JsonSchemaGenerator.spec.ts +28 -1
  215. package/schemas/JsonSchemaGenerator.ts +45 -30
  216. package/schemas/createDeferredSchema.ts +21 -0
  217. package/schemas/decorators.expected.fixture.ts +24 -0
  218. package/schemas/decorators.legacy.spec.ts +62 -0
  219. package/schemas/decorators.standard.spec.ts +235 -0
  220. package/schemas/index.ts +1 -0
  221. package/schemas/jsonSchemaProperty.ts +4 -3
  222. package/schemas/legacyDesignMetadata.spec.ts +39 -0
  223. package/types/TypeIntrospector.ts +8 -3
  224. package/types/index.ts +2 -0
  225. package/types/propertyDecoratorMetadata.ts +80 -0
  226. package/types/standardDecoratorMetadata.spec.ts +31 -0
  227. package/types/standardDecoratorMetadata.ts +26 -0
  228. package/vitest.config.ts +17 -0
@@ -77,22 +77,33 @@ describe('MaterializedReadModels', () => {
77
77
  pii()(Anonymous.prototype, 'ssn');
78
78
  readModel('AnonymousMaterialized')(Anonymous);
79
79
 
80
- it('should not call release, unchanged from today', async () => {
80
+ it('should fail closed without a subject', async () => {
81
81
  const json = JSON.stringify({ ssn: '000-00-0000' });
82
82
  const { readModels, release } = createMaterializedReadModels([json]);
83
83
 
84
- await readModels.getInstances(Anonymous);
85
-
84
+ await expect(readModels.getInstances(Anonymous)).rejects.toThrow(/subject/);
86
85
  expect(release).not.toHaveBeenCalled();
87
86
  });
88
87
 
89
- it('should return the instance unreleased', async () => {
88
+ it('should not return the unreleased instance', async () => {
90
89
  const json = JSON.stringify({ ssn: '000-00-0000' });
91
90
  const { readModels } = createMaterializedReadModels([json]);
92
91
 
93
- const [instance] = await readModels.getInstances(Anonymous);
92
+ await expect(readModels.getInstances(Anonymous)).rejects.toThrow(/subject/);
93
+ });
94
+ });
94
95
 
95
- expect(instance.ssn).toBe('000-00-0000');
96
+ describe('when the compliance service rejects release', () => {
97
+ class PrivateCustomer { id = ''; ssn = ''; }
98
+ field(String)(PrivateCustomer.prototype, 'id');
99
+ field(String)(PrivateCustomer.prototype, 'ssn');
100
+ pii()(PrivateCustomer.prototype, 'ssn');
101
+ readModel('PrivateCustomer')(PrivateCustomer);
102
+
103
+ it('should reject instead of returning encrypted content', async () => {
104
+ const { readModels } = createMaterializedReadModels(
105
+ ['{"id":"a","ssn":"ciphertext"}'], { HasError: true, Error: 'denied' });
106
+ await expect(readModels.getInstances(PrivateCustomer)).rejects.toThrow('Failed to release PII: denied');
96
107
  });
97
108
  });
98
109
  });
@@ -100,7 +100,7 @@ export class MaterializedReadModels implements IMaterializedReadModels {
100
100
  private async releaseInstance<TReadModel>(readModelType: Constructor<TReadModel>, instance: TReadModel, schema: string): Promise<TReadModel> {
101
101
  const subject = ReadModelSubjectResolver.resolveFrom(readModelType, instance);
102
102
  if (!subject) {
103
- return instance;
103
+ 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');
104
104
  }
105
105
 
106
106
  const payload = JsonSerializer.serialize(instance);
@@ -113,7 +113,7 @@ export class MaterializedReadModels implements IMaterializedReadModels {
113
113
  });
114
114
 
115
115
  if (response.HasError) {
116
- return instance;
116
+ throw new Error(`Failed to release PII: ${response.Error}`);
117
117
  }
118
118
 
119
119
  return this.deserialize(readModelType, response.Payload);
@@ -2,11 +2,13 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import 'reflect-metadata';
5
- import type { Constructor } from '@cratis/fundamentals';
5
+ import { field, type Constructor } from '@cratis/fundamentals';
6
6
  import { describe, expect, it, vi } from 'vitest';
7
7
  import type { IClientArtifactsProvider } from '../artifacts/index.js';
8
8
  import type { ChronicleConnection } from '../connection/index.js';
9
9
  import { subject } from '../compliance/subject.js';
10
+ import { pii } from '../compliance/pii.js';
11
+ import { reducer } from '../reducers/reducer.js';
10
12
  import { fromEvent } from '../projections/modelBound/fromEvent.js';
11
13
  import { readModel } from './readModel.js';
12
14
  import { ReadModels } from './ReadModels.js';
@@ -20,8 +22,10 @@ class SomeEvent {
20
22
 
21
23
  function createReadModels(readModelType: Constructor, releaseResponse: Record<string, unknown> = { HasError: false, Payload: '{}' }) {
22
24
  const release = vi.fn().mockResolvedValue(releaseResponse);
25
+ const getInstanceByKey = vi.fn().mockResolvedValue({ ReadModel: '' });
23
26
  const connection = {
24
- compliance: { release }
27
+ compliance: { release },
28
+ readModels: { getInstanceByKey }
25
29
  } as unknown as ChronicleConnection;
26
30
 
27
31
  const clientArtifacts = {
@@ -38,10 +42,99 @@ function createReadModels(readModelType: Constructor, releaseResponse: Record<st
38
42
  } as IClientArtifactsProvider;
39
43
 
40
44
  const readModels = new ReadModels('test-store', 'test-namespace', connection, clientArtifacts, 'default-sink');
41
- return { readModels, release };
45
+ return { readModels, release, getInstanceByKey };
42
46
  }
43
47
 
44
48
  describe('ReadModels', () => {
49
+ describe('when no read model instance exists for a key', () => {
50
+ class MissingModel { id = ''; }
51
+ fromEvent(SomeEvent)(MissingModel);
52
+ readModel('MissingModel')(MissingModel);
53
+
54
+ it('should retain the legacy prototype-only instance for getInstanceById', async () => {
55
+ const { readModels, release } = createReadModels(MissingModel);
56
+ const instance = await readModels.getInstanceById(MissingModel, 'missing');
57
+ expect(instance).toBeInstanceOf(MissingModel);
58
+ expect(Object.keys(instance)).toEqual([]);
59
+ expect(release).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('should return null from findInstanceById without releasing PII', async () => {
63
+ const { readModels, release, getInstanceByKey } = createReadModels(MissingModel);
64
+ const instance = await readModels.findInstanceById(MissingModel, 'missing', 'session-1');
65
+ expect(instance).toBeNull();
66
+ expect(getInstanceByKey).toHaveBeenCalledWith(expect.objectContaining({ ReadModelKey: 'missing', SessionId: 'session-1' }));
67
+ expect(release).not.toHaveBeenCalled();
68
+ });
69
+ });
70
+
71
+ describe('when a read model instance exists for a key', () => {
72
+ class ExistingModel { id = ''; }
73
+ field(String)(ExistingModel.prototype, 'id');
74
+ fromEvent(SomeEvent)(ExistingModel);
75
+ readModel('ExistingModel')(ExistingModel);
76
+
77
+ it('should deserialize the model', async () => {
78
+ const { readModels, getInstanceByKey } = createReadModels(ExistingModel);
79
+ getInstanceByKey.mockResolvedValue({ ReadModel: '{"id":"found"}' });
80
+ const instance = await readModels.findInstanceById(ExistingModel, 'found');
81
+ expect(instance).toBeInstanceOf(ExistingModel);
82
+ expect(instance?.id).toBe('found');
83
+ const legacyInstance = await readModels.getInstanceById(ExistingModel, 'found');
84
+ expect(legacyInstance).toBeInstanceOf(ExistingModel);
85
+ expect(legacyInstance.id).toBe('found');
86
+ });
87
+ });
88
+
89
+ describe('when reading a compliance-bearing reducer', () => {
90
+ class PrivateModel { id = ''; ssn = ''; }
91
+ field(String)(PrivateModel.prototype, 'id');
92
+ field(String)(PrivateModel.prototype, 'ssn');
93
+ pii()(PrivateModel.prototype, 'ssn');
94
+ readModel('PrivateInstanceModel')(PrivateModel);
95
+ class PrivateReducer {}
96
+ reducer('PrivateInstanceReducer', undefined, PrivateModel)(PrivateReducer);
97
+
98
+ it('should reject both read methods when compliance release fails', async () => {
99
+ const release = vi.fn().mockResolvedValue({ HasError: true, Error: 'denied' });
100
+ const getInstanceByKey = vi.fn().mockResolvedValue({ ReadModel: '{"id":"a","ssn":"ciphertext"}' });
101
+ const connection = {
102
+ readModels: { getInstanceByKey },
103
+ compliance: { release }
104
+ } as unknown as ChronicleConnection;
105
+ const provider = { reducers: [PrivateReducer], projections: [], readModels: [] } as unknown as IClientArtifactsProvider;
106
+ const readModels = new ReadModels('store', 'tenant', connection, provider, 'sink');
107
+
108
+ await expect(readModels.getInstanceById(PrivateModel, 'a')).rejects.toThrow('Failed to release PII: denied');
109
+ await expect(readModels.findInstanceById(PrivateModel, 'a')).rejects.toThrow('Failed to release PII: denied');
110
+ expect(release).toHaveBeenCalledTimes(2);
111
+ });
112
+ });
113
+
114
+ describe('when watching a compliance-bearing reducer', () => {
115
+ class PrivateModel { id = ''; ssn = ''; }
116
+ field(String)(PrivateModel.prototype, 'id');
117
+ field(String)(PrivateModel.prototype, 'ssn');
118
+ pii()(PrivateModel.prototype, 'ssn');
119
+ readModel('PrivateReducerModel')(PrivateModel);
120
+ class PrivateReducer {}
121
+ reducer('PrivateReducer', undefined, PrivateModel)(PrivateReducer);
122
+
123
+ it('should reject rather than expose an unreleased changeset', async () => {
124
+ const release = vi.fn().mockResolvedValue({ HasError: true, Error: 'denied' });
125
+ const connection = {
126
+ readModels: { watch: async function* () {
127
+ yield { Namespace: 'tenant', ModelKey: 'a', ReadModel: '{"id":"a","ssn":"ciphertext"}', Removed: false };
128
+ } },
129
+ compliance: { release }
130
+ } as unknown as ChronicleConnection;
131
+ const provider = { reducers: [PrivateReducer], projections: [], readModels: [] } as unknown as IClientArtifactsProvider;
132
+ const readModels = new ReadModels('store', 'tenant', connection, provider, 'sink');
133
+ const iterator = readModels.watch(PrivateModel)[Symbol.asyncIterator]();
134
+ await expect(iterator.next()).rejects.toThrow('Failed to release PII: denied');
135
+ });
136
+ });
137
+
45
138
  describe('when releasing a read model with a property decorated with @subject()', () => {
46
139
  class Employee {
47
140
  id = '';
@@ -72,7 +72,10 @@ export class ReadModels implements IReadModels {
72
72
  });
73
73
  }
74
74
 
75
- /** @inheritdoc */
75
+ /**
76
+ * @inheritdoc
77
+ * @deprecated Use {@link findInstanceById} to distinguish an absent instance from a stored one.
78
+ */
76
79
  async getInstanceById<TReadModel>(readModelType: Constructor<TReadModel>, key: string, sessionId?: string): Promise<TReadModel> {
77
80
  const readModel = this.resolveReadModel(readModelType);
78
81
  const response = await this._connection.readModels.getInstanceByKey({
@@ -93,6 +96,30 @@ export class ReadModels implements IReadModels {
93
96
  return instance;
94
97
  }
95
98
 
99
+ /** @inheritdoc */
100
+ async findInstanceById<TReadModel>(readModelType: Constructor<TReadModel>, key: string, sessionId?: string): Promise<TReadModel | null> {
101
+ const readModel = this.resolveReadModel(readModelType);
102
+ const response = await this._connection.readModels.getInstanceByKey({
103
+ EventStore: this._eventStore,
104
+ Namespace: this._namespace,
105
+ ReadModelIdentifier: readModel.identifier,
106
+ EventSequenceId: readModel.eventSequenceId,
107
+ ReadModelKey: key,
108
+ SessionId: sessionId ?? ''
109
+ });
110
+
111
+ if (!response.ReadModel) {
112
+ return null;
113
+ }
114
+ const instance = this.deserializeReadModel(readModelType, response.ReadModel);
115
+
116
+ if (readModel.observerType === ContractReadModelObserverType.Reducer && this.schemaHasComplianceMetadata(readModel.schema)) {
117
+ return this.release(readModelType, instance);
118
+ }
119
+
120
+ return instance;
121
+ }
122
+
96
123
  /** @inheritdoc */
97
124
  async getInstances<TReadModel>(readModelType: Constructor<TReadModel>, eventCount: bigint = unlimitedEventCount): Promise<TReadModel[]> {
98
125
  const readModel = this.resolveReadModel(readModelType);
@@ -149,10 +176,13 @@ export class ReadModels implements IReadModels {
149
176
  ReadModelIdentifier: readModel.identifier,
150
177
  EventSequenceId: readModel.eventSequenceId
151
178
  })) {
179
+ const instance = this.deserializeReadModel(readModelType, changeset.ReadModel);
180
+ const requiresRelease = !changeset.Removed && readModel.observerType === ContractReadModelObserverType.Reducer &&
181
+ this.schemaHasComplianceMetadata(readModel.schema);
152
182
  yield {
153
183
  namespace: changeset.Namespace,
154
184
  key: changeset.ModelKey,
155
- readModel: this.deserializeReadModel(readModelType, changeset.ReadModel),
185
+ readModel: requiresRelease ? await this.release(readModelType, instance) : instance,
156
186
  removed: changeset.Removed
157
187
  };
158
188
  }
@@ -6,6 +6,8 @@ import { Constructor } from '@cratis/fundamentals';
6
6
  import { ReadModelId } from './ReadModelId.js';
7
7
  import { DecoratorType, TypeDiscoverer, TypeIntrospector } from '../types/index.js';
8
8
  import { JsonSchema, JsonSchemaGenerator } from '../schemas/index.js';
9
+ import { ChronicleClassDecorator } from '../types/standardDecoratorMetadata.js';
10
+ import { createDeferredSchema } from '../schemas/createDeferredSchema.js';
9
11
 
10
12
  /** Metadata key used to store read model information on a class. */
11
13
  const READ_MODEL_METADATA_KEY = 'chronicle:readModel';
@@ -29,14 +31,23 @@ export interface ReadModelMetadata {
29
31
  * @param id - The unique identifier for the read model. Defaults to the class name if omitted.
30
32
  * @returns A class decorator.
31
33
  */
32
- export function readModel(id: string = ''): ClassDecorator {
33
- return (target: object) => {
34
- const constructor = target as Function;
34
+ export function readModel(id: string = ''): ChronicleClassDecorator {
35
+ return (target: object, context?: ClassDecoratorContext) => {
36
+ let constructor = target as Function;
35
37
  const readModelId = new ReadModelId(id || constructor.name);
36
- const members = TypeIntrospector.getMembers(constructor);
37
- const metadata: ReadModelMetadata = {
38
+ const members = context?.kind === 'class' ? undefined : TypeIntrospector.getMembers(constructor);
39
+ const resolve = createDeferredSchema(() => constructor);
40
+ const metadata: ReadModelMetadata = context?.kind === 'class' ? {
38
41
  id: readModelId,
39
- members,
42
+ get members() {
43
+ return resolve().members;
44
+ },
45
+ get schema() {
46
+ return resolve().schema;
47
+ }
48
+ } : {
49
+ id: readModelId,
50
+ members: members!,
40
51
  schema: JsonSchemaGenerator.generate(constructor, members)
41
52
  };
42
53
  Reflect.defineMetadata(READ_MODEL_METADATA_KEY, metadata, target);
@@ -45,6 +56,13 @@ export function readModel(id: string = ''): ClassDecorator {
45
56
  constructor as Constructor,
46
57
  readModelId.value
47
58
  );
59
+ context?.addInitializer(function () {
60
+ if (this !== constructor) {
61
+ constructor = this;
62
+ Reflect.defineMetadata(READ_MODEL_METADATA_KEY, metadata, constructor);
63
+ TypeDiscoverer.default.register(DecoratorType.ReadModel, constructor as Constructor, readModelId.value);
64
+ }
65
+ });
48
66
  };
49
67
  }
50
68
 
@@ -0,0 +1,39 @@
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 { IClientArtifactsProvider } from '../artifacts/IClientArtifactsProvider.js';
7
+ import { ChronicleConnection } from '../connection/ChronicleConnection.js';
8
+ import { reducer } from '../reducers/reducer.js';
9
+ import { MaterializedReadModels } from './MaterializedReadModels.js';
10
+ import { ReadModels } from './ReadModels.js';
11
+
12
+ class UndecoratedModel {
13
+ @field(Array) items!: string[];
14
+ }
15
+
16
+ @reducer('undecorated-model', undefined, UndecoratedModel)
17
+ class ModelReducer {}
18
+
19
+ const artifacts = {
20
+ eventTypes: [], readModels: [], reactors: [], reducers: [ModelReducer], seeders: [],
21
+ constraints: [], projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
22
+ } as IClientArtifactsProvider;
23
+
24
+ describe('standard read models without @readModel', () => {
25
+ it('rejects an untyped array before registering a reducer read model', async () => {
26
+ const registerMany = vi.fn();
27
+ const connection = { readModels: { registerMany } } as unknown as ChronicleConnection;
28
+ const readModels = new ReadModels('test', 'Default', connection, artifacts, 'sink');
29
+ await expect(readModels.register(UndecoratedModel)).rejects.toThrow(/Cannot determine the element type of UndecoratedModel.items/);
30
+ expect(registerMany).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('rejects an untyped array instead of releasing an incomplete materialized schema', async () => {
34
+ const getInstances = vi.fn().mockResolvedValue({ Instances: [] });
35
+ const connection = { materializedReadModels: { getInstances } } as unknown as ChronicleConnection;
36
+ const materialized = new MaterializedReadModels('test', 'Default', connection);
37
+ await expect(materialized.getInstances(UndecoratedModel)).rejects.toThrow(/Cannot determine the element type of UndecoratedModel.items/);
38
+ });
39
+ });
@@ -77,6 +77,8 @@ describe('JsonSchemaGenerator - security metadata', () => {
77
77
 
78
78
  describe('when a property is typed as a ConceptAs<T> marked @encrypted()', () => {
79
79
  class ApiKey extends ConceptAs<string> {
80
+ static readonly valueType = String;
81
+
80
82
  constructor(value: string) {
81
83
  super(value);
82
84
  }
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import 'reflect-metadata';
5
- import { ConceptAs, field } from '@cratis/fundamentals';
5
+ import { ConceptAs, field, Guid } from '@cratis/fundamentals';
6
6
  import { describe, expect, it } from 'vitest';
7
7
  import { pii } from '../compliance/pii.js';
8
8
  import { eventType, getEventTypeJsonSchemaFor } from '../events/eventTypeDecorator.js';
@@ -22,6 +22,31 @@ function schemaFor(target: Function): JsonSchema {
22
22
  }
23
23
 
24
24
  describe('JsonSchemaGenerator', () => {
25
+ it('retains the legacy string-concept fallback without emitted type metadata', () => {
26
+ class LegacyCode extends ConceptAs<string> {}
27
+ class LegacyModel {
28
+ code = new LegacyCode('A');
29
+ }
30
+ readModel()(LegacyModel);
31
+ expect(schemaFor(LegacyModel).properties?.code.type).toBe('string');
32
+ });
33
+
34
+ it('preserves Guid and Date-backed legacy concepts with design:type', () => {
35
+ class LegacyId extends ConceptAs<Guid> {}
36
+ class LegacyTime extends ConceptAs<Date> {}
37
+ Reflect.defineMetadata('design:type', Guid, LegacyId.prototype, 'value');
38
+ Reflect.defineMetadata('design:type', Date, LegacyTime.prototype, 'value');
39
+ class LegacyEvent {
40
+ id = new LegacyId(Guid.empty);
41
+ occurred = new LegacyTime(new Date());
42
+ }
43
+ eventType()(LegacyEvent);
44
+ expect(getEventTypeJsonSchemaFor(LegacyEvent).properties).toMatchObject({
45
+ id: { type: 'string', format: 'guid' },
46
+ occurred: { type: 'string', format: 'date-time' }
47
+ });
48
+ });
49
+
25
50
  describe('when a class is marked @pii() at the class level', () => {
26
51
  class PersonProfile {
27
52
  name = '';
@@ -59,6 +84,8 @@ describe('JsonSchemaGenerator', () => {
59
84
 
60
85
  describe('when a property is typed as a ConceptAs<T> marked @pii()', () => {
61
86
  class EmailAddress extends ConceptAs<string> {
87
+ static readonly valueType = String;
88
+
62
89
  constructor(value: string) {
63
90
  super(value);
64
91
  }
@@ -2,9 +2,10 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import 'reflect-metadata';
5
- import { ConceptAs, Constructor, Fields, Guid } from '@cratis/fundamentals';
5
+ import { conceptAsTypeKey, Constructor, Fields, Guid, typeKeyOf } from '@cratis/fundamentals';
6
6
  import { ComplianceSchemaMetadata, JsonSchema, SecuritySchemaMetadata } from './JsonSchema.js';
7
7
  import { TypeIntrospector } from '../types/index.js';
8
+ import { hasOwnStandardMetadata } from '../types/standardDecoratorMetadata.js';
8
9
  import { ComplianceMetadata } from '../compliance/ComplianceMetadata.js';
9
10
  import { ComplianceMetadataResolver } from '../compliance/ComplianceMetadataResolver.js';
10
11
  import { SecurityMetadata } from '../confidentiality/SecurityMetadata.js';
@@ -14,6 +15,11 @@ import { PIIAndEncryptedCombinedNotSupported } from '../confidentiality/PIIAndEn
14
15
  /**
15
16
  * Generates JSON schemas for class constructors using reflection metadata.
16
17
  */
18
+ export interface JsonSchemaGenerationOptions {
19
+ /** Reject members and array elements without runtime types. Defaults to true for standard-decorated classes. */
20
+ readonly requireResolvedTypes?: boolean;
21
+ }
22
+
17
23
  export class JsonSchemaGenerator {
18
24
  private static readonly _knownTypeFormats = new Map<Function, { type: JsonSchema['type']; format: string }>([
19
25
  [Guid, { type: 'string', format: 'guid' }],
@@ -44,15 +50,20 @@ export class JsonSchemaGenerator {
44
50
  * Generates a JSON schema for a class constructor.
45
51
  * @param target - The class constructor to generate schema for.
46
52
  * @param members - Optional pre-introspected members for reuse.
53
+ * @param options - Schema resolution options. A boolean remains supported for existing callers.
47
54
  * @returns The generated JSON schema.
48
55
  */
49
- static generate(target: Function, members?: ReadonlyMap<string, Function | undefined>): JsonSchema {
56
+ static generate(target: Function, members?: ReadonlyMap<string, Function | undefined>, options?: JsonSchemaGenerationOptions | boolean): JsonSchema {
57
+ const requireResolvedTypes = typeof options === 'boolean' ? options : options?.requireResolvedTypes ?? hasOwnStandardMetadata(target);
50
58
  const membersToUse = members ?? TypeIntrospector.getMembers(target);
51
59
  const schemaProperties: Record<string, JsonSchema> = {};
52
60
  const prototype = target.prototype;
53
61
 
54
62
  for (const [memberName, memberType] of membersToUse.entries()) {
55
- const propertySchema = this.mapRuntimeTypeToSchema(memberType, target, memberName);
63
+ if (!memberType && requireResolvedTypes) {
64
+ throw new TypeError(`Cannot determine the type of ${target.name}.${memberName}; declare @field with its runtime type.`);
65
+ }
66
+ const propertySchema = this.mapRuntimeTypeToSchema(memberType, target, memberName, requireResolvedTypes);
56
67
  // Only include properties whose type was resolved. An empty schema ({}) means
57
68
  // the runtime type was unavailable (e.g. esbuild/tsx omits design:paramtypes).
58
69
  if (Object.keys(propertySchema).length > 0) {
@@ -71,9 +82,8 @@ export class JsonSchemaGenerator {
71
82
  }
72
83
  }
73
84
 
74
- // When no property types could be resolved, return a minimal schema with empty
75
- // properties so the server uses its fallback path that preserves all event
76
- // content as-is via ConvertUnknownSchemaTypeToClrType.
85
+ // Legacy decorators retain the minimal schema fallback for members with no
86
+ // runtime type metadata. Standard decorators reject unresolved types above.
77
87
  if (Object.keys(schemaProperties).length === 0) {
78
88
  return this.createEmptySchema(target.name);
79
89
  }
@@ -85,7 +95,7 @@ export class JsonSchemaGenerator {
85
95
  };
86
96
  }
87
97
 
88
- private static mapRuntimeTypeToSchema(runtimeType: Function | undefined, declaringType?: Function, propertyName?: string): JsonSchema {
98
+ private static mapRuntimeTypeToSchema(runtimeType: Function | undefined, declaringType?: Function, propertyName?: string, requireResolvedTypes = false): JsonSchema {
89
99
  const knownTypeFormat = this.getKnownTypeFormat(runtimeType);
90
100
  if (knownTypeFormat) {
91
101
  return knownTypeFormat;
@@ -104,25 +114,29 @@ export class JsonSchemaGenerator {
104
114
  }
105
115
 
106
116
  if (runtimeType === Array) {
107
- return this.mapArrayTypeToSchema(declaringType, propertyName);
117
+ return this.mapArrayTypeToSchema(declaringType, propertyName, requireResolvedTypes);
108
118
  }
109
119
 
110
120
  if (!runtimeType) {
111
121
  return {};
112
122
  }
113
123
 
114
- // A ConceptAs<T> serializes as its underlying primitive value, so the schema must
115
- // describe that primitive rather than the wrapper object. The generic argument T is
116
- // not available at runtime, so resolve it from the 'value' property's design:type
117
- // when present (ts-node/webpack with emitDecoratorMetadata) and fall back to string
118
- // when it is not (esbuild/tsx) — string-backed concepts are by far the common case.
124
+ // TypeScript erases ConceptAs<T>'s primitive type. Prefer an explicit field or
125
+ // static hint; legacy emitDecoratorMetadata is the last available source.
119
126
  if (this.isConceptAs(runtimeType)) {
120
- const valueType = Reflect.getMetadata('design:type', runtimeType.prototype, 'value') as Function | undefined;
121
- return this.mapRuntimeTypeToSchema(valueType ?? String);
127
+ const concept = runtimeType as Function & { valueType?: Function };
128
+ const fieldType = Fields.getFieldsForType(concept as Constructor).find(field => field.name === 'value')?.type;
129
+ const valueType = fieldType ?? concept.valueType ?? Reflect.getMetadata('design:type', runtimeType.prototype, 'value') as Function | undefined;
130
+ if (!valueType && !requireResolvedTypes) return { type: 'string' }; // Legacy schema compatibility.
131
+ const valueSchema = this.mapRuntimeTypeToSchema(valueType, undefined, undefined, requireResolvedTypes);
132
+ if (!valueType || !valueSchema.type || (requireResolvedTypes && valueSchema.type === 'object')) {
133
+ throw new TypeError(`Cannot determine the value type of concept ${runtimeType.name}; declare static readonly valueType = String, Number, Boolean, Guid, or Date.`);
134
+ }
135
+ return valueSchema;
122
136
  }
123
137
 
124
138
  if (runtimeType !== Object) {
125
- return this.generate(runtimeType);
139
+ return this.generate(runtimeType, undefined, requireResolvedTypes);
126
140
  }
127
141
 
128
142
  return { type: 'object' };
@@ -135,7 +149,7 @@ export class JsonSchemaGenerator {
135
149
  * @param propertyName - The array property name.
136
150
  * @returns The array schema, with the element's own compliance metadata carried onto `items` when the element is a PII concept.
137
151
  */
138
- private static mapArrayTypeToSchema(declaringType: Function | undefined, propertyName: string | undefined): JsonSchema {
152
+ private static mapArrayTypeToSchema(declaringType: Function | undefined, propertyName: string | undefined, requireResolvedTypes: boolean): JsonSchema {
139
153
  const elementType = this.getArrayElementType(declaringType, propertyName);
140
154
 
141
155
  // An array whose element is a ConceptAs<T> loses its classification the moment it is put in
@@ -143,7 +157,7 @@ export class JsonSchemaGenerator {
143
157
  // item schema - a value that would be encrypted as a scalar would otherwise be persisted in
144
158
  // the clear as a list element. Mirrors the C# generator's explicit enumerable-of-concept branch.
145
159
  if (elementType && this.isConceptAs(elementType)) {
146
- const itemSchema = this.mapRuntimeTypeToSchema(elementType);
160
+ const itemSchema = this.mapRuntimeTypeToSchema(elementType, undefined, undefined, requireResolvedTypes);
147
161
  const complianceMetadata = ComplianceMetadataResolver.getMetadataForType(elementType);
148
162
  const securityMetadata = SecurityMetadataResolver.getMetadataForType(elementType);
149
163
  this.throwIfBothCompliantAndSecure(elementType.name, complianceMetadata, securityMetadata);
@@ -152,7 +166,15 @@ export class JsonSchemaGenerator {
152
166
  return { type: 'array', items: itemSchema };
153
167
  }
154
168
 
155
- return { type: 'array', items: { type: 'object' } };
169
+ if (!elementType) {
170
+ if (requireResolvedTypes) {
171
+ throw new TypeError(`Cannot determine the element type of ${declaringType?.name}.${propertyName}; declare @field(Array, { genericArguments: [ItemType] }).`);
172
+ }
173
+ return { type: 'array', items: { type: 'object' } };
174
+ }
175
+
176
+ if (!requireResolvedTypes) return { type: 'array', items: { type: 'object' } }; // Legacy schema compatibility.
177
+ return { type: 'array', items: this.mapRuntimeTypeToSchema(elementType, undefined, undefined, true) };
156
178
  }
157
179
 
158
180
  /**
@@ -189,16 +211,7 @@ export class JsonSchemaGenerator {
189
211
  }
190
212
 
191
213
  private static isConceptAs(runtimeType: Function): boolean {
192
- let current: Function | null = runtimeType;
193
- while (current && current !== Function.prototype) {
194
- if (current === ConceptAs) {
195
- return true;
196
- }
197
-
198
- current = Object.getPrototypeOf(current) as Function | null;
199
- }
200
-
201
- return false;
214
+ return typeKeyOf(runtimeType as Constructor) === conceptAsTypeKey;
202
215
  }
203
216
 
204
217
  private static getKnownTypeFormat(runtimeType: Function | undefined): JsonSchema | undefined {
@@ -206,7 +219,9 @@ export class JsonSchemaGenerator {
206
219
  return undefined;
207
220
  }
208
221
 
209
- const known = this._knownTypeFormats.get(runtimeType);
222
+ const known = typeKeyOf(runtimeType as Constructor) === 'Guid'
223
+ ? this._knownTypeFormats.get(Guid)
224
+ : this._knownTypeFormats.get(runtimeType);
210
225
  if (!known) {
211
226
  return undefined;
212
227
  }
@@ -0,0 +1,21 @@
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 { JsonSchema } from './JsonSchema.js';
5
+ import { JsonSchemaGenerator } from './JsonSchemaGenerator.js';
6
+ import { TypeIntrospector } from '../types/TypeIntrospector.js';
7
+ import { requireCompletedStandardMetadata } from '../types/standardDecoratorMetadata.js';
8
+
9
+ /** Resolves a standard-decorated class's members and schema together, after class evaluation. */
10
+ export function createDeferredSchema(getType: () => Function): () => { members: ReadonlyMap<string, Function | undefined>; schema: JsonSchema } {
11
+ let resolved: { members: ReadonlyMap<string, Function | undefined>; schema: JsonSchema } | undefined;
12
+ return () => {
13
+ const type = getType();
14
+ requireCompletedStandardMetadata(type);
15
+ if (!resolved) {
16
+ const members = TypeIntrospector.getMembers(type);
17
+ resolved = { members, schema: JsonSchemaGenerator.generate(type, members, { requireResolvedTypes: true }) };
18
+ }
19
+ return resolved;
20
+ };
21
+ }
@@ -0,0 +1,24 @@
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 { JsonSchema } from './JsonSchema.js';
5
+
6
+ /** Expected schema contract for both decorator transforms. */
7
+ export const expectedProperties: Record<string, JsonSchema> = {
8
+ name: { type: 'string' },
9
+ age: { type: 'number' },
10
+ active: { type: 'boolean' },
11
+ id: { type: 'string', format: 'guid' },
12
+ occurred: { type: 'string', format: 'date-time' },
13
+ codes: { type: 'array', items: { type: 'string' } },
14
+ address: {
15
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
16
+ title: 'Address',
17
+ type: 'object',
18
+ properties: { city: { type: 'string' } },
19
+ required: ['city'],
20
+ additionalProperties: false
21
+ },
22
+ code: { type: 'string' },
23
+ quantity: { type: 'number' }
24
+ };