@cratis/chronicle 6.32.1 → 6.34.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 (189) hide show
  1. package/ChronicleClient.ts +35 -33
  2. package/ChronicleOptions.ts +27 -1
  3. package/EventStore.ts +14 -12
  4. package/Metrics.ts +70 -96
  5. package/Tracing.ts +4 -2
  6. package/WellKnownTelemetryNames.ts +67 -0
  7. package/artifacts/validateArtifactSchemas.spec.ts +1 -1
  8. package/artifacts/withActivatedArtifact.ts +7 -5
  9. package/connection/ChronicleConnection.ts +18 -6
  10. package/connection/CompatibilityPreflight.ts +2 -1
  11. package/connection/KernelKeepAlive.ts +13 -7
  12. package/connection/TokenProvider.ts +3 -1
  13. package/connection/TraceContextMiddleware.ts +30 -0
  14. package/connection/for_ChronicleConnection/when_creating_clients/with_correlated_multi_host_callers.spec.ts +73 -0
  15. package/connection/for_OAuthTokenProvider/when_acquiring_tokens/with_http_instrumentation.spec.ts +102 -0
  16. package/connection/for_TraceContextMiddleware/when_calling/with_active_context.spec.ts +235 -0
  17. package/connection/for_TraceContextMiddleware/when_calling/with_grpc_instrumentation.spec.ts +102 -0
  18. package/correlation/CorrelationIdManager.ts +10 -0
  19. package/dist/ChronicleClient.d.ts.map +1 -1
  20. package/dist/ChronicleClient.js +35 -33
  21. package/dist/ChronicleClient.js.map +1 -1
  22. package/dist/ChronicleOptions.d.ts +8 -0
  23. package/dist/ChronicleOptions.d.ts.map +1 -1
  24. package/dist/ChronicleOptions.js +19 -1
  25. package/dist/ChronicleOptions.js.map +1 -1
  26. package/dist/EventStore.d.ts +3 -1
  27. package/dist/EventStore.d.ts.map +1 -1
  28. package/dist/EventStore.js +14 -12
  29. package/dist/EventStore.js.map +1 -1
  30. package/dist/Metrics.d.ts +23 -64
  31. package/dist/Metrics.d.ts.map +1 -1
  32. package/dist/Metrics.js +62 -92
  33. package/dist/Metrics.js.map +1 -1
  34. package/dist/Tracing.d.ts +1 -1
  35. package/dist/Tracing.d.ts.map +1 -1
  36. package/dist/Tracing.js +4 -2
  37. package/dist/Tracing.js.map +1 -1
  38. package/dist/WellKnownTelemetryNames.d.ts +65 -0
  39. package/dist/WellKnownTelemetryNames.d.ts.map +1 -0
  40. package/dist/WellKnownTelemetryNames.js +67 -0
  41. package/dist/WellKnownTelemetryNames.js.map +1 -0
  42. package/dist/artifacts/withActivatedArtifact.d.ts +2 -1
  43. package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -1
  44. package/dist/artifacts/withActivatedArtifact.js +5 -4
  45. package/dist/artifacts/withActivatedArtifact.js.map +1 -1
  46. package/dist/connection/ChronicleConnection.d.ts +5 -0
  47. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  48. package/dist/connection/ChronicleConnection.js +13 -6
  49. package/dist/connection/ChronicleConnection.js.map +1 -1
  50. package/dist/connection/CompatibilityPreflight.d.ts.map +1 -1
  51. package/dist/connection/CompatibilityPreflight.js +2 -1
  52. package/dist/connection/CompatibilityPreflight.js.map +1 -1
  53. package/dist/connection/KernelKeepAlive.d.ts +3 -1
  54. package/dist/connection/KernelKeepAlive.d.ts.map +1 -1
  55. package/dist/connection/KernelKeepAlive.js +10 -7
  56. package/dist/connection/KernelKeepAlive.js.map +1 -1
  57. package/dist/connection/TokenProvider.d.ts.map +1 -1
  58. package/dist/connection/TokenProvider.js +3 -1
  59. package/dist/connection/TokenProvider.js.map +1 -1
  60. package/dist/connection/TraceContextMiddleware.d.ts +4 -0
  61. package/dist/connection/TraceContextMiddleware.d.ts.map +1 -0
  62. package/dist/connection/TraceContextMiddleware.js +30 -0
  63. package/dist/connection/TraceContextMiddleware.js.map +1 -0
  64. package/dist/correlation/CorrelationIdManager.d.ts +4 -0
  65. package/dist/correlation/CorrelationIdManager.d.ts.map +1 -1
  66. package/dist/correlation/CorrelationIdManager.js +8 -0
  67. package/dist/correlation/CorrelationIdManager.js.map +1 -1
  68. package/dist/eventSequences/EventLog.d.ts +2 -1
  69. package/dist/eventSequences/EventLog.d.ts.map +1 -1
  70. package/dist/eventSequences/EventLog.js +2 -2
  71. package/dist/eventSequences/EventLog.js.map +1 -1
  72. package/dist/eventSequences/EventSequence.d.ts +4 -1
  73. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  74. package/dist/eventSequences/EventSequence.js +63 -80
  75. package/dist/eventSequences/EventSequence.js.map +1 -1
  76. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.d.ts.map +1 -1
  77. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js +3 -4
  78. package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js.map +1 -1
  79. package/dist/index.d.ts +7 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +4 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/logging/ChronicleLogEntry.d.ts +11 -0
  84. package/dist/logging/ChronicleLogEntry.d.ts.map +1 -0
  85. package/dist/logging/ChronicleLogEntry.js +4 -0
  86. package/dist/logging/ChronicleLogEntry.js.map +1 -0
  87. package/dist/logging/ChronicleLogLevel.d.ts +9 -0
  88. package/dist/logging/ChronicleLogLevel.d.ts.map +1 -0
  89. package/dist/logging/ChronicleLogLevel.js +12 -0
  90. package/dist/logging/ChronicleLogLevel.js.map +1 -0
  91. package/dist/logging/DiagChronicleLogger.d.ts +7 -0
  92. package/dist/logging/DiagChronicleLogger.d.ts.map +1 -0
  93. package/dist/logging/DiagChronicleLogger.js +10 -0
  94. package/dist/logging/DiagChronicleLogger.js.map +1 -0
  95. package/dist/logging/IChronicleLogger.d.ts +7 -0
  96. package/dist/logging/IChronicleLogger.d.ts.map +1 -0
  97. package/dist/logging/IChronicleLogger.js +4 -0
  98. package/dist/logging/IChronicleLogger.js.map +1 -0
  99. package/dist/logging/createLogger.d.ts +10 -0
  100. package/dist/logging/createLogger.d.ts.map +1 -0
  101. package/dist/logging/createLogger.js +55 -0
  102. package/dist/logging/createLogger.js.map +1 -0
  103. package/dist/projections/Projections.d.ts.map +1 -1
  104. package/dist/projections/Projections.js +4 -3
  105. package/dist/projections/Projections.js.map +1 -1
  106. package/dist/reactors/Reactors.d.ts.map +1 -1
  107. package/dist/reactors/Reactors.js +26 -24
  108. package/dist/reactors/Reactors.js.map +1 -1
  109. package/dist/readModels/IReadModels.d.ts +14 -0
  110. package/dist/readModels/IReadModels.d.ts.map +1 -1
  111. package/dist/readModels/ReadModels.d.ts +6 -0
  112. package/dist/readModels/ReadModels.d.ts.map +1 -1
  113. package/dist/readModels/ReadModels.js +110 -0
  114. package/dist/readModels/ReadModels.js.map +1 -1
  115. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts +20 -0
  116. package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts.map +1 -0
  117. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js +79 -0
  118. package/dist/readModels/for_ReadModels/given/a_stored_read_model.js.map +1 -0
  119. package/dist/reducers/Reducers.d.ts.map +1 -1
  120. package/dist/reducers/Reducers.js +28 -25
  121. package/dist/reducers/Reducers.js.map +1 -1
  122. package/dist/seeding/EventSeeding.d.ts.map +1 -1
  123. package/dist/seeding/EventSeeding.js +4 -3
  124. package/dist/seeding/EventSeeding.js.map +1 -1
  125. package/dist/telemetry/ChronicleTelemetryOptions.d.ts +11 -0
  126. package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -0
  127. package/dist/telemetry/ChronicleTelemetryOptions.js +4 -0
  128. package/dist/telemetry/ChronicleTelemetryOptions.js.map +1 -0
  129. package/dist/telemetry/observeOperation.d.ts +6 -0
  130. package/dist/telemetry/observeOperation.d.ts.map +1 -0
  131. package/dist/telemetry/observeOperation.js +23 -0
  132. package/dist/telemetry/observeOperation.js.map +1 -0
  133. package/dist/telemetry/runInBackgroundContext.d.ts +3 -0
  134. package/dist/telemetry/runInBackgroundContext.d.ts.map +1 -0
  135. package/dist/telemetry/runInBackgroundContext.js +9 -0
  136. package/dist/telemetry/runInBackgroundContext.js.map +1 -0
  137. package/dist/telemetry/spanAttributes.d.ts +14 -0
  138. package/dist/telemetry/spanAttributes.d.ts.map +1 -0
  139. package/dist/telemetry/spanAttributes.js +47 -0
  140. package/dist/telemetry/spanAttributes.js.map +1 -0
  141. package/dist/tsconfig.tsbuildinfo +1 -1
  142. package/eventSequences/EventLog.ts +4 -2
  143. package/eventSequences/EventSequence.ts +64 -81
  144. package/eventStoreSubscriptions/EventStoreSubscriptions.ts +5 -5
  145. package/for_ChronicleClient/when_starting_background_work/with_a_correlated_caller.spec.ts +150 -0
  146. package/for_ChronicleOptions/when_configuring_telemetry/with_event_source_identifiers.spec.ts +44 -0
  147. package/index.ts +7 -1
  148. package/logging/ChronicleLogEntry.ts +14 -0
  149. package/logging/ChronicleLogLevel.ts +11 -0
  150. package/logging/DiagChronicleLogger.ts +13 -0
  151. package/logging/IChronicleLogger.ts +10 -0
  152. package/logging/createLogger.ts +50 -0
  153. package/logging/for_ChronicleLogger/when_logging/with_client_owned_sinks.spec.ts +96 -0
  154. package/package.json +7 -1
  155. package/projections/Projections.ts +4 -3
  156. package/reactors/Reactors.ts +27 -25
  157. package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +14 -14
  158. package/readModels/IReadModels.ts +15 -0
  159. package/readModels/ReadModels.ts +118 -1
  160. package/readModels/for_ReadModels/given/a_stored_read_model.ts +62 -0
  161. package/readModels/for_ReadModels/when_releasing_a_document/with_a_default_subject.spec.ts +39 -0
  162. package/readModels/for_ReadModels/when_releasing_a_document/with_a_frozen_document.spec.ts +28 -0
  163. package/readModels/for_ReadModels/when_releasing_a_document/with_a_malformed_response.spec.ts +24 -0
  164. package/readModels/for_ReadModels/when_releasing_a_document/with_a_reported_failure.spec.ts +38 -0
  165. package/readModels/for_ReadModels/when_releasing_a_document/with_an_empty_released_value.spec.ts +19 -0
  166. package/readModels/for_ReadModels/when_releasing_a_document/with_an_unknown_subject_property.spec.ts +42 -0
  167. package/readModels/for_ReadModels/when_releasing_a_document/with_bookkeeping_fields.spec.ts +27 -0
  168. package/readModels/for_ReadModels/when_releasing_a_document/with_conflicting_or_missing_properties.spec.ts +32 -0
  169. package/readModels/for_ReadModels/when_releasing_a_document/with_empty_subject_metadata.spec.ts +56 -0
  170. package/readModels/for_ReadModels/when_releasing_a_document/with_invalid_subject_metadata.spec.ts +27 -0
  171. package/readModels/for_ReadModels/when_releasing_a_document/with_mixed_subjects.spec.ts +38 -0
  172. package/readModels/for_ReadModels/when_releasing_a_document/with_property_subjects.spec.ts +52 -0
  173. package/readModels/for_ReadModels/when_releasing_a_document/with_whitespace_subjects.spec.ts +24 -0
  174. package/readModels/for_ReadModels/when_releasing_a_document/without_subjects.spec.ts +24 -0
  175. package/reducers/Reducers.ts +29 -26
  176. package/seeding/EventSeeding.ts +6 -4
  177. package/telemetry/ChronicleTelemetryOptions.ts +8 -0
  178. package/telemetry/for_ChronicleTelemetry/given/a_telemetry_session.fixture.ts +36 -0
  179. package/telemetry/for_ChronicleTelemetry/given/an_event_sequence.fixture.ts +29 -0
  180. package/telemetry/for_ChronicleTelemetry/when_appending/with_shared_and_legacy_attributes.spec.ts +58 -0
  181. package/telemetry/for_ChronicleTelemetry/when_appending/without_an_sdk.spec.ts +15 -0
  182. package/telemetry/for_ChronicleTelemetry/when_documenting/with_public_names.spec.ts +15 -0
  183. package/telemetry/for_ChronicleTelemetry/when_recording_failures/with_sensitive_exception_details.spec.ts +34 -0
  184. package/telemetry/for_ChronicleTelemetry/when_recording_identifiers/with_per_client_privacy.spec.ts +92 -0
  185. package/telemetry/for_ChronicleTelemetry/when_recording_metrics/with_legacy_overlap.spec.ts +40 -0
  186. package/telemetry/for_ChronicleTelemetry/when_recording_sequence_numbers/with_integer_precision.spec.ts +23 -0
  187. package/telemetry/observeOperation.ts +25 -0
  188. package/telemetry/runInBackgroundContext.ts +10 -0
  189. package/telemetry/spanAttributes.ts +49 -0
