@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.
Files changed (74) hide show
  1. package/ChronicleClient.ts +2 -3
  2. package/ChronicleOptions.ts +5 -1
  3. package/EventStore.ts +1 -2
  4. package/WellKnownTelemetryNames.ts +21 -0
  5. package/dist/ChronicleClient.d.ts.map +1 -1
  6. package/dist/ChronicleClient.js +2 -3
  7. package/dist/ChronicleClient.js.map +1 -1
  8. package/dist/ChronicleOptions.d.ts +1 -1
  9. package/dist/ChronicleOptions.d.ts.map +1 -1
  10. package/dist/ChronicleOptions.js +5 -1
  11. package/dist/ChronicleOptions.js.map +1 -1
  12. package/dist/EventStore.d.ts.map +1 -1
  13. package/dist/EventStore.js +1 -2
  14. package/dist/EventStore.js.map +1 -1
  15. package/dist/WellKnownTelemetryNames.d.ts +21 -0
  16. package/dist/WellKnownTelemetryNames.d.ts.map +1 -1
  17. package/dist/WellKnownTelemetryNames.js +21 -0
  18. package/dist/WellKnownTelemetryNames.js.map +1 -1
  19. package/dist/eventSequences/EventSequence.js +9 -9
  20. package/dist/eventSequences/EventSequence.js.map +1 -1
  21. package/dist/telemetry/ChronicleTelemetryOptions.d.ts +3 -1
  22. package/dist/telemetry/ChronicleTelemetryOptions.d.ts.map +1 -1
  23. package/dist/telemetry/observeOperation.d.ts +3 -1
  24. package/dist/telemetry/observeOperation.d.ts.map +1 -1
  25. package/dist/telemetry/observeOperation.js +3 -1
  26. package/dist/telemetry/observeOperation.js.map +1 -1
  27. package/dist/testing/EventFieldSchema.d.ts +6 -0
  28. package/dist/testing/EventFieldSchema.d.ts.map +1 -0
  29. package/dist/testing/EventFieldSchema.js +78 -0
  30. package/dist/testing/EventFieldSchema.js.map +1 -0
  31. package/dist/testing/EventScenario.d.ts.map +1 -1
  32. package/dist/testing/EventScenario.js +17 -7
  33. package/dist/testing/EventScenario.js.map +1 -1
  34. package/dist/testing/InProcessConstraints.d.ts +3 -1
  35. package/dist/testing/InProcessConstraints.d.ts.map +1 -1
  36. package/dist/testing/InProcessConstraints.js +20 -9
  37. package/dist/testing/InProcessConstraints.js.map +1 -1
  38. package/dist/testing/InProcessEventSequence.d.ts.map +1 -1
  39. package/dist/testing/InProcessEventSequence.js +9 -10
  40. package/dist/testing/InProcessEventSequence.js.map +1 -1
  41. package/dist/testing/ReadModelScenario.js +1 -1
  42. package/dist/testing/ReadModelScenario.js.map +1 -1
  43. package/dist/testing/ScenarioEvent.d.ts +1 -0
  44. package/dist/testing/ScenarioEvent.d.ts.map +1 -1
  45. package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
  46. package/dist/testing/projections/ProjectionReadModelProcessor.js +7 -0
  47. package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
  48. package/dist/tsconfig.tsbuildinfo +1 -1
  49. package/eventSequences/EventSequence.ts +9 -9
  50. package/events/migrations/for_EventTypeMigrators/against_a_kernel/when_replaying_an_older_generation.integration.spec.ts +30 -9
  51. package/for_ChronicleOptions/when_configuring_telemetry/with_span_names.spec.ts +32 -0
  52. package/package.json +2 -2
  53. package/telemetry/ChronicleTelemetryOptions.ts +4 -1
  54. package/telemetry/for_ChronicleTelemetry/given/a_client.fixture.ts +47 -0
  55. package/telemetry/for_ChronicleTelemetry/given/a_span_name_registry.fixture.ts +33 -0
  56. package/telemetry/for_ChronicleTelemetry/when_exposing_names/with_legacy_compatibility.spec.ts +44 -0
  57. package/telemetry/for_ChronicleTelemetry/when_naming_spans/with_per_client_modes.spec.ts +138 -0
  58. package/telemetry/for_ChronicleTelemetry/when_recording_product_attributes/with_both_naming_modes.spec.ts +52 -0
  59. package/telemetry/observeOperation.ts +9 -1
  60. package/testing/EventFieldSchema.ts +71 -0
  61. package/testing/EventScenario.constraint-cycles.spec.ts +1 -1
  62. package/testing/EventScenario.constraints.spec.ts +4 -2
  63. package/testing/EventScenario.fixtures.spec.ts +5 -5
  64. package/testing/EventScenario.ts +18 -7
  65. package/testing/InProcessConstraints.ts +19 -9
  66. package/testing/InProcessEventSequence.ts +9 -10
  67. package/testing/ReadModelScenario.ts +1 -1
  68. package/testing/ScenarioEvent.ts +1 -1
  69. package/testing/for_EventScenario/when_appending_field_types/field_scenario_behaviors.fixture.ts +219 -0
  70. package/testing/for_EventScenario/when_appending_field_types/with_invalid_formatted_content.legacy.spec.ts +123 -0
  71. package/testing/for_EventScenario/when_appending_field_types/with_legacy_decorators.legacy.spec.ts +124 -0
  72. package/testing/for_EventScenario/when_appending_field_types/with_standard_decorators.standard.spec.ts +123 -0
  73. package/testing/for_EventScenario/when_appending_field_types/with_unproven_schemas.legacy.spec.ts +82 -0
  74. 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(names.spans.append, async span => {
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(names.spans.appendMany, async span => {
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(names.spans.getTailSequenceNumber, async span => {
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(names.spans.hasEventsFor, async span => {
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(names.spans.getForEventSourceIdAndEventTypes, async span => {
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(names.spans.getFromSequenceNumber, async span => {
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(names.spans.redact, async span => {
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(names.spans.redactForEventSource, async span => {
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(names.spans.completeStream, async span => {
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
- // A later version registers the second generation with a migration, a projection and a reactor that replay the history.
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
- event = await eventually(
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!.firstName.should.equal('Jane'));
151
- it('should project the migrated last name', () => author!.lastName.should.equal('Doe'));
152
- it('should replay the migrated first name to the reactor', () => observation.event.firstName.should.equal('Jane'));
153
- it('should replay the migrated last name to the reactor', () => observation.event.lastName.should.equal('Doe'));
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.38.0",
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
+ };
@@ -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>(name: string, action: (span: Span) => Promise<T>, correlationId?: { toString(): string }): Promise<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
+ }