@cratis/chronicle 6.38.0 → 6.40.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 +2 -3
- package/ChronicleOptions.ts +5 -1
- package/EventStore.ts +1 -2
- package/WellKnownTelemetryNames.ts +21 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +2 -3
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +1 -1
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +5 -1
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +1 -2
- package/dist/EventStore.js.map +1 -1
- package/dist/WellKnownTelemetryNames.d.ts +21 -0
- package/dist/WellKnownTelemetryNames.d.ts.map +1 -1
- package/dist/WellKnownTelemetryNames.js +21 -0
- package/dist/WellKnownTelemetryNames.js.map +1 -1
- package/dist/eventSequences/EventSequence.js +9 -9
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts +3 -1
- package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -1
- package/dist/telemetry/observeOperation.d.ts +3 -1
- package/dist/telemetry/observeOperation.d.ts.map +1 -1
- package/dist/telemetry/observeOperation.js +3 -1
- package/dist/telemetry/observeOperation.js.map +1 -1
- package/dist/testing/EventFieldSchema.d.ts +6 -0
- package/dist/testing/EventFieldSchema.d.ts.map +1 -0
- package/dist/testing/EventFieldSchema.js +78 -0
- package/dist/testing/EventFieldSchema.js.map +1 -0
- package/dist/testing/EventScenario.d.ts.map +1 -1
- package/dist/testing/EventScenario.js +17 -7
- package/dist/testing/EventScenario.js.map +1 -1
- package/dist/testing/InProcessConstraints.d.ts +3 -1
- package/dist/testing/InProcessConstraints.d.ts.map +1 -1
- package/dist/testing/InProcessConstraints.js +20 -9
- package/dist/testing/InProcessConstraints.js.map +1 -1
- package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
- package/dist/testing/InProcessEventSequence.js +9 -10
- package/dist/testing/InProcessEventSequence.js.map +1 -1
- package/dist/testing/ReadModelScenario.js +1 -1
- package/dist/testing/ReadModelScenario.js.map +1 -1
- package/dist/testing/ScenarioEvent.d.ts +1 -0
- package/dist/testing/ScenarioEvent.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +7 -0
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +9 -9
- package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
- package/for_ChronicleOptions/when_configuring_telemetry/with_span_names.spec.ts +32 -0
- package/package.json +2 -2
- package/telemetry/ChronicleTelemetryOptions.ts +4 -1
- package/telemetry/for_ChronicleTelemetry/given/a_client.fixture.ts +47 -0
- package/telemetry/for_ChronicleTelemetry/given/a_span_name_registry.fixture.ts +33 -0
- package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts +44 -0
- package/telemetry/for_ChronicleTelemetry/when_naming_spans/with_per_client_modes.spec.ts +138 -0
- package/telemetry/for_ChronicleTelemetry/when_recording_product_attributes/with_both_naming_modes.spec.ts +52 -0
- package/telemetry/observeOperation.ts +9 -1
- package/testing/EventFieldSchema.ts +71 -0
- package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
- package/testing/EventScenario.constraints.spec.ts +4 -2
- package/testing/EventScenario.fixtures.spec.ts +5 -5
- package/testing/EventScenario.ts +18 -7
- package/testing/InProcessConstraints.ts +19 -9
- package/testing/InProcessEventSequence.ts +9 -10
- package/testing/ReadModelScenario.ts +1 -1
- package/testing/ScenarioEvent.ts +1 -1
- package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
- package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
- package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
- package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
- package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
- package/testing/projections/ProjectionReadModelProcessor.ts +10 -0
|
@@ -80,7 +80,7 @@ export class EventSequence implements IEventSequence {
|
|
|
80
80
|
'chronicle.event_type_id': eventType.id.value
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
return observeOperation(
|
|
83
|
+
return observeOperation('append', this._telemetry, async span => {
|
|
84
84
|
this.setSequenceAttributes(span);
|
|
85
85
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
86
86
|
setTelemetryAttribute(span, 'eventTypeId', eventType.id.value);
|
|
@@ -183,7 +183,7 @@ export class EventSequence implements IEventSequence {
|
|
|
183
183
|
'chronicle.events_count': eventsForEventSourceIds.length
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
-
return observeOperation(
|
|
186
|
+
return observeOperation('appendMany', this._telemetry, async span => {
|
|
187
187
|
this.setSequenceAttributes(span);
|
|
188
188
|
if (distinctEventSourceIds.length === 1) {
|
|
189
189
|
setEventSourceId(span, distinctEventSourceIds[0], this._telemetry);
|
|
@@ -299,7 +299,7 @@ export class EventSequence implements IEventSequence {
|
|
|
299
299
|
eventStreamId?: string,
|
|
300
300
|
filterEventTypes?: Constructor[]
|
|
301
301
|
): Promise<EventSequenceNumber> {
|
|
302
|
-
return observeOperation(
|
|
302
|
+
return observeOperation('getTailSequenceNumber', this._telemetry, async span => {
|
|
303
303
|
this.setSequenceAttributes(span);
|
|
304
304
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
305
305
|
try {
|
|
@@ -339,7 +339,7 @@ export class EventSequence implements IEventSequence {
|
|
|
339
339
|
|
|
340
340
|
/** @inheritdoc */
|
|
341
341
|
async hasEventsFor(eventSourceId: string): Promise<boolean> {
|
|
342
|
-
return observeOperation(
|
|
342
|
+
return observeOperation('hasEventsFor', this._telemetry, async span => {
|
|
343
343
|
this.setSequenceAttributes(span);
|
|
344
344
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
345
345
|
try {
|
|
@@ -371,7 +371,7 @@ export class EventSequence implements IEventSequence {
|
|
|
371
371
|
eventStreamId?: string,
|
|
372
372
|
eventSourceType?: string
|
|
373
373
|
): Promise<AppendedEvent[]> {
|
|
374
|
-
return observeOperation(
|
|
374
|
+
return observeOperation('getForEventSourceIdAndEventTypes', this._telemetry, async span => {
|
|
375
375
|
this.setSequenceAttributes(span);
|
|
376
376
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
377
377
|
try {
|
|
@@ -406,7 +406,7 @@ export class EventSequence implements IEventSequence {
|
|
|
406
406
|
eventSourceId?: string,
|
|
407
407
|
filterEventTypes?: Constructor[]
|
|
408
408
|
): Promise<AppendedEvent[]> {
|
|
409
|
-
return observeOperation(
|
|
409
|
+
return observeOperation('getFromSequenceNumber', this._telemetry, async span => {
|
|
410
410
|
this.setSequenceAttributes(span);
|
|
411
411
|
setSequenceNumber(span, sequenceNumber.value);
|
|
412
412
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
@@ -438,7 +438,7 @@ export class EventSequence implements IEventSequence {
|
|
|
438
438
|
const causationChain = causationManager.getCurrentChain();
|
|
439
439
|
const identity = identityProvider.getCurrent();
|
|
440
440
|
|
|
441
|
-
return observeOperation(
|
|
441
|
+
return observeOperation('redact', this._telemetry, async span => {
|
|
442
442
|
this.setSequenceAttributes(span);
|
|
443
443
|
setSequenceNumber(span, sequenceNumber.value);
|
|
444
444
|
try {
|
|
@@ -472,7 +472,7 @@ export class EventSequence implements IEventSequence {
|
|
|
472
472
|
const identity = identityProvider.getCurrent();
|
|
473
473
|
const wireEventTypeIds = (eventTypes ?? []).map(constructor => getEventTypeFor(constructor as unknown as Function).id.value);
|
|
474
474
|
|
|
475
|
-
return observeOperation(
|
|
475
|
+
return observeOperation('redactForEventSource', this._telemetry, async span => {
|
|
476
476
|
this.setSequenceAttributes(span);
|
|
477
477
|
setEventSourceId(span, eventSourceId, this._telemetry);
|
|
478
478
|
try {
|
|
@@ -518,7 +518,7 @@ export class EventSequence implements IEventSequence {
|
|
|
518
518
|
|
|
519
519
|
/** @inheritdoc */
|
|
520
520
|
async completeStream(eventStreamType: string, eventStreamId: string): Promise<CompleteStreamResult> {
|
|
521
|
-
return observeOperation(
|
|
521
|
+
return observeOperation('completeStream', this._telemetry, async span => {
|
|
522
522
|
this.setSequenceAttributes(span);
|
|
523
523
|
setTelemetryAttribute(span, 'eventStreamType', eventStreamType);
|
|
524
524
|
setTelemetryAttribute(span, 'eventStreamId', eventStreamId);
|
|
@@ -102,6 +102,7 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying an older g
|
|
|
102
102
|
const eventStoreName = `Migration${randomUUID().replaceAll('-', '').slice(0, 12)}`;
|
|
103
103
|
const eventSourceId = randomUUID();
|
|
104
104
|
let firstGenerationClient: ChronicleClient;
|
|
105
|
+
let migrationClient: ChronicleClient;
|
|
105
106
|
let secondGenerationClient: ChronicleClient;
|
|
106
107
|
let appendedSequenceNumber: bigint;
|
|
107
108
|
let event: AppendedEvent;
|
|
@@ -113,11 +114,33 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying an older g
|
|
|
113
114
|
// An application that only knows the first generation appends the event.
|
|
114
115
|
firstGenerationClient = clientWith(artifactsWith({ eventTypes: [AuthorRegisteredV1] }));
|
|
115
116
|
const firstGenerationStore = await firstGenerationClient.getEventStore(eventStoreName);
|
|
117
|
+
// Keep a backlog so replay can overtake the asynchronous migration if the readiness barrier is removed.
|
|
118
|
+
await firstGenerationStore.eventLog.appendMany(Array.from({ length: 500 }, () => ({
|
|
119
|
+
eventSourceId: randomUUID(),
|
|
120
|
+
event: Object.assign(new AuthorRegisteredV1(), { name: 'Earlier Author' })
|
|
121
|
+
})));
|
|
116
122
|
const appended = await firstGenerationStore.eventLog.append(eventSourceId, Object.assign(new AuthorRegisteredV1(), { name: 'Jane Doe' }));
|
|
117
123
|
appendedSequenceNumber = appended.sequenceNumber.value;
|
|
124
|
+
// The backlog is the regression setup; without it the race this spec guards against cannot occur.
|
|
125
|
+
if (appendedSequenceNumber < 500n) throw new Error(`Expected the 500-event backlog before the target event, but it was appended at ${appendedSequenceNumber}.`);
|
|
118
126
|
firstGenerationClient.dispose();
|
|
119
127
|
|
|
120
|
-
//
|
|
128
|
+
// Registration starts a background migration job; it does not mean stored history has been migrated.
|
|
129
|
+
// Wait for the target's second-generation content before any observer can replay the old payload.
|
|
130
|
+
migrationClient = clientWith(artifactsWith({
|
|
131
|
+
eventTypes: [AuthorRegisteredV1, AuthorRegistered],
|
|
132
|
+
eventTypeMigrations: [AuthorRegisteredMigration]
|
|
133
|
+
}));
|
|
134
|
+
const migrationStore = await migrationClient.getEventStore(eventStoreName);
|
|
135
|
+
event = await eventually(
|
|
136
|
+
async () => (await migrationStore.eventLog.getForEventSourceIdAndEventTypes(eventSourceId, [AuthorRegistered]))[0],
|
|
137
|
+
_ => {
|
|
138
|
+
const content = _?.content as AuthorRegistered | undefined;
|
|
139
|
+
return content?.firstName === 'Jane' && content.lastName === 'Doe';
|
|
140
|
+
});
|
|
141
|
+
migrationClient.dispose();
|
|
142
|
+
|
|
143
|
+
// A later application replays the migrated history through a projection and a reactor.
|
|
121
144
|
secondGenerationClient = clientWith(artifactsWith({
|
|
122
145
|
eventTypes: [AuthorRegisteredV1, AuthorRegistered],
|
|
123
146
|
eventTypeMigrations: [AuthorRegisteredMigration],
|
|
@@ -126,10 +149,7 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying an older g
|
|
|
126
149
|
}));
|
|
127
150
|
const store: IEventStore = await secondGenerationClient.getEventStore(eventStoreName);
|
|
128
151
|
|
|
129
|
-
|
|
130
|
-
async () => (await store.eventLog.getForEventSourceIdAndEventTypes(eventSourceId, [AuthorRegistered]))[0],
|
|
131
|
-
_ => (_?.content as AuthorRegistered | undefined)?.firstName === 'Jane');
|
|
132
|
-
author = await eventually(() => store.readModels.findInstanceById(Author, eventSourceId), _ => _ !== null && _.firstName !== '');
|
|
152
|
+
author = await eventually(() => store.readModels.findInstanceById(Author, eventSourceId), _ => _ !== null);
|
|
133
153
|
observation = await eventually(async () => observed.find(_ => _.context.eventSourceId === eventSourceId)!, _ => _ !== undefined);
|
|
134
154
|
|
|
135
155
|
// Registering a second generation without a migration from the first is rejected.
|
|
@@ -140,6 +160,7 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying an older g
|
|
|
140
160
|
|
|
141
161
|
afterAll(() => {
|
|
142
162
|
firstGenerationClient?.dispose();
|
|
163
|
+
migrationClient?.dispose();
|
|
143
164
|
secondGenerationClient?.dispose();
|
|
144
165
|
});
|
|
145
166
|
|
|
@@ -147,10 +168,10 @@ describe.skipIf(!connectionString && !process.env.CI)('when replaying an older g
|
|
|
147
168
|
it('should read the last name split from the first generation', () => (event.content as AuthorRegistered).lastName.should.equal('Doe'));
|
|
148
169
|
it('should keep the event source of the appended event', () => event.context.eventSourceId.should.equal(eventSourceId));
|
|
149
170
|
it('should keep the sequence number of the appended event', () => event.context.sequenceNumber.should.equal(appendedSequenceNumber));
|
|
150
|
-
it('should project the migrated first name', () => author!.
|
|
151
|
-
it('should project the migrated last name', () => author!.
|
|
152
|
-
it('should replay the migrated first name to the reactor', () => observation.event.
|
|
153
|
-
it('should replay the migrated last name to the reactor', () => observation.event.
|
|
171
|
+
it('should project the migrated first name', () => author!.should.have.property('firstName', 'Jane'));
|
|
172
|
+
it('should project the migrated last name', () => author!.should.have.property('lastName', 'Doe'));
|
|
173
|
+
it('should replay the migrated first name to the reactor', () => observation.event.should.have.property('firstName', 'Jane'));
|
|
174
|
+
it('should replay the migrated last name to the reactor', () => observation.event.should.have.property('lastName', 'Doe'));
|
|
154
175
|
it('should replay the event source to the reactor', () => observation.context.eventSourceId.should.equal(eventSourceId));
|
|
155
176
|
it('should reject a second generation that has no migration', () => missingMigrationError.should.be.instanceOf(Error));
|
|
156
177
|
});
|
|
@@ -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 { chai, describe, it } from 'vitest';
|
|
5
|
+
import { ChronicleOptions } from '../../ChronicleOptions.js';
|
|
6
|
+
import type { ChronicleTelemetryOptions } from '../../telemetry/ChronicleTelemetryOptions.js';
|
|
7
|
+
|
|
8
|
+
chai.should();
|
|
9
|
+
|
|
10
|
+
for (const spanNames of ['unknown', '', null, false, 0, {}, [], ['legacy']]) {
|
|
11
|
+
describe(`when configuring unsupported span names ${JSON.stringify(spanNames)}`, () => {
|
|
12
|
+
const telemetry = { spanNames } as unknown as ChronicleTelemetryOptions;
|
|
13
|
+
it('should reject the development options at construction', () => {
|
|
14
|
+
(() => ChronicleOptions.development({ telemetry })).should.throw(TypeError, 'telemetry.spanNames must be legacy or convention.');
|
|
15
|
+
});
|
|
16
|
+
it('should reject the connection string options at construction', () => {
|
|
17
|
+
(() => ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { telemetry })).should.throw(TypeError, 'telemetry.spanNames must be legacy or convention.');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const spanNames of [undefined, 'legacy', 'convention'] as const) {
|
|
23
|
+
describe(`when configuring supported span names ${spanNames ?? 'by default'}`, () => {
|
|
24
|
+
it('should preserve the choice with the event source identifier policy', () => {
|
|
25
|
+
const telemetry = { spanNames, eventSourceId: { mode: 'raw' as const } };
|
|
26
|
+
const development = ChronicleOptions.development({ telemetry });
|
|
27
|
+
const connection = ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { telemetry });
|
|
28
|
+
development.telemetry!.should.deep.equal(telemetry);
|
|
29
|
+
connection.telemetry!.should.deep.equal(telemetry);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/chronicle",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.40.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",
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
"clean": "rm -rf dist",
|
|
174
174
|
"test": "vitest run",
|
|
175
175
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
176
|
-
"test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts events/for_handles/when_decorating_a_method.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts",
|
|
176
|
+
"test:standard-types": "tsc --ignoreConfig --noEmit --target es2022 --module es2022 --moduleResolution bundler --lib es2022,esnext.decorators --types node --skipLibCheck --strict schemas/decorators.standard.spec.ts projections/declarative/addChildCompatibility.standard.spec.ts projections/modelBound/decorators.standard.spec.ts events/classDecorators.standard.spec.ts reactors/decorators.standard.spec.ts readModels/standardFallbacks.standard.spec.ts readModels/registration.standard.spec.ts for_ChronicleClient/when_registering_artifacts/with_standard_property_mappings.standard.spec.ts artifacts/validateArtifactSchemas.spec.ts testing/for_EventScenario/when_appending_concept_fields/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_scoped_constraints/with_standard_decorators.standard.spec.ts schemas/for_JsonSchemaGenerator/when_generating_children/with_standard_decorators.standard.spec.ts events/for_handles/when_decorating_a_method.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts testing/for_ReadModelScenario/when_evaluating_joins/with_standard_decorators.standard.spec.ts testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts",
|
|
177
177
|
"test:native": "node ./scripts/check-native-esm.mjs"
|
|
178
178
|
},
|
|
179
179
|
"dependencies": {
|
|
@@ -1,8 +1,11 @@
|
|
|
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
|
-
/** Per-client privacy settings; instrumentation itself is always available. */
|
|
4
|
+
/** Per-client naming and privacy settings; instrumentation itself is always available. */
|
|
5
5
|
export interface ChronicleTelemetryOptions {
|
|
6
|
+
/** Span naming convention. Defaults to legacy; both modes use the same scope and attribute families. */
|
|
7
|
+
spanNames?: 'legacy' | 'convention';
|
|
8
|
+
|
|
6
9
|
/** Absent means no event source identifiers. HMAC uses SHA-256 and a deployment-owned key. */
|
|
7
10
|
eventSourceId?: { mode: 'raw' } | { mode: 'hmac'; key: Uint8Array };
|
|
8
11
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { vi } from 'vitest';
|
|
5
|
+
import { ChronicleClient } from '../../../ChronicleClient.js';
|
|
6
|
+
import { ChronicleOptions } from '../../../ChronicleOptions.js';
|
|
7
|
+
import type { IClientArtifactsProvider } from '../../../artifacts/IClientArtifactsProvider.js';
|
|
8
|
+
import type { ChronicleTelemetryOptions } from '../../ChronicleTelemetryOptions.js';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../connection/ChronicleConnection.js', () => ({
|
|
11
|
+
ChronicleConnection: class {
|
|
12
|
+
async resetChannel() {}
|
|
13
|
+
async connect() {}
|
|
14
|
+
disconnect() {}
|
|
15
|
+
server = { getVersionInfo: async () => ({}) };
|
|
16
|
+
eventStores = {
|
|
17
|
+
ensureEventStore: async () => ({}),
|
|
18
|
+
allEventStores: async () => ({ IsAuthorized: true, Data: [{ Name: 'store' }] })
|
|
19
|
+
};
|
|
20
|
+
namespaces = { allNamespaces: async () => ({ IsAuthorized: true, Data: [{ Name: 'namespace' }] }) };
|
|
21
|
+
readModels = { registerMany: async () => ({}) };
|
|
22
|
+
eventSequences = {
|
|
23
|
+
append: async () => ({ Response: { SequenceNumber: 42n } }),
|
|
24
|
+
appendManyForEventSources: async () => ({ Response: { SequenceNumbers: [42n, 43n] } }),
|
|
25
|
+
tailSequenceNumber: async () => ({ IsAuthorized: true, Data: { SequenceNumber: 42n } }),
|
|
26
|
+
hasEventsForEventSourceId: async (request: { EventSourceId: string }) => ({ IsAuthorized: true, Data: { HasEvents: request.EventSourceId !== 'empty' } }),
|
|
27
|
+
forEventSourceIdAndEventTypes: async () => ({ IsAuthorized: true, Data: [] }),
|
|
28
|
+
fromSequenceNumber: async () => ({ IsAuthorized: true, Data: [] }),
|
|
29
|
+
redact: async () => ({}),
|
|
30
|
+
redactForEventSource: async () => ({}),
|
|
31
|
+
completeStream: async () => ({ Response: { IsSuccess: true, SequenceNumber: 42n } })
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('../../../connection/KernelKeepAlive.js', () => ({
|
|
36
|
+
KernelKeepAlive: class { async start() {} }
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
export function client(telemetry?: ChronicleTelemetryOptions) {
|
|
40
|
+
const artifacts: IClientArtifactsProvider = {
|
|
41
|
+
eventTypes: [], readModels: [], reactors: [], reducers: [], seeders: [], constraints: [],
|
|
42
|
+
projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: []
|
|
43
|
+
};
|
|
44
|
+
return new ChronicleClient(ChronicleOptions.development({
|
|
45
|
+
discoveryPatterns: [], clientArtifactsProvider: artifacts, telemetry
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** Literal compatibility contract, independent of the production name selection. */
|
|
5
|
+
export const legacySpans = {
|
|
6
|
+
append: 'chronicle.event_sequences.append',
|
|
7
|
+
appendMany: 'chronicle.event_sequences.append_many',
|
|
8
|
+
getTailSequenceNumber: 'chronicle.event_sequences.get_tail_sequence_number',
|
|
9
|
+
hasEventsFor: 'chronicle.event_sequences.has_events_for',
|
|
10
|
+
getForEventSourceIdAndEventTypes: 'chronicle.event_sequences.get_for_event_source_id_and_event_types',
|
|
11
|
+
getFromSequenceNumber: 'chronicle.event_sequences.get_from_sequence_number',
|
|
12
|
+
redact: 'chronicle.event_sequences.redact',
|
|
13
|
+
redactForEventSource: 'chronicle.event_sequences.redact_for_event_source',
|
|
14
|
+
completeStream: 'chronicle.event_sequences.complete_stream',
|
|
15
|
+
getEventStore: 'chronicle.client.get_event_store',
|
|
16
|
+
getEventStores: 'chronicle.client.get_event_stores',
|
|
17
|
+
getNamespaces: 'chronicle.event_store.get_namespaces'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const conventionSpans = {
|
|
21
|
+
append: 'cratis.chronicle.client.event_sequence.append',
|
|
22
|
+
appendMany: 'cratis.chronicle.client.event_sequence.append_many',
|
|
23
|
+
getTailSequenceNumber: 'cratis.chronicle.client.event_sequence.get_tail_sequence_number',
|
|
24
|
+
hasEventsFor: 'cratis.chronicle.client.event_sequence.has_events_for',
|
|
25
|
+
getForEventSourceIdAndEventTypes: 'cratis.chronicle.client.event_sequence.get_for_event_source_id_and_event_types',
|
|
26
|
+
getFromSequenceNumber: 'cratis.chronicle.client.event_sequence.get_from_sequence_number',
|
|
27
|
+
redact: 'cratis.chronicle.client.event_sequence.redact',
|
|
28
|
+
redactForEventSource: 'cratis.chronicle.client.event_sequence.redact_for_event_source',
|
|
29
|
+
completeStream: 'cratis.chronicle.client.event_sequence.complete_stream',
|
|
30
|
+
getEventStore: 'cratis.chronicle.client.event_store.get',
|
|
31
|
+
getEventStores: 'cratis.chronicle.client.event_store.list',
|
|
32
|
+
getNamespaces: 'cratis.chronicle.client.event_store.get_namespaces'
|
|
33
|
+
};
|
package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { chai, describe, it } from 'vitest';
|
|
5
|
+
import { WellKnownTelemetryNames as names } from '../../../WellKnownTelemetryNames.js';
|
|
6
|
+
import { legacySpans, conventionSpans } from '../given/a_span_name_registry.fixture.js';
|
|
7
|
+
|
|
8
|
+
chai.should();
|
|
9
|
+
describe('when exposing names during the migration overlap', () => {
|
|
10
|
+
it('should preserve every legacy span constant', () => {
|
|
11
|
+
names.spans.should.deep.equal(legacySpans);
|
|
12
|
+
});
|
|
13
|
+
it('should expose a matching convention span for every operation', () => {
|
|
14
|
+
names.conventionSpans.should.deep.equal(conventionSpans);
|
|
15
|
+
Object.keys(names.conventionSpans).should.deep.equal(Object.keys(names.spans));
|
|
16
|
+
});
|
|
17
|
+
it('should preserve the current and historical scopes', () => {
|
|
18
|
+
names.scope.should.equal('Cratis.Chronicle.Client');
|
|
19
|
+
names.legacyScope.should.equal('@cratis/chronicle');
|
|
20
|
+
});
|
|
21
|
+
it('should preserve every legacy attribute constant', () => {
|
|
22
|
+
names.legacyAttributes.should.deep.equal({
|
|
23
|
+
eventStore: 'chronicle.event_store', namespace: 'chronicle.namespace',
|
|
24
|
+
eventSequenceId: 'chronicle.event_sequence_id', sequenceNumber: 'chronicle.sequence_number',
|
|
25
|
+
eventTypeId: 'chronicle.event_type_id', eventTypeGeneration: 'chronicle.event_type_generation',
|
|
26
|
+
eventSourceId: 'chronicle.event_source_id', eventCount: 'chronicle.events_count',
|
|
27
|
+
hasEvents: 'chronicle.has_events', eventStreamType: 'chronicle.event_stream_type', eventStreamId: 'chronicle.event_stream_id'
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it('should preserve every legacy metric constant', () => {
|
|
31
|
+
names.legacyMetrics.should.deep.equal({
|
|
32
|
+
eventsAppended: 'chronicle.events.appended', batchAppendsPerformed: 'chronicle.events.batch_appends',
|
|
33
|
+
eventStoreRetrievals: 'chronicle.client.event_store_retrievals', appendDuration: 'chronicle.events.append_duration',
|
|
34
|
+
appendManyDuration: 'chronicle.events.append_many_duration', constraintViolations: 'chronicle.events.constraint_violations',
|
|
35
|
+
appendErrors: 'chronicle.events.append_errors'
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it('should expose the product-only attributes alongside the shared registry', () => {
|
|
39
|
+
names.attributes.should.include({
|
|
40
|
+
hasEvents: 'cratis.chronicle.event_sequence.has_events',
|
|
41
|
+
eventStreamType: 'cratis.chronicle.event_stream.type', eventStreamId: 'cratis.chronicle.event_stream.id'
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { afterEach, beforeEach, chai, describe, it } from 'vitest';
|
|
5
|
+
import { SpanKind } from '@opentelemetry/api';
|
|
6
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
7
|
+
import type { ChronicleClient } from '../../../ChronicleClient.js';
|
|
8
|
+
import type { IEventSequence } from '../../../eventSequences/IEventSequence.js';
|
|
9
|
+
import { EventSequenceId } from '../../../eventSequences/EventSequenceId.js';
|
|
10
|
+
import { EventSequenceNumber } from '../../../eventSequences/EventSequenceNumber.js';
|
|
11
|
+
import { clientVersion } from '../../../connection/clientVersion.js';
|
|
12
|
+
import { client } from '../given/a_client.fixture.js';
|
|
13
|
+
import { Recorded } from '../given/an_event_sequence.fixture.js';
|
|
14
|
+
import { telemetrySession } from '../given/a_telemetry_session.fixture.js';
|
|
15
|
+
import { legacySpans, conventionSpans } from '../given/a_span_name_registry.fixture.js';
|
|
16
|
+
|
|
17
|
+
const should = chai.should();
|
|
18
|
+
const telemetry = telemetrySession();
|
|
19
|
+
const sequenceNumber = new EventSequenceNumber(42n);
|
|
20
|
+
const operations = {
|
|
21
|
+
append: (sequence: IEventSequence) => sequence.append('source', new Recorded()),
|
|
22
|
+
appendMany: (sequence: IEventSequence) => sequence.appendMany('source', [new Recorded(), new Recorded()]),
|
|
23
|
+
getTailSequenceNumber: (sequence: IEventSequence) => sequence.getTailSequenceNumber(),
|
|
24
|
+
hasEventsFor: (sequence: IEventSequence) => sequence.hasEventsFor('source'),
|
|
25
|
+
getForEventSourceIdAndEventTypes: (sequence: IEventSequence) => sequence.getForEventSourceIdAndEventTypes('source', [Recorded]),
|
|
26
|
+
getFromSequenceNumber: (sequence: IEventSequence) => sequence.getFromSequenceNumber(sequenceNumber),
|
|
27
|
+
redact: (sequence: IEventSequence) => sequence.redact(sequenceNumber, 'reason'),
|
|
28
|
+
redactForEventSource: (sequence: IEventSequence) => sequence.redactForEventSource('source', 'reason'),
|
|
29
|
+
completeStream: (sequence: IEventSequence) => sequence.completeStream('stream-type', 'stream-id')
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
for (const spanNames of [undefined, 'legacy', 'convention'] as const) {
|
|
33
|
+
const expected = spanNames === 'convention' ? conventionSpans : legacySpans;
|
|
34
|
+
for (const sequenceId of [EventSequenceId.eventLog, new EventSequenceId('dynamic')]) {
|
|
35
|
+
for (const operation of Object.keys(operations) as (keyof typeof operations)[]) {
|
|
36
|
+
describe(`when naming ${operation} on ${sequenceId.value} with ${spanNames ?? 'default'} names`, () => {
|
|
37
|
+
let instance: ChronicleClient;
|
|
38
|
+
let span: ReadableSpan;
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
instance = client(spanNames === undefined ? undefined : { spanNames });
|
|
41
|
+
const store = await instance.getEventStore('store', 'namespace');
|
|
42
|
+
const sequence = sequenceId === EventSequenceId.eventLog ? store.eventLog : store.getEventSequence(sequenceId);
|
|
43
|
+
telemetry.spans.reset();
|
|
44
|
+
await operations[operation](sequence);
|
|
45
|
+
span = telemetry.spans.getFinishedSpans()[0];
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => instance.dispose());
|
|
48
|
+
it('should emit exactly one client span with the selected name', () => {
|
|
49
|
+
telemetry.spans.getFinishedSpans().should.have.lengthOf(1);
|
|
50
|
+
span.name.should.equal(expected[operation]);
|
|
51
|
+
span.kind.should.equal(SpanKind.CLIENT);
|
|
52
|
+
});
|
|
53
|
+
it('should retain the versioned shared instrumentation scope', () => {
|
|
54
|
+
span.instrumentationScope.name.should.equal('Cratis.Chronicle.Client');
|
|
55
|
+
should.equal(span.instrumentationScope.version, clientVersion);
|
|
56
|
+
});
|
|
57
|
+
it('should retain both attribute families', () => {
|
|
58
|
+
span.attributes.should.include({
|
|
59
|
+
'chronicle.event_store': 'store', 'cratis.event_store.name': 'store',
|
|
60
|
+
'chronicle.namespace': 'namespace', 'cratis.event_store.namespace': 'namespace',
|
|
61
|
+
'chronicle.event_sequence_id': sequenceId.value, 'cratis.event_sequence.id': sequenceId.value
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const operation of ['getEventStore', 'getEventStores', 'getNamespaces'] as const) {
|
|
69
|
+
describe(`when naming ${operation} with ${spanNames ?? 'default'} names`, () => {
|
|
70
|
+
let instance: ChronicleClient;
|
|
71
|
+
let span: ReadableSpan;
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
instance = client(spanNames === undefined ? undefined : { spanNames });
|
|
74
|
+
if (operation === 'getNamespaces') {
|
|
75
|
+
const store = await instance.getEventStore('store', 'namespace');
|
|
76
|
+
telemetry.spans.reset();
|
|
77
|
+
await store.getNamespaces();
|
|
78
|
+
} else if (operation === 'getEventStore') {
|
|
79
|
+
await instance.getEventStore('store', 'namespace');
|
|
80
|
+
} else {
|
|
81
|
+
await instance.getEventStores();
|
|
82
|
+
}
|
|
83
|
+
span = telemetry.spans.getFinishedSpans()[0];
|
|
84
|
+
});
|
|
85
|
+
afterEach(() => instance.dispose());
|
|
86
|
+
it('should emit exactly one client span with the selected name and shared scope', () => {
|
|
87
|
+
telemetry.spans.getFinishedSpans().should.have.lengthOf(1);
|
|
88
|
+
span.name.should.equal(expected[operation]);
|
|
89
|
+
span.kind.should.equal(SpanKind.CLIENT);
|
|
90
|
+
span.instrumentationScope.name.should.equal('Cratis.Chronicle.Client');
|
|
91
|
+
should.equal(span.instrumentationScope.version, clientVersion);
|
|
92
|
+
});
|
|
93
|
+
if (operation !== 'getEventStores') {
|
|
94
|
+
it('should retain both event store attributes', () => {
|
|
95
|
+
span.attributes.should.include({ 'chronicle.event_store': 'store', 'cratis.event_store.name': 'store' });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('when interleaving two clients with different span naming modes', () => {
|
|
103
|
+
let legacy: ChronicleClient;
|
|
104
|
+
let convention: ChronicleClient;
|
|
105
|
+
let spans: ReadableSpan[];
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
legacy = client({ spanNames: 'legacy' });
|
|
108
|
+
convention = client({ spanNames: 'convention' });
|
|
109
|
+
const [legacyStore, conventionStore] = await Promise.all([
|
|
110
|
+
legacy.getEventStore('legacy-store'), convention.getEventStore('convention-store')
|
|
111
|
+
]);
|
|
112
|
+
await Promise.all([convention.getEventStores(), legacy.getEventStores()]);
|
|
113
|
+
await Promise.all([legacyStore.getNamespaces(), conventionStore.getNamespaces()]);
|
|
114
|
+
await conventionStore.eventLog.append('source', new Recorded());
|
|
115
|
+
await legacyStore.eventLog.append('source', new Recorded());
|
|
116
|
+
const id = new EventSequenceId('dynamic');
|
|
117
|
+
await legacyStore.getEventSequence(id).append('source', new Recorded());
|
|
118
|
+
await conventionStore.getEventSequence(id).append('source', new Recorded());
|
|
119
|
+
await legacy.getEventStore('legacy-store');
|
|
120
|
+
await convention.getEventStore('convention-store');
|
|
121
|
+
spans = telemetry.spans.getFinishedSpans();
|
|
122
|
+
});
|
|
123
|
+
afterEach(() => { legacy.dispose(); convention.dispose(); });
|
|
124
|
+
it('should keep each client and its cached stores in its own mode', () => {
|
|
125
|
+
spans.should.have.lengthOf(12);
|
|
126
|
+
spans.filter(span => span.attributes['cratis.event_store.name'] === 'legacy-store').map(span => span.name).should.deep.equal([
|
|
127
|
+
legacySpans.getEventStore, legacySpans.getNamespaces, legacySpans.append, legacySpans.append, legacySpans.getEventStore
|
|
128
|
+
]);
|
|
129
|
+
spans.filter(span => span.attributes['cratis.event_store.name'] === 'convention-store').map(span => span.name).should.deep.equal([
|
|
130
|
+
conventionSpans.getEventStore, conventionSpans.getNamespaces, conventionSpans.append, conventionSpans.append, conventionSpans.getEventStore
|
|
131
|
+
]);
|
|
132
|
+
spans.map(span => span.name).should.include(legacySpans.getEventStores).and.include(conventionSpans.getEventStores);
|
|
133
|
+
});
|
|
134
|
+
it('should use the same tracer scope in both modes', () => {
|
|
135
|
+
new Set(spans.map(span => `${span.instrumentationScope.name}/${span.instrumentationScope.version}`)).should.deep.equal(
|
|
136
|
+
new Set([`Cratis.Chronicle.Client/${clientVersion}`]));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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 { afterEach, beforeEach, chai, describe, it } from 'vitest';
|
|
5
|
+
import type { Attributes } from '@opentelemetry/api';
|
|
6
|
+
import type { ChronicleClient } from '../../../ChronicleClient.js';
|
|
7
|
+
import { client } from '../given/a_client.fixture.js';
|
|
8
|
+
import { telemetrySession } from '../given/a_telemetry_session.fixture.js';
|
|
9
|
+
|
|
10
|
+
chai.should();
|
|
11
|
+
const telemetry = telemetrySession();
|
|
12
|
+
|
|
13
|
+
for (const spanNames of ['legacy', 'convention'] as const) {
|
|
14
|
+
for (const hasEvents of [true, false]) {
|
|
15
|
+
describe(`when recording event presence ${hasEvents} with ${spanNames} span names`, () => {
|
|
16
|
+
let instance: ChronicleClient;
|
|
17
|
+
let attributes: Attributes;
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
instance = client({ spanNames });
|
|
20
|
+
const store = await instance.getEventStore('store');
|
|
21
|
+
telemetry.spans.reset();
|
|
22
|
+
await store.eventLog.hasEventsFor(hasEvents ? 'source' : 'empty');
|
|
23
|
+
attributes = telemetry.spans.getFinishedSpans()[0].attributes;
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => instance.dispose());
|
|
26
|
+
it('should record the boolean under both the legacy and product-only names', () => {
|
|
27
|
+
attributes.should.include({
|
|
28
|
+
'chronicle.has_events': hasEvents,
|
|
29
|
+
'cratis.chronicle.event_sequence.has_events': hasEvents
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
describe(`when completing a stream with ${spanNames} span names`, () => {
|
|
35
|
+
let instance: ChronicleClient;
|
|
36
|
+
let attributes: Attributes;
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
instance = client({ spanNames });
|
|
39
|
+
const store = await instance.getEventStore('store');
|
|
40
|
+
telemetry.spans.reset();
|
|
41
|
+
await store.eventLog.completeStream('orders', 'stream-1');
|
|
42
|
+
attributes = telemetry.spans.getFinishedSpans()[0].attributes;
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => instance.dispose());
|
|
45
|
+
it('should record the stream type under both names', () => {
|
|
46
|
+
attributes.should.include({ 'chronicle.event_stream_type': 'orders', 'cratis.chronicle.event_stream.type': 'orders' });
|
|
47
|
+
});
|
|
48
|
+
it('should record the stream identifier under both names', () => {
|
|
49
|
+
attributes.should.include({ 'chronicle.event_stream_id': 'stream-1', 'cratis.chronicle.event_stream.id': 'stream-1' });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -6,9 +6,17 @@ import { ChronicleTracer } from '../Tracing.js';
|
|
|
6
6
|
import { correlationIdManager } from '../correlation/index.js';
|
|
7
7
|
import { CorrelationId } from '../correlation/CorrelationId.js';
|
|
8
8
|
import { WellKnownTelemetryNames } from '../WellKnownTelemetryNames.js';
|
|
9
|
+
import type { ChronicleTelemetryOptions } from './ChronicleTelemetryOptions.js';
|
|
9
10
|
|
|
10
11
|
/** Opens one client span, scoping the resolved business correlation without generating a new one. */
|
|
11
|
-
export function observeOperation<T>(
|
|
12
|
+
export function observeOperation<T>(
|
|
13
|
+
operation: keyof typeof WellKnownTelemetryNames.conventionSpans,
|
|
14
|
+
telemetry: ChronicleTelemetryOptions | undefined,
|
|
15
|
+
action: (span: Span) => Promise<T>,
|
|
16
|
+
correlationId?: { toString(): string }
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const names = telemetry?.spanNames === 'convention' ? WellKnownTelemetryNames.conventionSpans : WellKnownTelemetryNames.spans;
|
|
19
|
+
const name = names[operation];
|
|
12
20
|
const resolved = correlationId ? new CorrelationId(correlationId.toString()) : correlationIdManager.scoped;
|
|
13
21
|
const run = () => {
|
|
14
22
|
const active = context.active();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { JsonSchema } from '../schemas/JsonSchema.js';
|
|
5
|
+
|
|
6
|
+
const integerRanges = new Map<string, readonly [number, number]>([
|
|
7
|
+
['int8', [-128, 127]], ['uint8', [0, 255]],
|
|
8
|
+
['int16', [-32768, 32767]], ['uint16', [0, 65535]],
|
|
9
|
+
['int32', [-2147483648, 2147483647]], ['uint32', [0, 4294967295]],
|
|
10
|
+
// JSON numbers cannot reliably represent the entire 64-bit domain. Fail closed outside safe integers.
|
|
11
|
+
['int64', [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]], ['uint64', [0, Number.MAX_SAFE_INTEGER]]
|
|
12
|
+
]);
|
|
13
|
+
const numberLimits = new Map<string, number>([
|
|
14
|
+
['double', Number.MAX_VALUE], ['float', 3.4028234663852886e38], ['decimal', 7.922816251426433e28]
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Payload support is independent of the narrower, fixture-backed unique-key domain. */
|
|
18
|
+
export function supportsEventField(schema: JsonSchema): boolean {
|
|
19
|
+
if (schema.compliance?.length || schema.security?.length || schema.enum !== undefined || schema.items !== undefined) return false;
|
|
20
|
+
if (schema.type === 'object') {
|
|
21
|
+
return schema.format === undefined &&
|
|
22
|
+
(typeof schema.additionalProperties !== 'object' || supportsEventField(schema.additionalProperties)) &&
|
|
23
|
+
Object.values(schema.properties ?? {}).every(supportsEventField);
|
|
24
|
+
}
|
|
25
|
+
if (schema.properties !== undefined || schema.additionalProperties !== undefined) return false;
|
|
26
|
+
if (schema.type === 'string') return schema.format === undefined || ['guid', 'uuid', 'date', 'date-time'].includes(schema.format);
|
|
27
|
+
if (schema.type === 'number' || schema.type === 'integer') {
|
|
28
|
+
return schema.format === undefined || integerRanges.has(schema.format) ||
|
|
29
|
+
(schema.type === 'number' && numberLimits.has(schema.format));
|
|
30
|
+
}
|
|
31
|
+
return schema.type === 'boolean' && schema.format === undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Validate serialized JSON, never the wrapper objects production serialization has already removed. */
|
|
35
|
+
export function matchesEventField(value: unknown, schema: JsonSchema): boolean {
|
|
36
|
+
if (schema.type === 'object') {
|
|
37
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
38
|
+
const content = value as Record<string, unknown>;
|
|
39
|
+
return (schema.required ?? []).every(key => Object.hasOwn(content, key)) &&
|
|
40
|
+
Object.entries(content).every(([key, entry]) => {
|
|
41
|
+
const property = schema.properties?.[key];
|
|
42
|
+
if (property) return matchesEventField(entry, property);
|
|
43
|
+
if (schema.additionalProperties === false) return false;
|
|
44
|
+
return typeof schema.additionalProperties !== 'object' || matchesEventField(entry, schema.additionalProperties);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (schema.type === 'integer' || schema.type === 'number') {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
|
|
49
|
+
const range = integerRanges.get(schema.format ?? '');
|
|
50
|
+
if (schema.type === 'integer' || range) {
|
|
51
|
+
return Number.isSafeInteger(value) && (!range || (value >= range[0] && value <= range[1]));
|
|
52
|
+
}
|
|
53
|
+
return Math.abs(value) <= (numberLimits.get(schema.format ?? '') ?? Number.MAX_VALUE);
|
|
54
|
+
}
|
|
55
|
+
if (schema.type === 'string') {
|
|
56
|
+
if (typeof value !== 'string') return false;
|
|
57
|
+
if (schema.format === 'guid' || schema.format === 'uuid') return /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value);
|
|
58
|
+
if (schema.format === 'date' || schema.format === 'date-time') return matchesDate(value, schema.format);
|
|
59
|
+
}
|
|
60
|
+
return typeof value === schema.type;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function matchesDate(value: string, format: string): boolean {
|
|
64
|
+
const dateOnly = format === 'date';
|
|
65
|
+
const pattern = dateOnly ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
66
|
+
if (!pattern.test(value) || +value.slice(0, 4) < 1) return false;
|
|
67
|
+
const instant = dateOnly ? `${value}T00:00:00.000Z` : value;
|
|
68
|
+
if (!Number.isFinite(Date.parse(instant))) return false;
|
|
69
|
+
// Date.parse rolls invalid calendar dates and 24:00 into another day; do not accept that coercion.
|
|
70
|
+
return new Date(instant).toISOString().slice(0, dateOnly ? 10 : 19) === value.slice(0, dateOnly ? 10 : 19);
|
|
71
|
+
}
|