@@ -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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { JsonSchemaGenerator } from '../../../schemas/index.js';
6
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when releasing a document with a default subject', () => {
11
+ let context: a_stored_read_model;
12
+ let result: Record<string, unknown>;
13
+
14
+ beforeEach(async () => {
15
+ context = new a_stored_read_model();
16
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: '{"id":"account","name":"released name"}' });
17
+ result = await context.readModels.releaseDocument(AccountDocument, {
18
+ id: 'account', name: 'encrypted name', __subject: 'owner'
19
+ });
20
+ });
21
+
22
+ it('should release all present properties together', () => context.release.mock.calls.should.have.lengthOf(1));
23
+ it('should use the stored subject rather than the id', () => context.requestFor('owner').Subject.should.equal('owner'));
24
+ it('should use the current event store and namespace', () => {
25
+ const { EventStore, Namespace } = context.requestFor('owner');
26
+ ({ EventStore, Namespace }).should.deep.equal({ EventStore: 'store', Namespace: 'tenant' });
27
+ });
28
+ it('should send only data properties', () => JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ id: 'account', name: 'encrypted name' }));
29
+ it('should preserve compliance metadata in the relevant schema', () => {
30
+ const schema = JsonSchemaGenerator.generate(AccountDocument);
31
+ JSON.parse(context.requestFor('owner').Schema).should.deep.equal({
32
+ ...schema,
33
+ properties: { id: schema.properties!.id, name: schema.properties!.name },
34
+ required: ['id', 'name']
35
+ });
36
+ });
37
+ it('should return the released values', () => result.should.deep.equal({ id: 'account', name: 'released name' }));
38
+ it('should return a plain document rather than a model instance', () => Object.getPrototypeOf(result).should.equal(Object.prototype));
39
+ });
@@ -0,0 +1,28 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a frozen document', () => {
10
+ let document: Readonly<Record<string, unknown>>;
11
+ let result: Record<string, unknown>;
12
+ let original: string;
13
+
14
+ beforeEach(async () => {
15
+ const context = new a_stored_read_model();
16
+ document = Object.freeze({
17
+ name: 'encrypted name', contact: Object.freeze({ phone: 'unchanged phone' }),
18
+ __subjects: Object.freeze({ name: 'owner' })
19
+ });
20
+ original = JSON.stringify(document);
21
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
22
+ result = await context.readModels.releaseDocument(AccountDocument, document);
23
+ });
24
+
25
+ it('should not mutate the document or its metadata', () => JSON.stringify(document).should.equal(original));
26
+ it('should release into a separate document', () => result.should.deep.equal({ name: 'released name', contact: { phone: 'unchanged phone' } }));
27
+ it('should copy even the subtrees that are not released', () => result.contact!.should.not.equal(document.contact));
28
+ });
@@ -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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ const should = chai.should();
8
+
9
+ describe.each(['private@example.test', '', 'null', '[]', '"private@example.test"', '42'])('when releasing a document with malformed response %s', payload => {
10
+ let result: Record<string, unknown> | undefined;
11
+ let error: Error;
12
+
13
+ beforeEach(async () => {
14
+ const context = new a_stored_read_model();
15
+ result = undefined;
16
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: payload });
17
+ await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', __subject: 'owner' }).then(
18
+ released => { result = released; }, caught => { error = caught as Error; }
19
+ );
20
+ });
21
+
22
+ it('should reject without returning data', () => should.not.exist(result));
23
+ it('should sanitize parsing failures', () => error.message.should.equal('Failed to release PII in stored read model document.'));
24
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ const should = chai.should();
8
+
9
+ describe.each(['kernel', 'transport'])('when releasing a document and a later subject reports a %s failure', failure => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown> | undefined;
12
+ let error: Error;
13
+ let document: Record<string, unknown>;
14
+ let original: string;
15
+
16
+ beforeEach(async () => {
17
+ context = new a_stored_read_model();
18
+ result = undefined;
19
+ document = { name: 'encrypted name', email: 'encrypted email', __subjects: { name: 'owner', email: 'other-owner' } };
20
+ original = JSON.stringify(document);
21
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
22
+ if (failure === 'kernel') {
23
+ context.release.mockResolvedValueOnce({ HasError: true, Error: 'PII private@example.test', Payload: '{"email":"private@example.test"}' });
24
+ } else {
25
+ context.release.mockRejectedValueOnce(new Error('Transport details: private@example.test'));
26
+ }
27
+ await context.readModels.releaseDocument(AccountDocument, document).then(
28
+ released => { result = released; }, caught => { error = caught as Error; }
29
+ );
30
+ });
31
+
32
+ it('should have attempted both subjects', () => context.release.mock.calls.should.have.lengthOf(2));
33
+ it('should reject the whole operation', () => error.should.be.instanceOf(Error));
34
+ it('should never expose the earlier released group', () => should.not.exist(result));
35
+ it('should leave the input unchanged even after a partial release', () => JSON.stringify(document).should.equal(original));
36
+ it('should not expose PII from errors or payloads', () => error.message.should.equal('Failed to release PII in stored read model document.'));
37
+ it('should not attach an unsafe cause', () => should.not.exist(error.cause));
38
+ });
@@ -0,0 +1,19 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a document and the kernel reports success with an empty value', () => {
10
+ let result: Record<string, unknown>;
11
+
12
+ beforeEach(async () => {
13
+ const context = new a_stored_read_model();
14
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: '{"name":""}' });
15
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', __subject: 'owner' });
16
+ });
17
+
18
+ it('should accept the reported success without guessing whether decryption failed', () => result.should.deep.equal({ name: '' }));
19
+ });
@@ -0,0 +1,42 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each(['unknown', 'contact.phone', '__subject', '__subjects', 'toString'])('when releasing a document with unknown subject property %s', property => {
10
+ let context: a_stored_read_model;
11
+ let error: Error;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ await context.readModels.releaseDocument(AccountDocument, {
16
+ name: 'encrypted name', contact: { phone: 'encrypted phone' }, unknown: 'not declared',
17
+ 'contact.phone': 'not declared', toString: 'not declared',
18
+ __subject: 'owner', __subjects: { [property]: 'other-owner' }
19
+ }).catch(caught => { error = caught as Error; });
20
+ });
21
+
22
+ it('should reject undeclared, nested, reserved and inherited property names', () => error.message.should.equal('Stored read model document has an unknown subject property.'));
23
+ it('should validate the entire subject map before attempting any releases', () => context.release.mock.calls.should.have.lengthOf(0));
24
+ });
25
+
26
+ describe.each(['email', 'removedProperty', 'contact.phone', 'toString'])('when releasing a document with a stale subject property %s', property => {
27
+ let context: a_stored_read_model;
28
+ let result: Record<string, unknown>;
29
+
30
+ beforeEach(async () => {
31
+ context = new a_stored_read_model();
32
+ result = await context.readModels.releaseDocument(AccountDocument, {
33
+ name: 'encrypted name', __subject: 'owner', __subjects: { [property]: 'other-owner' }
34
+ });
35
+ });
36
+
37
+ it('should ignore mappings without a corresponding document property', () => result.should.deep.equal({ name: 'encrypted name' }));
38
+ it('should release only the existing properties under their subject', () => {
39
+ context.release.mock.calls.should.have.lengthOf(1);
40
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name' });
41
+ });
42
+ });
@@ -0,0 +1,27 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([undefined, 'owner'])('when releasing a document with bookkeeping and default subject %s', defaultSubject => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown>;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ result = await context.readModels.releaseDocument(AccountDocument, {
16
+ id: 'account', name: 'stored name', _id: 'storage-id', __generation: 1,
17
+ __subject: defaultSubject, __subjects: {}
18
+ });
19
+ });
20
+
21
+ it('should strip storage-only and subject metadata', () => result.should.deep.equal({ id: 'account', name: 'stored name' }));
22
+ it('should never send bookkeeping for release', () => {
23
+ context.release.mock.calls.forEach(([request]) => {
24
+ JSON.parse(request.Payload).should.deep.equal({ id: 'account', name: 'stored name' });
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,32 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ const should = chai.should();
8
+
9
+ describe.each([
10
+ '{}',
11
+ '{"name":"wrong group"}',
12
+ '{"email":"released email","name":"overwrite released group"}',
13
+ '{"email":"released email","id":"overwrite unreleased property"}',
14
+ '{"email":"released email","__proto__":{"polluted":true}}'
15
+ ])('when releasing a document with missing or conflicting response properties %s', payload => {
16
+ let result: Record<string, unknown> | undefined;
17
+ let error: Error;
18
+
19
+ beforeEach(async () => {
20
+ const context = new a_stored_read_model();
21
+ result = undefined;
22
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: '{"name":"released name"}' });
23
+ context.release.mockResolvedValueOnce({ HasError: false, Error: '', Payload: payload });
24
+ await context.readModels.releaseDocument(AccountDocument, {
25
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
26
+ __subjects: { name: 'owner', email: 'other-owner' }
27
+ }).then(released => { result = released; }, caught => { error = caught as Error; });
28
+ });
29
+
30
+ it('should reject the whole operation', () => error.message.should.equal('Failed to release stored read model document: missing or conflicting properties.'));
31
+ it('should not expose a partial result', () => should.not.exist(result));
32
+ });
@@ -0,0 +1,56 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: null }, { __subject: '' },
11
+ { __subjects: { name: null } }, { __subjects: { name: '' } },
12
+ { __subjects: null }, { __subjects: [] }, { __subjects: 'owner' }, { __subjects: 42 }
13
+ ])('when releasing a document without usable subject metadata %j', metadata => {
14
+ let context: a_stored_read_model;
15
+ let result: Record<string, unknown>;
16
+
17
+ beforeEach(async () => {
18
+ context = new a_stored_read_model();
19
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'stored name', ...metadata });
20
+ });
21
+
22
+ it('should copy properties without a subject unchanged', () => result.should.deep.equal({ name: 'stored name' }));
23
+ it('should not attempt release', () => context.release.mock.calls.should.have.lengthOf(0));
24
+ });
25
+
26
+ describe.each([null, ''])('when releasing a document with an empty property subject %j and a default subject', subject => {
27
+ let context: a_stored_read_model;
28
+ let result: Record<string, unknown>;
29
+
30
+ beforeEach(async () => {
31
+ context = new a_stored_read_model();
32
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
33
+ result = await context.readModels.releaseDocument(AccountDocument, {
34
+ name: 'encrypted name', __subject: 'owner', __subjects: { name: subject }
35
+ });
36
+ });
37
+
38
+ it('should fall back to the default subject', () => context.requestFor('owner').Subject.should.equal('owner'));
39
+ it('should release the property', () => result.should.deep.equal({ name: 'released name' }));
40
+ });
41
+
42
+ describe.each([null, ''])('when releasing a document with an empty default subject %j and a property subject', subject => {
43
+ let context: a_stored_read_model;
44
+ let result: Record<string, unknown>;
45
+
46
+ beforeEach(async () => {
47
+ context = new a_stored_read_model();
48
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
49
+ result = await context.readModels.releaseDocument(AccountDocument, {
50
+ id: 'account', name: 'encrypted name', __subject: subject, __subjects: { name: 'owner' }
51
+ });
52
+ });
53
+
54
+ it('should release only the property with a subject', () => JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name' }));
55
+ it('should retain properties without a subject', () => result.should.deep.equal({ id: 'account', name: 'released name' }));
56
+ });
@@ -0,0 +1,27 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: 42 }, { __subject: false }, { __subject: [] }, { __subject: {} },
11
+ { __subjects: { name: 42 } }, { __subjects: { name: false } },
12
+ { __subjects: { name: [] } }, { __subjects: { name: { subject: 'private@example.test' } } },
13
+ { __subjects: { email: 42 } }, { __subjects: { removedProperty: 42 } }
14
+ ])('when releasing a document with invalid subject metadata %j', metadata => {
15
+ let context: a_stored_read_model;
16
+ let error: Error;
17
+
18
+ beforeEach(async () => {
19
+ context = new a_stored_read_model();
20
+ await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', ...metadata })
21
+ .catch(caught => { error = caught as Error; });
22
+ });
23
+
24
+ it('should reject the document', () => error.should.be.instanceOf(Error));
25
+ it('should not expose metadata values in the error', () => error.message.should.not.contain('private@example.test'));
26
+ it('should not attempt release with invalid metadata', () => context.release.mock.calls.should.have.lengthOf(0));
27
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a document with mixed default and property subjects', () => {
10
+ let context: a_stored_read_model;
11
+ let result: Record<string, unknown>;
12
+
13
+ beforeEach(async () => {
14
+ context = new a_stored_read_model();
15
+ context.release.mockImplementation(async request => ({
16
+ HasError: false, Error: '', Payload: JSON.stringify(request.Subject === 'owner'
17
+ ? { id: 'account', name: 'released name' } : { email: 'released email' })
18
+ }));
19
+ result = await context.readModels.releaseDocument(AccountDocument, {
20
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
21
+ __subject: 'owner', __subjects: { name: 'owner', email: 'joined-owner' }
22
+ });
23
+ });
24
+
25
+ it('should reuse the default subject group for matching property subjects', () => context.release.mock.calls.should.have.lengthOf(2));
26
+ it('should apply the default to unmapped properties', () => {
27
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ id: 'account', name: 'encrypted name' });
28
+ });
29
+ it('should override the default for mapped properties', () => {
30
+ JSON.parse(context.requestFor('joined-owner').Payload).should.deep.equal({ email: 'encrypted email' });
31
+ });
32
+ it('should send a schema limited to the overridden property', () => {
33
+ Object.keys(JSON.parse(context.requestFor('joined-owner').Schema).properties).should.deep.equal(['email']);
34
+ });
35
+ it('should merge the released properties without bookkeeping', () => result.should.deep.equal({
36
+ id: 'account', name: 'released name', email: 'released email'
37
+ }));
38
+ });
@@ -0,0 +1,52 @@
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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { JsonSchemaGenerator } from '../../../schemas/index.js';
6
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
7
+
8
+ chai.should();
9
+
10
+ describe('when releasing a document with property subjects', () => {
11
+ let context: a_stored_read_model;
12
+ let result: Record<string, unknown>;
13
+
14
+ beforeEach(async () => {
15
+ context = new a_stored_read_model();
16
+ context.release.mockImplementation(async request => ({
17
+ HasError: false, Error: '', Payload: JSON.stringify(request.Subject === 'owner'
18
+ ? { name: 'released name', email: 'released email' }
19
+ : { contact: { phone: 'released phone' }, contacts: [{ phone: 'released list phone' }] })
20
+ }));
21
+ result = await context.readModels.releaseDocument(AccountDocument, {
22
+ id: 'account', name: 'encrypted name', email: 'encrypted email',
23
+ contact: { phone: 'encrypted phone' }, contacts: [{ phone: 'encrypted list phone' }],
24
+ __subjects: { name: 'owner', email: 'owner', contact: 'contact-owner', contacts: 'contact-owner' }
25
+ });
26
+ });
27
+
28
+ it('should batch properties by distinct subject', () => context.release.mock.calls.should.have.lengthOf(2));
29
+ it('should send only the owner properties to that subject', () => {
30
+ JSON.parse(context.requestFor('owner').Payload).should.deep.equal({ name: 'encrypted name', email: 'encrypted email' });
31
+ });
32
+ it('should release each nested subtree and array under its top-level subject', () => {
33
+ JSON.parse(context.requestFor('contact-owner').Payload).should.deep.equal({
34
+ contact: { phone: 'encrypted phone' }, contacts: [{ phone: 'encrypted list phone' }]
35
+ });
36
+ });
37
+ it('should retain nested compliance schemas', () => {
38
+ const properties = JsonSchemaGenerator.generate(AccountDocument).properties!;
39
+ JSON.parse(context.requestFor('contact-owner').Schema).properties.should.deep.equal({
40
+ contact: properties.contact, contacts: properties.contacts
41
+ });
42
+ });
43
+ it('should include the array item phone PII metadata in the subject-specific schema', () => {
44
+ JSON.parse(context.requestFor('contact-owner').Schema).properties.contacts.items.properties.phone.should.deep.equal({
45
+ type: 'string', compliance: [{ metadataType: 'PII', details: '' }]
46
+ });
47
+ });
48
+ it('should combine the released groups and retain properties without a subject', () => result.should.deep.equal({
49
+ id: 'account', name: 'released name', email: 'released email',
50
+ contact: { phone: 'released phone' }, contacts: [{ phone: 'released list phone' }]
51
+ }));
52
+ });
@@ -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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe.each([
10
+ { __subject: ' \t' },
11
+ { __subject: 'owner', __subjects: { name: ' \t' } }
12
+ ])('when releasing a document with a whitespace-only subject %j', metadata => {
13
+ let context: a_stored_read_model;
14
+ let result: Record<string, unknown>;
15
+
16
+ beforeEach(async () => {
17
+ context = new a_stored_read_model();
18
+ context.release.mockResolvedValue({ HasError: false, Error: '', Payload: JSON.stringify({ name: 'released name' }) });
19
+ result = await context.readModels.releaseDocument(AccountDocument, { name: 'encrypted name', ...metadata });
20
+ });
21
+
22
+ it('should preserve the exact subject in the release request', () => context.requestFor(' \t').Subject.should.equal(' \t'));
23
+ it('should release the property', () => result.should.deep.equal({ name: 'released name' }));
24
+ });
@@ -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 { beforeEach, chai, describe, it } from 'vitest';
5
+ import { AccountDocument, a_stored_read_model } from '../given/a_stored_read_model.js';
6
+
7
+ chai.should();
8
+
9
+ describe('when releasing a document without subjects', () => {
10
+ let context: a_stored_read_model;
11
+ let document: Record<string, unknown>;
12
+ let result: Record<string, unknown>;
13
+
14
+ beforeEach(async () => {
15
+ context = new a_stored_read_model();
16
+ document = { id: 'account', name: 'unchanged', contact: { phone: 'unchanged phone' } };
17
+ result = await context.readModels.releaseDocument(AccountDocument, document);
18
+ });
19
+
20
+ it('should leave data values unchanged', () => result.should.deep.equal(document));
21
+ it('should not infer a subject from id or subject decorators', () => context.release.mock.calls.should.have.lengthOf(0));
22
+ it('should return a copy', () => result.should.not.equal(document));
23
+ it('should not share unreleased subtrees with the input', () => result.contact!.should.not.equal(document.contact));
24
+ });
@@ -2,7 +2,8 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import 'reflect-metadata';
5
- import { diag } from '@opentelemetry/api';
5
+ import { createLogger } from '../logging/createLogger.js';
6
+ import { runInBackgroundContext } from '../telemetry/runInBackgroundContext.js';
6
7
  import { Constructor } from '@cratis/fundamentals';
