@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.
- package/ChronicleClient.ts +35 -33
- package/ChronicleOptions.ts +27 -1
- package/EventStore.ts +14 -12
- package/Metrics.ts +70 -96
- package/Tracing.ts +4 -2
- package/WellKnownTelemetryNames.ts +67 -0
- package/artifacts/validateArtifactSchemas.spec.ts +1 -1
- package/artifacts/withActivatedArtifact.ts +7 -5
- package/connection/ChronicleConnection.ts +18 -6
- package/connection/CompatibilityPreflight.ts +2 -1
- package/connection/KernelKeepAlive.ts +13 -7
- package/connection/TokenProvider.ts +3 -1
- package/connection/TraceContextMiddleware.ts +30 -0
- package/connection/for_ChronicleConnection/when_creating_clients/with_correlated_multi_host_callers.spec.ts +73 -0
- package/connection/for_OAuthTokenProvider/when_acquiring_tokens/with_http_instrumentation.spec.ts +102 -0
- package/connection/for_TraceContextMiddleware/when_calling/with_active_context.spec.ts +235 -0
- package/connection/for_TraceContextMiddleware/when_calling/with_grpc_instrumentation.spec.ts +102 -0
- package/correlation/CorrelationIdManager.ts +10 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +35 -33
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +8 -0
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +19 -1
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/EventStore.d.ts +3 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +14 -12
- package/dist/EventStore.js.map +1 -1
- package/dist/Metrics.d.ts +23 -64
- package/dist/Metrics.d.ts.map +1 -1
- package/dist/Metrics.js +62 -92
- package/dist/Metrics.js.map +1 -1
- package/dist/Tracing.d.ts +1 -1
- package/dist/Tracing.d.ts.map +1 -1
- package/dist/Tracing.js +4 -2
- package/dist/Tracing.js.map +1 -1
- package/dist/WellKnownTelemetryNames.d.ts +65 -0
- package/dist/WellKnownTelemetryNames.d.ts.map +1 -0
- package/dist/WellKnownTelemetryNames.js +67 -0
- package/dist/WellKnownTelemetryNames.js.map +1 -0
- package/dist/artifacts/withActivatedArtifact.d.ts +2 -1
- package/dist/artifacts/withActivatedArtifact.d.ts.map +1 -1
- package/dist/artifacts/withActivatedArtifact.js +5 -4
- package/dist/artifacts/withActivatedArtifact.js.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +5 -0
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +13 -6
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/CompatibilityPreflight.d.ts.map +1 -1
- package/dist/connection/CompatibilityPreflight.js +2 -1
- package/dist/connection/CompatibilityPreflight.js.map +1 -1
- package/dist/connection/KernelKeepAlive.d.ts +3 -1
- package/dist/connection/KernelKeepAlive.d.ts.map +1 -1
- package/dist/connection/KernelKeepAlive.js +10 -7
- package/dist/connection/KernelKeepAlive.js.map +1 -1
- package/dist/connection/TokenProvider.d.ts.map +1 -1
- package/dist/connection/TokenProvider.js +3 -1
- package/dist/connection/TokenProvider.js.map +1 -1
- package/dist/connection/TraceContextMiddleware.d.ts +4 -0
- package/dist/connection/TraceContextMiddleware.d.ts.map +1 -0
- package/dist/connection/TraceContextMiddleware.js +30 -0
- package/dist/connection/TraceContextMiddleware.js.map +1 -0
- package/dist/correlation/CorrelationIdManager.d.ts +4 -0
- package/dist/correlation/CorrelationIdManager.d.ts.map +1 -1
- package/dist/correlation/CorrelationIdManager.js +8 -0
- package/dist/correlation/CorrelationIdManager.js.map +1 -1
- package/dist/eventSequences/EventLog.d.ts +2 -1
- package/dist/eventSequences/EventLog.d.ts.map +1 -1
- package/dist/eventSequences/EventLog.js +2 -2
- package/dist/eventSequences/EventLog.js.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts +4 -1
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +63 -80
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventStoreSubscriptions/EventStoreSubscriptions.d.ts.map +1 -1
- package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js +3 -4
- package/dist/eventStoreSubscriptions/EventStoreSubscriptions.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/logging/ChronicleLogEntry.d.ts +11 -0
- package/dist/logging/ChronicleLogEntry.d.ts.map +1 -0
- package/dist/logging/ChronicleLogEntry.js +4 -0
- package/dist/logging/ChronicleLogEntry.js.map +1 -0
- package/dist/logging/ChronicleLogLevel.d.ts +9 -0
- package/dist/logging/ChronicleLogLevel.d.ts.map +1 -0
- package/dist/logging/ChronicleLogLevel.js +12 -0
- package/dist/logging/ChronicleLogLevel.js.map +1 -0
- package/dist/logging/DiagChronicleLogger.d.ts +7 -0
- package/dist/logging/DiagChronicleLogger.d.ts.map +1 -0
- package/dist/logging/DiagChronicleLogger.js +10 -0
- package/dist/logging/DiagChronicleLogger.js.map +1 -0
- package/dist/logging/IChronicleLogger.d.ts +7 -0
- package/dist/logging/IChronicleLogger.d.ts.map +1 -0
- package/dist/logging/IChronicleLogger.js +4 -0
- package/dist/logging/IChronicleLogger.js.map +1 -0
- package/dist/logging/createLogger.d.ts +10 -0
- package/dist/logging/createLogger.d.ts.map +1 -0
- package/dist/logging/createLogger.js +55 -0
- package/dist/logging/createLogger.js.map +1 -0
- package/dist/projections/Projections.d.ts.map +1 -1
- package/dist/projections/Projections.js +4 -3
- package/dist/projections/Projections.js.map +1 -1
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +26 -24
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/readModels/IReadModels.d.ts +14 -0
- package/dist/readModels/IReadModels.d.ts.map +1 -1
- package/dist/readModels/ReadModels.d.ts +6 -0
- package/dist/readModels/ReadModels.d.ts.map +1 -1
- package/dist/readModels/ReadModels.js +110 -0
- package/dist/readModels/ReadModels.js.map +1 -1
- package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts +20 -0
- package/dist/readModels/for_ReadModels/given/a_stored_read_model.d.ts.map +1 -0
- package/dist/readModels/for_ReadModels/given/a_stored_read_model.js +79 -0
- package/dist/readModels/for_ReadModels/given/a_stored_read_model.js.map +1 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +28 -25
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/seeding/EventSeeding.d.ts.map +1 -1
- package/dist/seeding/EventSeeding.js +4 -3
- package/dist/seeding/EventSeeding.js.map +1 -1
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts +11 -0
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -0
- package/dist/telemetry/ChronicleTelemetryOptions.js +4 -0
- package/dist/telemetry/ChronicleTelemetryOptions.js.map +1 -0
- package/dist/telemetry/observeOperation.d.ts +6 -0
- package/dist/telemetry/observeOperation.d.ts.map +1 -0
- package/dist/telemetry/observeOperation.js +23 -0
- package/dist/telemetry/observeOperation.js.map +1 -0
- package/dist/telemetry/runInBackgroundContext.d.ts +3 -0
- package/dist/telemetry/runInBackgroundContext.d.ts.map +1 -0
- package/dist/telemetry/runInBackgroundContext.js +9 -0
- package/dist/telemetry/runInBackgroundContext.js.map +1 -0
- package/dist/telemetry/spanAttributes.d.ts +14 -0
- package/dist/telemetry/spanAttributes.d.ts.map +1 -0
- package/dist/telemetry/spanAttributes.js +47 -0
- package/dist/telemetry/spanAttributes.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventLog.ts +4 -2
- package/eventSequences/EventSequence.ts +64 -81
- package/eventStoreSubscriptions/EventStoreSubscriptions.ts +5 -5
- package/for_ChronicleClient/when_starting_background_work/with_a_correlated_caller.spec.ts +150 -0
- package/for_ChronicleOptions/when_configuring_telemetry/with_event_source_identifiers.spec.ts +44 -0
- package/index.ts +7 -1
- package/logging/ChronicleLogEntry.ts +14 -0
- package/logging/ChronicleLogLevel.ts +11 -0
- package/logging/DiagChronicleLogger.ts +13 -0
- package/logging/IChronicleLogger.ts +10 -0
- package/logging/createLogger.ts +50 -0
- package/logging/for_ChronicleLogger/when_logging/with_client_owned_sinks.spec.ts +96 -0
- package/package.json +7 -1
- package/projections/Projections.ts +4 -3
- package/reactors/Reactors.ts +27 -25
- package/reactors/for_Reactors/when_delivering/with_activation.spec.ts +14 -14
- package/readModels/IReadModels.ts +15 -0
- package/readModels/ReadModels.ts +118 -1
- package/readModels/for_ReadModels/given/a_stored_read_model.ts +62 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_a_default_subject.spec.ts +39 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_a_frozen_document.spec.ts +28 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_a_malformed_response.spec.ts +24 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_a_reported_failure.spec.ts +38 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_an_empty_released_value.spec.ts +19 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_an_unknown_subject_property.spec.ts +42 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_bookkeeping_fields.spec.ts +27 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_conflicting_or_missing_properties.spec.ts +32 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_empty_subject_metadata.spec.ts +56 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_invalid_subject_metadata.spec.ts +27 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_mixed_subjects.spec.ts +38 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_property_subjects.spec.ts +52 -0
- package/readModels/for_ReadModels/when_releasing_a_document/with_whitespace_subjects.spec.ts +24 -0
- package/readModels/for_ReadModels/when_releasing_a_document/without_subjects.spec.ts +24 -0
- package/reducers/Reducers.ts +29 -26
- package/seeding/EventSeeding.ts +6 -4
- package/telemetry/ChronicleTelemetryOptions.ts +8 -0
- package/telemetry/for_ChronicleTelemetry/given/a_telemetry_session.fixture.ts +36 -0
- package/telemetry/for_ChronicleTelemetry/given/an_event_sequence.fixture.ts +29 -0
- package/telemetry/for_ChronicleTelemetry/when_appending/with_shared_and_legacy_attributes.spec.ts +58 -0
- package/telemetry/for_ChronicleTelemetry/when_appending/without_an_sdk.spec.ts +15 -0
- package/telemetry/for_ChronicleTelemetry/when_documenting/with_public_names.spec.ts +15 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_failures/with_sensitive_exception_details.spec.ts +34 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_identifiers/with_per_client_privacy.spec.ts +92 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_metrics/with_legacy_overlap.spec.ts +40 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_sequence_numbers/with_integer_precision.spec.ts +23 -0
- package/telemetry/observeOperation.ts +25 -0
- package/telemetry/runInBackgroundContext.ts +10 -0
- package/telemetry/spanAttributes.ts +49 -0
|
@@ -0,0 +1,50 @@
|
|
|
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 { context, propagation, trace, isSpanContextValid, type Attributes } from '@opentelemetry/api';
|
|
5
|
+
import { ClientError, ServerError } from 'nice-grpc-common';
|
|
6
|
+
import { correlationIdManager } from '../correlation/index.js';
|
|
7
|
+
import { WellKnownTelemetryNames } from '../WellKnownTelemetryNames.js';
|
|
8
|
+
import { exceptionType } from '../telemetry/spanAttributes.js';
|
|
9
|
+
import { ChronicleLogLevel } from './ChronicleLogLevel.js';
|
|
10
|
+
import { DiagChronicleLogger } from './DiagChronicleLogger.js';
|
|
11
|
+
import type { IChronicleLogger } from './IChronicleLogger.js';
|
|
12
|
+
|
|
13
|
+
/** Binds a category to a client-owned sink and adds safe correlation fields. */
|
|
14
|
+
export function createLogger(category: string, sink: IChronicleLogger = new DiagChronicleLogger()) {
|
|
15
|
+
const log = (level: ChronicleLogLevel) => (message: string, fields: Record<string, unknown> = {}) => {
|
|
16
|
+
const attributes: Attributes = {};
|
|
17
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
18
|
+
if (key === 'error') {
|
|
19
|
+
attributes['error.type'] = exceptionType(value);
|
|
20
|
+
attributes['exception.type'] = exceptionType(value);
|
|
21
|
+
if (value instanceof ClientError || value instanceof ServerError) attributes['rpc.grpc.status_code'] = value.code;
|
|
22
|
+
} else if (key === 'sequenceNumber') {
|
|
23
|
+
const number = Number(value);
|
|
24
|
+
if (Number.isSafeInteger(number)) attributes[WellKnownTelemetryNames.attributes.sequenceNumber] = number;
|
|
25
|
+
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
26
|
+
attributes[key] = value;
|
|
27
|
+
} else if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
|
|
28
|
+
attributes[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const active = context.active();
|
|
32
|
+
const correlationKey = WellKnownTelemetryNames.attributes.correlationId;
|
|
33
|
+
const correlation = correlationIdManager.scoped?.toString() ?? propagation.getBaggage(active)?.getEntry(correlationKey)?.value;
|
|
34
|
+
if (correlation) attributes[correlationKey] = correlation;
|
|
35
|
+
const spanContext = trace.getSpanContext(active);
|
|
36
|
+
if (spanContext && isSpanContextValid(spanContext)) {
|
|
37
|
+
attributes.trace_id = spanContext.traceId;
|
|
38
|
+
attributes.span_id = spanContext.spanId;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
sink.log({ category, level, message, attributes });
|
|
42
|
+
} catch {
|
|
43
|
+
// Diagnostics are not the operation's outcome. A broken sink must not change RPCs or acknowledgements.
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
verbose: log(ChronicleLogLevel.Verbose), debug: log(ChronicleLogLevel.Debug),
|
|
48
|
+
info: log(ChronicleLogLevel.Info), warn: log(ChronicleLogLevel.Warn), error: log(ChronicleLogLevel.Error)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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, vi } from 'vitest';
|
|
5
|
+
import { diag, DiagLogLevel } from '@opentelemetry/api';
|
|
6
|
+
import { ClientError, ServerError, Status } from 'nice-grpc-common';
|
|
7
|
+
import { ChronicleClient } from '../../../ChronicleClient.js';
|
|
8
|
+
import { ChronicleOptions } from '../../../ChronicleOptions.js';
|
|
9
|
+
import { createLogger } from '../../createLogger.js';
|
|
10
|
+
import type { ChronicleLogEntry } from '../../ChronicleLogEntry.js';
|
|
11
|
+
import { telemetrySession } from '../../../telemetry/for_ChronicleTelemetry/given/a_telemetry_session.fixture.js';
|
|
12
|
+
import { CorrelationId, correlationIdManager } from '../../../correlation/index.js';
|
|
13
|
+
|
|
14
|
+
vi.mock('../../../connection/ChronicleConnection.js', () => ({ ChronicleConnection: class { disconnect() {} } }));
|
|
15
|
+
chai.should();
|
|
16
|
+
const telemetry = telemetrySession();
|
|
17
|
+
|
|
18
|
+
describe('when two clients have different diagnostic sinks', () => {
|
|
19
|
+
const first: ChronicleLogEntry[] = [];
|
|
20
|
+
const second: ChronicleLogEntry[] = [];
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
first.length = 0;
|
|
23
|
+
second.length = 0;
|
|
24
|
+
const one = new ChronicleClient(ChronicleOptions.development({ discoveryPatterns: [], logger: { log: entry => first.push(entry) } }));
|
|
25
|
+
const two = new ChronicleClient(ChronicleOptions.development({ discoveryPatterns: [], logger: { log: entry => second.push(entry) } }));
|
|
26
|
+
one.dispose();
|
|
27
|
+
two.dispose();
|
|
28
|
+
});
|
|
29
|
+
it('should route each diagnostic only to its owning sink', () => {
|
|
30
|
+
first.should.have.lengthOf(2);
|
|
31
|
+
second.should.have.lengthOf(2);
|
|
32
|
+
(first[0] === second[0]).should.be.false;
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('when logging in a correlated trace', () => {
|
|
37
|
+
let entry: ChronicleLogEntry;
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
const logger = createLogger('test', { log: value => { entry = value; } });
|
|
40
|
+
await correlationIdManager.run(new CorrelationId('business-id'), () => telemetry.traces.getTracer('host').startActiveSpan('operation', async span => {
|
|
41
|
+
try { logger.error('Operation failed', { error: new TypeError('secret'), sequenceNumber: 9007199254740992n }); }
|
|
42
|
+
finally { span.end(); }
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
it('should export error types with business and trace identifiers but no exception details', () => {
|
|
46
|
+
entry.attributes.should.include({ 'error.type': 'TypeError', 'exception.type': 'TypeError', 'cratis.correlation_id': 'business-id' });
|
|
47
|
+
entry.attributes.should.have.property('trace_id');
|
|
48
|
+
entry.attributes.should.have.property('span_id');
|
|
49
|
+
entry.attributes.should.not.have.property('cratis.event_sequence.number');
|
|
50
|
+
JSON.stringify(entry).should.not.contain('secret');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const error of [new ClientError('/test', Status.UNAVAILABLE, 'secret'), new ServerError(Status.UNAVAILABLE, 'secret')]) {
|
|
55
|
+
describe('when logging a gRPC failure', () => {
|
|
56
|
+
let entry: ChronicleLogEntry;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
createLogger('test', { log: value => { entry = value; } }).error('Operation failed', { error });
|
|
59
|
+
});
|
|
60
|
+
it('should retain the numeric status without exporting sensitive exception details', () => {
|
|
61
|
+
entry.attributes.should.include({ 'rpc.grpc.status_code': Status.UNAVAILABLE });
|
|
62
|
+
JSON.stringify(entry).should.not.contain('secret');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const error of [new DOMException('secret', 'TimeoutError'), new DOMException('secret', 'AbortError'), { code: Status.UNAVAILABLE, message: 'secret' }]) {
|
|
68
|
+
describe('when logging a non-gRPC error with a numeric code', () => {
|
|
69
|
+
let entry: ChronicleLogEntry;
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
createLogger('test', { log: value => { entry = value; } }).error('Operation failed', { error });
|
|
72
|
+
});
|
|
73
|
+
it('should not label its code as a gRPC status', () => {
|
|
74
|
+
entry.attributes.should.not.have.property('rpc.grpc.status_code');
|
|
75
|
+
JSON.stringify(entry).should.not.contain('secret');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('when no logger is configured during the compatibility period', () => {
|
|
81
|
+
it('should still deliver sanitized diagnostics to diag', () => {
|
|
82
|
+
const error = vi.fn();
|
|
83
|
+
diag.setLogger({ error, warn: vi.fn(), info: vi.fn(), debug: vi.fn(), verbose: vi.fn() }, DiagLogLevel.ERROR);
|
|
84
|
+
try {
|
|
85
|
+
createLogger('test').error('Operation failed', { error: new Error('secret') });
|
|
86
|
+
error.mock.calls.should.have.lengthOf(1);
|
|
87
|
+
JSON.stringify(error.mock.calls).should.not.contain('secret');
|
|
88
|
+
} finally { diag.disable(); }
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('when a diagnostic sink throws', () => {
|
|
93
|
+
it('should leave the operation unaffected', () => {
|
|
94
|
+
(() => createLogger('test', { log: () => { throw new Error('sink failed'); } }).info('Diagnostic')).should.not.throw();
|
|
95
|
+
});
|
|
96
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/chronicle",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.34.0",
|
|
4
4
|
"description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
|
|
5
5
|
"author": "Cratis",
|
|
6
6
|
"license": "MIT",
|
|
@@ -184,6 +184,12 @@
|
|
|
184
184
|
},
|
|
185
185
|
"devDependencies": {
|
|
186
186
|
"@cratis/fundamentals": "7.20.0",
|
|
187
|
+
"@opentelemetry/context-async-hooks": "2.11.0",
|
|
188
|
+
"@opentelemetry/core": "2.11.0",
|
|
189
|
+
"@opentelemetry/instrumentation-grpc": "0.222.0",
|
|
190
|
+
"@opentelemetry/instrumentation-http": "0.222.0",
|
|
191
|
+
"@opentelemetry/sdk-metrics": "2.11.0",
|
|
192
|
+
"@opentelemetry/sdk-trace-base": "2.11.0",
|
|
187
193
|
"@types/node": "^26.6.3",
|
|
188
194
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
189
195
|
"esbuild": "^0.28.2",
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import {
|
|
4
|
+
import { createLogger } from '../logging/createLogger.js';
|
|
5
5
|
import { ProjectionOwner, type ProjectionDefinition } from '@cratis/chronicle.contracts';
|
|
6
6
|
import { Constructor, Guid } from '@cratis/fundamentals';
|
|
7
7
|
import { IClientArtifactsProvider } from '../artifacts/index.js';
|
|
@@ -36,7 +36,7 @@ export class Projections implements IProjections {
|
|
|
36
36
|
private readonly _modelBound = new Map<string, Constructor>();
|
|
37
37
|
private readonly _failedPartitions: FailedPartitions;
|
|
38
38
|
|
|
39
|
-
private readonly _logger
|
|
39
|
+
private readonly _logger: ReturnType<typeof createLogger>;
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Creates a new {@link Projections} instance.
|
|
@@ -55,6 +55,7 @@ export class Projections implements IProjections {
|
|
|
55
55
|
private readonly _defaultSinkTypeId: string,
|
|
56
56
|
private readonly _readModelNamingPolicy?: ReadModelNamingPolicy
|
|
57
57
|
) {
|
|
58
|
+
this._logger = createLogger('@cratis/chronicle/projections', _connection.logger);
|
|
58
59
|
this._failedPartitions = new FailedPartitions(_eventStore, _namespace, _connection);
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -296,7 +297,7 @@ export class Projections implements IProjections {
|
|
|
296
297
|
identifier,
|
|
297
298
|
attempt,
|
|
298
299
|
nextAttemptInMs: delay,
|
|
299
|
-
error
|
|
300
|
+
error
|
|
300
301
|
});
|
|
301
302
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
302
303
|
delay = Math.min(delay * 2, 15000);
|
package/reactors/Reactors.ts
CHANGED
|
@@ -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 {
|
|
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, ReactorMessage, ReplayState } from '@cratis/chronicle.contracts';
|
|
8
9
|
import { IClientArtifactsProvider } from '../artifacts/index.js';
|
|
@@ -102,7 +103,7 @@ export class Reactors implements IReactors {
|
|
|
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
|
|
106
|
+
private readonly _logger: ReturnType<typeof createLogger>;
|
|
106
107
|
private readonly _lifecycle: ConnectionLifecycle;
|
|
107
108
|
private readonly _reactors = new Map<string, Constructor>();
|
|
108
109
|
private readonly _queues = new Map<string, AsyncQueue<ReactorMessage>>();
|
|
@@ -130,6 +131,7 @@ export class Reactors implements IReactors {
|
|
|
130
131
|
private readonly _eventStore?: IEventStore,
|
|
131
132
|
private readonly _artifactActivator?: ClientArtifactsActivator
|
|
132
133
|
) {
|
|
134
|
+
this._logger = createLogger('@cratis/chronicle/reactors', _connection.logger);
|
|
133
135
|
this._lifecycle = lifecycle;
|
|
134
136
|
lifecycle.onDisconnected(async () => {
|
|
135
137
|
this._logger.info('Disconnected — stopping all reactor observations');
|
|
@@ -177,19 +179,21 @@ export class Reactors implements IReactors {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
private startObservation(id: string, reactorType: Constructor): void {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
runInBackgroundContext(() => {
|
|
183
|
+
if (this._disposed) return;
|
|
184
|
+
const metadata = getReactorMetadata(reactorType)!;
|
|
185
|
+
const eventSequenceId = metadata.eventSequenceId ?? EventSequenceId.eventLog.value;
|
|
186
|
+
const eventTypes = getReactorEventTypes(reactorType, this._clientArtifacts.eventTypes);
|
|
187
|
+
|
|
188
|
+
this._logger.info('Starting reactor observation', {
|
|
189
|
+
reactorId: id,
|
|
190
|
+
eventSequenceId,
|
|
191
|
+
handlerCount: eventTypes.length,
|
|
192
|
+
handlers: eventTypes.map(e => e.methodName)
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
|
|
190
196
|
});
|
|
191
|
-
|
|
192
|
-
void this.runObservation(id, reactorType, eventSequenceId, eventTypes);
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
private async runObservation(
|
|
@@ -201,7 +205,7 @@ export class Reactors implements IReactors {
|
|
|
201
205
|
try {
|
|
202
206
|
await this.observeReactor(id, reactorType, eventSequenceId, eventTypes);
|
|
203
207
|
} catch (error) {
|
|
204
|
-
this._logger.error('Reactor observation loop exited with error', { reactorId: id, error
|
|
208
|
+
this._logger.error('Reactor observation loop exited with error', { reactorId: id, error });
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
this.scheduleReobserve(id, reactorType);
|
|
@@ -223,14 +227,14 @@ export class Reactors implements IReactors {
|
|
|
223
227
|
return;
|
|
224
228
|
}
|
|
225
229
|
|
|
226
|
-
const handle = setTimeout(() => {
|
|
230
|
+
const handle = runInBackgroundContext(() => setTimeout(() => {
|
|
227
231
|
if (!this._registered || this._disposed) {
|
|
228
232
|
return;
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
this._logger.info('Re-establishing reactor observation', { reactorId: id });
|
|
232
236
|
this.startObservation(id, reactorType);
|
|
233
|
-
}, Reactors._reobserveDelayMs);
|
|
237
|
+
}, Reactors._reobserveDelayMs));
|
|
234
238
|
|
|
235
239
|
handle.unref?.();
|
|
236
240
|
}
|
|
@@ -286,7 +290,6 @@ export class Reactors implements IReactors {
|
|
|
286
290
|
|
|
287
291
|
this._logger.debug('Received events to observe', {
|
|
288
292
|
reactorId: id,
|
|
289
|
-
partition: eventsToObserve.Partition,
|
|
290
293
|
count: eventsToObserve.Events.length,
|
|
291
294
|
replayState: eventsToObserve.ReplayState
|
|
292
295
|
});
|
|
@@ -301,12 +304,12 @@ export class Reactors implements IReactors {
|
|
|
301
304
|
replayState: eventsToObserve.ReplayState
|
|
302
305
|
}, this._artifactActivator, artifact => runActivated(artifact, () =>
|
|
303
306
|
notifyReplayLifecycle(artifact.instance, eventsToObserve.ReplayState, eventsToObserve.Partition),
|
|
304
|
-
{ delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }));
|
|
307
|
+
{ delivery: ArtifactDelivery.ReplayNotification, replayState: eventsToObserve.ReplayState }), this._connection.logger);
|
|
305
308
|
} else if (reactorInstance) {
|
|
306
309
|
await notifyReplayLifecycle(reactorInstance, eventsToObserve.ReplayState, eventsToObserve.Partition);
|
|
307
310
|
}
|
|
308
311
|
} catch (err) {
|
|
309
|
-
this._logger.error('Error notifying reactor of replay lifecycle transition', { reactorId: id, error:
|
|
312
|
+
this._logger.error('Error notifying reactor of replay lifecycle transition', { reactorId: id, error: err });
|
|
310
313
|
if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
|
|
311
314
|
exceptionMessages.push(String(err.processingError));
|
|
312
315
|
}
|
|
@@ -348,7 +351,6 @@ export class Reactors implements IReactors {
|
|
|
348
351
|
}
|
|
349
352
|
|
|
350
353
|
const content = JSON.parse(event.Content) as Record<string, unknown>;
|
|
351
|
-
this._logger.debug('Event content', { reactorId: id, eventTypeId, contentKeys: Object.keys(content), rawContent: event.Content.substring(0, 200) });
|
|
352
354
|
const context = toClientEventContext(event.Context!);
|
|
353
355
|
|
|
354
356
|
this._logger.info('Invoking reactor handler', {
|
|
@@ -364,7 +366,7 @@ export class Reactors implements IReactors {
|
|
|
364
366
|
|
|
365
367
|
lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
|
|
366
368
|
} catch (err) {
|
|
367
|
-
this._logger.error('Error handling event in reactor', { reactorId: id, error:
|
|
369
|
+
this._logger.error('Error handling event in reactor', { reactorId: id, error: err });
|
|
368
370
|
exceptionMessages.push(String(err));
|
|
369
371
|
exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
|
|
370
372
|
state = ObservationState.Failed;
|
|
@@ -386,9 +388,9 @@ export class Reactors implements IReactors {
|
|
|
386
388
|
readModels: services.readModels, eventSequenceId, partition: eventsToObserve.Partition,
|
|
387
389
|
signal: controller.signal, delivery: ArtifactDelivery.Events,
|
|
388
390
|
eventContext: toClientEventContext(firstInvocableEvent.Context!)
|
|
389
|
-
}, this._artifactActivator, processEvents);
|
|
391
|
+
}, this._artifactActivator, processEvents, this._connection.logger);
|
|
390
392
|
} catch (err) {
|
|
391
|
-
this._logger.error('Error activating reactor', { reactorId: id, error:
|
|
393
|
+
this._logger.error('Error activating reactor', { reactorId: id, error: err });
|
|
392
394
|
exceptionMessages.push(String(err));
|
|
393
395
|
const completionStack = err instanceof Error ? (err.stack ?? '') : '';
|
|
394
396
|
exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
|
|
@@ -419,7 +421,7 @@ export class Reactors implements IReactors {
|
|
|
419
421
|
if (!this._queues.has(id)) {
|
|
420
422
|
this._logger.debug('Reactor observation stream closed cleanly', { reactorId: id });
|
|
421
423
|
} else {
|
|
422
|
-
this._logger.error('Reactor observation stream ended unexpectedly', { reactorId: id, error:
|
|
424
|
+
this._logger.error('Reactor observation stream ended unexpectedly', { reactorId: id, error: err });
|
|
423
425
|
}
|
|
424
426
|
} finally {
|
|
425
427
|
// Only retire our own queue: a reconnect can already have replaced it,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { EventContext as WireContext, EventObservationState, EventType, ObservationState, type ReactorMessage } from '@cratis/chronicle.contracts';
|
|
5
5
|
import { ReplayState } from '../../../index.js';
|
|
6
6
|
import { chai, describe, it, vi } from 'vitest';
|
|
7
|
-
import {
|
|
7
|
+
import type { IChronicleLogger } from '../../../logging/IChronicleLogger.js';
|
|
8
|
+
import type { ChronicleLogEntry } from '../../../logging/ChronicleLogEntry.js';
|
|
8
9
|
import type { ChronicleConnection } from '../../../connection/index.js';
|
|
9
10
|
import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle.js';
|
|
10
11
|
import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifactsProvider.js';
|
|
@@ -98,7 +99,7 @@ function delivery(events: ReturnType<typeof event>[], replayState = ReplayState.
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
async function observe(type: Function, batches: ReturnType<typeof delivery>[], namespace = 'tenant-a',
|
|
101
|
-
activator?: ClientArtifactsActivator, appendResult = true, onAppendMany?: () => void) {
|
|
102
|
+
activator?: ClientArtifactsActivator, appendResult = true, onAppendMany?: () => void, logger?: IChronicleLogger) {
|
|
102
103
|
const acknowledgements: NonNullable<ReactorMessage['Content']>['Value1'][] = [];
|
|
103
104
|
const append = vi.fn().mockResolvedValue({ isSuccess: appendResult });
|
|
104
105
|
const appendMany = vi.fn().mockImplementation(async () => {
|
|
@@ -122,7 +123,7 @@ async function observe(type: Function, batches: ReturnType<typeof delivery>[], n
|
|
|
122
123
|
await lifecycle.disconnected(error => { throw error; });
|
|
123
124
|
finished();
|
|
124
125
|
}
|
|
125
|
-
const connection = { reactors: { observe: stream } } as unknown as ChronicleConnection;
|
|
126
|
+
const connection = { logger, reactors: { observe: stream } } as unknown as ChronicleConnection;
|
|
126
127
|
const artifacts = { reactors: [type], eventTypes: [ActivationEvent] } as unknown as IClientArtifactsProvider;
|
|
127
128
|
await new Reactors(artifacts, connection, 'store', namespace, lifecycle, eventLog, undefined, store, activator).register();
|
|
128
129
|
await complete;
|
|
@@ -462,16 +463,15 @@ describe('when delivering reactor batches', () => {
|
|
|
462
463
|
});
|
|
463
464
|
|
|
464
465
|
it('should log disposal failure without changing successful acknowledgement', async () => {
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
466
|
+
const entries: ChronicleLogEntry[] = [];
|
|
467
|
+
const activator: ClientArtifactsActivator = type => ({ instance: new type(), dispose: async () => { throw new Error('private cleanup details'); } });
|
|
468
|
+
const result = await observe(ActivationReactor, [delivery([event('private payload', 1n)])], 'tenant-a', activator,
|
|
469
|
+
true, undefined, { log: entry => entries.push(entry) });
|
|
470
|
+
result.acknowledgements[0]?.State.should.equal(ObservationState.Success);
|
|
471
|
+
result.acknowledgements[0]?.LastSuccessfulObservation.should.equal(1n);
|
|
472
|
+
entries.filter(entry => entry.attributes['exception.type'] === 'Error').should.have.lengthOf(1);
|
|
473
|
+
JSON.stringify(entries).should.not.contain('private cleanup details');
|
|
474
|
+
JSON.stringify(entries).should.not.contain('private payload');
|
|
475
|
+
JSON.stringify(entries).should.not.contain('book-1');
|
|
476
476
|
});
|
|
477
477
|
});
|
|
@@ -95,4 +95,19 @@ export interface IReadModels {
|
|
|
95
95
|
* @returns The read model instances with decrypted PII values.
|
|
96
96
|
*/
|
|
97
97
|
releaseMany<TReadModel>(readModelType: Constructor<TReadModel>, instances: TReadModel[]): Promise<TReadModel[]>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Releases PII in a raw stored document using its __subject and per-property __subjects metadata.
|
|
101
|
+
* Each __subjects key names a top-level property and overrides the default for its entire subtree.
|
|
102
|
+
* Properties without either subject are copied unchanged; id and @subject() are not fallbacks.
|
|
103
|
+
* @param readModelType - The discovered read model type supplying the schema.
|
|
104
|
+
* @param document - A JSON document, with optional non-empty string __subject and a __subjects map of property names to non-empty strings.
|
|
105
|
+
* @returns A new plain document containing only schema-declared top-level properties, without __subject or __subjects.
|
|
106
|
+
* The input is never mutated. This does not construct a typed read model instance.
|
|
107
|
+
* @throws If metadata is invalid, a subject property is absent from the document or schema, any release fails,
|
|
108
|
+
* or a response is malformed, incomplete, or conflicts with another property's group. No partial result is returned.
|
|
109
|
+
* @remarks Kernel per-property decryption failures that return HasError: false cannot be detected:
|
|
110
|
+
* https://github.com/Cratis/Chronicle/issues/4480.
|
|
111
|
+
*/
|
|
112
|
+
releaseDocument<TReadModel>(readModelType: Constructor<TReadModel>, document: Readonly<Record<string, unknown>>): Promise<Record<string, unknown>>;
|
|
98
113
|
}
|
package/readModels/ReadModels.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { hasFromEventMetadata } from '../projections/modelBound/fromEvent.js';
|
|
|
19
19
|
import { hasModelBoundProperties } from '../types/TypeDiscoverer.js';
|
|
20
20
|
import { isPassive } from '../projections/modelBound/passive.js';
|
|
21
21
|
import { getReducerMetadata } from '../reducers/reducer.js';
|
|
22
|
-
import { JsonSchemaGenerator } from '../schemas/index.js';
|
|
22
|
+
import { JsonSchemaGenerator, type JsonSchema } from '../schemas/index.js';
|
|
23
23
|
import { WellKnownSinks } from '../sinks/index.js';
|
|
24
24
|
import { getReadModelMetadata, getReadModelId } from './readModel.js';
|
|
25
25
|
import { buildReadModelDefinition } from './buildReadModelDefinition.js';
|
|
@@ -238,6 +238,123 @@ export class ReadModels implements IReadModels {
|
|
|
238
238
|
return Promise.all(releasePromises);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** @inheritdoc */
|
|
242
|
+
async releaseDocument<TReadModel>(readModelType: Constructor<TReadModel>, document: Readonly<Record<string, unknown>>): Promise<Record<string, unknown>> {
|
|
243
|
+
const readModel = this.resolveReadModel(readModelType);
|
|
244
|
+
const schema = JSON.parse(this.getReadModelSchema(readModelType, readModel.identifier)) as JsonSchema;
|
|
245
|
+
const properties = schema.properties ?? {};
|
|
246
|
+
const stored = this.copyStoredDocument(document);
|
|
247
|
+
const storedDefaultSubject = stored.__subject;
|
|
248
|
+
const defaultSubject = this.isDocumentSubject(storedDefaultSubject) ? storedDefaultSubject : undefined;
|
|
249
|
+
const storedSubjects = this.isDocumentObject(stored.__subjects) ? stored.__subjects : {};
|
|
250
|
+
const subjects = new Map<string, string>();
|
|
251
|
+
|
|
252
|
+
if (storedDefaultSubject !== undefined && storedDefaultSubject !== null && typeof storedDefaultSubject !== 'string') {
|
|
253
|
+
throw new Error('Stored read model document has an invalid default subject.');
|
|
254
|
+
}
|
|
255
|
+
for (const [property, subject] of Object.entries(storedSubjects)) {
|
|
256
|
+
if (property === '__subject' || property === '__subjects') {
|
|
257
|
+
throw new Error('Stored read model document has an unknown subject property.');
|
|
258
|
+
}
|
|
259
|
+
// The kernel's JSON subject map ignores null/empty strings, but rejects non-string values.
|
|
260
|
+
if (subject !== null && typeof subject !== 'string') {
|
|
261
|
+
throw new Error('Stored read model document has an invalid property subject.');
|
|
262
|
+
}
|
|
263
|
+
// Cleared or removed properties can leave stale mappings in stored documents.
|
|
264
|
+
if (!Object.hasOwn(stored, property)) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!Object.hasOwn(properties, property)) {
|
|
268
|
+
throw new Error('Stored read model document has an unknown subject property.');
|
|
269
|
+
}
|
|
270
|
+
if (this.isDocumentSubject(subject)) {
|
|
271
|
+
subjects.set(property, subject);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const groups = new Map<string, string[]>();
|
|
276
|
+
const releasedEntries: [string, unknown][] = [];
|
|
277
|
+
for (const [property, value] of Object.entries(stored)) {
|
|
278
|
+
// Only schema-declared data is returned, never storage bookkeeping.
|
|
279
|
+
if (property === '__subject' || property === '__subjects' || !Object.hasOwn(properties, property)) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const subject = subjects.get(property) ?? defaultSubject;
|
|
283
|
+
if (subject === undefined) {
|
|
284
|
+
releasedEntries.push([property, value]);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const group = groups.get(subject) ?? [];
|
|
288
|
+
group.push(property);
|
|
289
|
+
groups.set(subject, group);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const [subject, propertyNames] of groups) {
|
|
293
|
+
const payload = Object.fromEntries(propertyNames.map(property => [property, stored[property]]));
|
|
294
|
+
const groupSchema: JsonSchema = {
|
|
295
|
+
...schema,
|
|
296
|
+
properties: Object.fromEntries(propertyNames.map(property => [property, properties[property]])),
|
|
297
|
+
required: schema.required?.filter(property => propertyNames.includes(property))
|
|
298
|
+
};
|
|
299
|
+
const released = await this.releaseDocumentProperties(subject, groupSchema, payload);
|
|
300
|
+
const releasedProperties = Object.keys(released);
|
|
301
|
+
if (releasedProperties.length !== propertyNames.length ||
|
|
302
|
+
releasedProperties.some(property => !Object.hasOwn(payload, property))) {
|
|
303
|
+
throw new Error('Failed to release stored read model document: missing or conflicting properties.');
|
|
304
|
+
}
|
|
305
|
+
releasedEntries.push(...Object.entries(released));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Publish only after every subject succeeded. Entire top-level subtrees belong to one group.
|
|
309
|
+
return Object.fromEntries(releasedEntries);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async releaseDocumentProperties(subject: string, schema: JsonSchema, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
313
|
+
try {
|
|
314
|
+
const response = await this._connection.compliance.release({
|
|
315
|
+
EventStore: this._eventStore,
|
|
316
|
+
Namespace: this._namespace,
|
|
317
|
+
Subject: subject,
|
|
318
|
+
Schema: JSON.stringify(schema),
|
|
319
|
+
Payload: JSON.stringify(payload)
|
|
320
|
+
});
|
|
321
|
+
if (response.HasError !== false || typeof response.Payload !== 'string') {
|
|
322
|
+
throw new Error();
|
|
323
|
+
}
|
|
324
|
+
const released: unknown = JSON.parse(response.Payload);
|
|
325
|
+
if (!this.isDocumentObject(released)) {
|
|
326
|
+
throw new Error();
|
|
327
|
+
}
|
|
328
|
+
return released;
|
|
329
|
+
} catch {
|
|
330
|
+
// Kernel, transport and JSON parser errors can contain PII; do not expose them as causes.
|
|
331
|
+
throw new Error('Failed to release PII in stored read model document.');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private copyStoredDocument(document: Readonly<Record<string, unknown>>): Record<string, unknown> {
|
|
336
|
+
try {
|
|
337
|
+
if (!this.isDocumentObject(document)) {
|
|
338
|
+
throw new Error();
|
|
339
|
+
}
|
|
340
|
+
const copy: unknown = JSON.parse(JSON.stringify(document));
|
|
341
|
+
if (!this.isDocumentObject(copy)) {
|
|
342
|
+
throw new Error();
|
|
343
|
+
}
|
|
344
|
+
return copy;
|
|
345
|
+
} catch {
|
|
346
|
+
throw new Error('Stored read model document must be a JSON object.');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private isDocumentObject(value: unknown): value is Record<string, unknown> {
|
|
351
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private isDocumentSubject(value: unknown): value is string {
|
|
355
|
+
return typeof value === 'string' && value.length > 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
241
358
|
private async releaseSnapshotInstances<TReadModel>(readModelType: Constructor<TReadModel>, snapshots: ReadModelSnapshot<TReadModel>[]): Promise<ReadModelSnapshot<TReadModel>[]> {
|
|
242
359
|
return Promise.all(
|
|
243
360
|
snapshots.map(async snapshot => ({
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { field } from '@cratis/fundamentals';
|
|
6
|
+
import type { ReleaseRequest, ReleaseResponse } from '@cratis/chronicle.contracts';
|
|
7
|
+
import { vi } from 'vitest';
|
|
8
|
+
import type { IClientArtifactsProvider } from '../../../artifacts/index.js';
|
|
9
|
+
import type { ChronicleConnection } from '../../../connection/index.js';
|
|
10
|
+
import { pii } from '../../../compliance/pii.js';
|
|
11
|
+
import { subject } from '../../../compliance/subject.js';
|
|
12
|
+
import { fromEvent } from '../../../projections/modelBound/fromEvent.js';
|
|
13
|
+
import type { IReadModels } from '../../IReadModels.js';
|
|
14
|
+
import { ReadModels } from '../../ReadModels.js';
|
|
15
|
+
|
|
16
|
+
class AccountOpened {}
|
|
17
|
+
|
|
18
|
+
export class ContactDetails {
|
|
19
|
+
@field(String)
|
|
20
|
+
@pii()
|
|
21
|
+
phone!: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@fromEvent(AccountOpened)
|
|
25
|
+
export class AccountDocument {
|
|
26
|
+
@field(String)
|
|
27
|
+
@subject()
|
|
28
|
+
id!: string;
|
|
29
|
+
|
|
30
|
+
@field(String)
|
|
31
|
+
@pii()
|
|
32
|
+
name!: string;
|
|
33
|
+
|
|
34
|
+
@field(String)
|
|
35
|
+
@pii()
|
|
36
|
+
email!: string;
|
|
37
|
+
|
|
38
|
+
@field(ContactDetails)
|
|
39
|
+
contact!: ContactDetails;
|
|
40
|
+
|
|
41
|
+
@field(Array, { genericArguments: [ContactDetails] })
|
|
42
|
+
contacts!: ContactDetails[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class a_stored_read_model {
|
|
46
|
+
readonly release = vi.fn<(request: ReleaseRequest) => Promise<ReleaseResponse>>()
|
|
47
|
+
.mockImplementation(async request => ({ HasError: false, Error: '', Payload: request.Payload }));
|
|
48
|
+
readonly readModels: IReadModels;
|
|
49
|
+
|
|
50
|
+
constructor() {
|
|
51
|
+
const connection = { compliance: { release: this.release } } as unknown as ChronicleConnection;
|
|
52
|
+
const artifacts: IClientArtifactsProvider = {
|
|
53
|
+
eventTypes: [], readModels: [AccountDocument], projections: [], reducers: [], reactors: [],
|
|
54
|
+
seeders: [], constraints: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
|
|
55
|
+
};
|
|
56
|
+
this.readModels = new ReadModels('store', 'tenant', connection, artifacts, 'sink');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
requestFor(subject: string): ReleaseRequest {
|
|
60
|
+
return this.release.mock.calls.map(([request]) => request).find(request => request.Subject === subject)!;
|
|
61
|
+
}
|
|
62
|
+
}
|