7
8
  import { ObservationState, ReadModelObserverType, ReducerMessage, ReplayState } from '@cratis/chronicle.contracts';
8
9
  import { IClientArtifactsProvider } from '../artifacts/index.js';
@@ -102,7 +103,7 @@ export class Reducers implements IReducers {
102
103
  /** How long to wait before re-establishing an observation whose stream ended. */
103
104
  private static readonly _reobserveDelayMs = 2000;
104
105
 
105
- private readonly _logger = diag.createComponentLogger({ namespace: '@cratis/chronicle/reducers' });
106
+ private readonly _logger: ReturnType<typeof createLogger>;
106
107
  private readonly _lifecycle: ConnectionLifecycle;
107
108
  private readonly _reducers = new Map<string, Constructor>();
108
109
  private readonly _queues = new Map<string, AsyncQueue<ReducerMessage>>();
@@ -133,6 +134,7 @@ export class Reducers implements IReducers {
133
134
  private readonly _artifactActivator?: ClientArtifactsActivator,
134
135
  private readonly _readModelNamingPolicy?: ReadModelNamingPolicy
135
136
  ) {
137
+ this._logger = createLogger('@cratis/chronicle/reducers', _connection.logger);
136
138
  this._lifecycle = lifecycle;
137
139
  lifecycle.onDisconnected(async () => {
138
140
  this._logger.info('Disconnected — stopping all reducer observations');
@@ -235,21 +237,23 @@ export class Reducers implements IReducers {
235
237
  }
236
238
 
237
239
  private startObservation(id: string, reducerType: Constructor): void {
238
- if (this._disposed) return;
239
- const metadata = getReducerMetadata(reducerType)!;
240
- const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
241
- const dispatcher = new ReducerEventDispatcher(reducerType, this._clientArtifacts.eventTypes);
242
- const readModelName = this.getReducerReadModelIdentifier(reducerType);
243
-
244
- this._logger.info('Starting reducer observation', {
245
- reducerId: id,
246
- eventSequenceId,
247
- readModel: readModelName,
248
- handlerCount: dispatcher.handlers.length,
249
- handlers: dispatcher.handlers.map(e => e.methodName)
250
- });
240
+ runInBackgroundContext(() => {
241
+ if (this._disposed) return;
242
+ const metadata = getReducerMetadata(reducerType)!;
243
+ const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
244
+ const dispatcher = new ReducerEventDispatcher(reducerType, this._clientArtifacts.eventTypes);
245
+ const readModelName = this.getReducerReadModelIdentifier(reducerType);
251
246
 
252
- void this.runObservation(id, reducerType, eventSequenceId, readModelName, dispatcher);
247
+ this._logger.info('Starting reducer observation', {
248
+ reducerId: id,
249
+ eventSequenceId,
250
+ readModel: readModelName,
251
+ handlerCount: dispatcher.handlers.length,
252
+ handlers: dispatcher.handlers.map(e => e.methodName)
253
+ });
254
+
255
+ void this.runObservation(id, reducerType, eventSequenceId, readModelName, dispatcher);
256
+ });
253
257
  }
254
258
 
255
259
  private async runObservation(
@@ -262,7 +266,7 @@ export class Reducers implements IReducers {
262
266
  try {
263
267
  await this.observeReducer(id, reducerType, eventSequenceId, readModelName, dispatcher);
264
268
  } catch (error) {
265
- this._logger.error('Reducer observation loop exited with error', { reducerId: id, error: String(error) });
269
+ this._logger.error('Reducer observation loop exited with error', { reducerId: id, error });
266
270
  }
267
271
 
268
272
  this.scheduleReobserve(id, reducerType);
@@ -285,14 +289,14 @@ export class Reducers implements IReducers {
285
289
  return;
286
290
  }
287
291
 
288
- const handle = setTimeout(() => {
292
+ const handle = runInBackgroundContext(() => setTimeout(() => {
289
293
  if (!this._registered || this._disposed) {
290
294
  return;
291
295
  }
292
296
 
293
297
  this._logger.info('Re-establishing reducer observation', { reducerId: id });
294
298
  this.startObservation(id, reducerType);
295
- }, Reducers._reobserveDelayMs);
299
+ }, Reducers._reobserveDelayMs));
296
300
 
297
301
  handle.unref?.();
298
302
  }
@@ -359,7 +363,6 @@ export class Reducers implements IReducers {
359
363
 
360
364
  this._logger.debug('Received reduce operation', {
361
365
  reducerId: id,
362
- partition: operation.Partition,
363
366
  count: operation.Events.length,
364
367
  hasInitialState: operation.InitialState !== '',
365
368
  replayState: operation.ReplayState
@@ -375,12 +378,12 @@ export class Reducers implements IReducers {
375
378
  replayState: operation.ReplayState
376
379
  }, this._artifactActivator, artifact => runActivated(artifact, () =>
377
380
  notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
378
- { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState }));
381
+ { delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState }), this._connection.logger);
379
382
  } else if (reducerInstance) {
380
383
  await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
381
384
  }
382
385
  } catch (err) {
383
- this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: String(err) });
386
+ this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: err });
384
387
  if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
385
388
  exceptionMessages.push(String(err.processingError));
386
389
  }
@@ -424,7 +427,7 @@ export class Reducers implements IReducers {
424
427
  { delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName });
425
428
  lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
426
429
  } catch (err) {
427
- this._logger.error('Error handling event in reducer', { reducerId: id, error: String(err) });
430
+ this._logger.error('Error handling event in reducer', { reducerId: id, error: err });
428
431
  exceptionMessages.push(String(err));
429
432
  exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
430
433
  state = ObservationState.Failed;
@@ -445,9 +448,9 @@ export class Reducers implements IReducers {
445
448
  readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
446
449
  signal: controller.signal, delivery: ArtifactDelivery.Events,
447
450
  eventContext: toClientEventContext(firstInvocableEvent.Context!)
448
- }, this._artifactActivator, processEvents);
451
+ }, this._artifactActivator, processEvents, this._connection.logger);
449
452
  } catch (err) {
450
- this._logger.error('Error activating reducer', { reducerId: id, error: String(err) });
453
+ this._logger.error('Error activating reducer', { reducerId: id, error: err });
451
454
  exceptionMessages.push(String(err));
452
455
  const completionStack = err instanceof Error ? (err.stack ?? '') : '';
453
456
  exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
@@ -483,7 +486,7 @@ export class Reducers implements IReducers {
483
486
  if (!this._queues.has(id)) {
484
487
  this._logger.debug('Reducer observation stream closed cleanly', { reducerId: id });
485
488
  } else {
486
- this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: String(err) });
489
+ this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: err });
487
490
  }
488
491
  } finally {
489
492
  // Only retire our own queue: a reconnect can already have replaced it,