@cratis/chronicle 5.1.0 → 6.1.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 (80) hide show
  1. package/ChronicleClient.ts +42 -9
  2. package/connection/ChronicleConnection.ts +9 -5
  3. package/connection/CompatibilityPreflight.ts +62 -0
  4. package/connection/IncompatibleChronicleServer.ts +10 -0
  5. package/connection/callResults.spec.ts +6 -0
  6. package/connection/callResults.ts +5 -7
  7. package/connection/for_ChronicleConnection/when_checking_compatibility/before_append.spec.ts +148 -0
  8. package/connection/for_CompatibilityPreflight/when_verifying/with_concurrent_calls.spec.ts +38 -0
  9. package/dist/ChronicleClient.d.ts +3 -0
  10. package/dist/ChronicleClient.d.ts.map +1 -1
  11. package/dist/ChronicleClient.js +41 -9
  12. package/dist/ChronicleClient.js.map +1 -1
  13. package/dist/connection/ChronicleConnection.d.ts +1 -0
  14. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  15. package/dist/connection/ChronicleConnection.js +9 -5
  16. package/dist/connection/ChronicleConnection.js.map +1 -1
  17. package/dist/connection/CompatibilityPreflight.d.ts +15 -0
  18. package/dist/connection/CompatibilityPreflight.d.ts.map +1 -0
  19. package/dist/connection/CompatibilityPreflight.js +61 -0
  20. package/dist/connection/CompatibilityPreflight.js.map +1 -0
  21. package/dist/connection/IncompatibleChronicleServer.d.ts +5 -0
  22. package/dist/connection/IncompatibleChronicleServer.d.ts.map +1 -0
  23. package/dist/connection/IncompatibleChronicleServer.js +10 -0
  24. package/dist/connection/IncompatibleChronicleServer.js.map +1 -0
  25. package/dist/connection/callResults.d.ts +4 -6
  26. package/dist/connection/callResults.d.ts.map +1 -1
  27. package/dist/connection/callResults.js +1 -1
  28. package/dist/connection/callResults.js.map +1 -1
  29. package/dist/eventSequences/AppendOptions.d.ts +11 -1
  30. package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
  31. package/dist/eventSequences/EventForEventSourceId.d.ts +6 -4
  32. package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
  33. package/dist/eventSequences/EventSequence.d.ts +1 -1
  34. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  35. package/dist/eventSequences/EventSequence.js +29 -38
  36. package/dist/eventSequences/EventSequence.js.map +1 -1
  37. package/dist/eventSequences/IEventSequence.d.ts +12 -3
  38. package/dist/eventSequences/IEventSequence.d.ts.map +1 -1
  39. package/dist/events/CausationEntry.d.ts +2 -0
  40. package/dist/events/CausationEntry.d.ts.map +1 -1
  41. package/dist/events/EventContext.d.ts +19 -0
  42. package/dist/events/EventContext.d.ts.map +1 -1
  43. package/dist/events/WireEventContext.d.ts +11 -0
  44. package/dist/events/WireEventContext.d.ts.map +1 -0
  45. package/dist/events/WireEventContext.js +4 -0
  46. package/dist/events/WireEventContext.js.map +1 -0
  47. package/dist/events/toClientEventContext.d.ts +5 -0
  48. package/dist/events/toClientEventContext.d.ts.map +1 -0
  49. package/dist/events/toClientEventContext.js +37 -0
  50. package/dist/events/toClientEventContext.js.map +1 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/reactors/Reactors.d.ts.map +1 -1
  56. package/dist/reactors/Reactors.js +2 -16
  57. package/dist/reactors/Reactors.js.map +1 -1
  58. package/dist/reducers/Reducers.d.ts.map +1 -1
  59. package/dist/reducers/Reducers.js +3 -16
  60. package/dist/reducers/Reducers.js.map +1 -1
  61. package/dist/tsconfig.tsbuildinfo +1 -1
  62. package/eventSequences/AppendOptions.ts +16 -1
  63. package/eventSequences/EventForEventSourceId.ts +7 -4
  64. package/eventSequences/EventSequence.spec.ts +4 -4
  65. package/eventSequences/EventSequence.ts +29 -42
  66. package/eventSequences/IEventSequence.ts +12 -3
  67. package/eventSequences/for_EventSequence/when_appending/with_metadata.spec.ts +131 -0
  68. package/eventSequences/for_EventSequence/when_reading/with_route_filters.spec.ts +124 -0
  69. package/events/CausationEntry.ts +3 -0
  70. package/events/EventContext.ts +28 -0
  71. package/events/WireEventContext.ts +14 -0
  72. package/events/for_EventContext/when_receiving/with_kernel_metadata.spec.ts +109 -0
  73. package/events/toClientEventContext.ts +45 -0
  74. package/for_ChronicleClient/when_reconnecting/and_the_server_becomes_incompatible.spec.ts +95 -0
  75. package/index.ts +1 -0
  76. package/package.json +5 -4
  77. package/reactors/ReactorSideEffects.spec.ts +2 -0
  78. package/reactors/Reactors.ts +2 -17
  79. package/reducers/Reducers.ts +3 -17
  80. package/vitest.config.ts +1 -1
@@ -0,0 +1,124 @@
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 { EventSequence, EventSequenceId } from '../../index';
6
+ import { eventType } from '../../../events';
7
+ import type { ChronicleConnection } from '../../../connection';
8
+ import type { IUnitOfWorkManager } from '../../../transactions';
9
+
10
+ chai.should();
11
+
12
+ // The kernel resolves an append that carried no route to source type 'Default', stream type 'All' and
13
+ // stream identifier 'Default', and treats an empty dimension on a read as "do not narrow". A client that
14
+ // defaulted a read to the legacy 'Default' route would therefore return none of those events.
15
+ class RouteReadRecorded {}
16
+ eventType('RouteReadRecorded')(RouteReadRecorded);
17
+
18
+ class RouteReadObserver {
19
+ routeReadRecorded(_event: RouteReadRecorded) {}
20
+ }
21
+
22
+ function createSequence() {
23
+ const tailSequenceNumber = vi.fn().mockResolvedValue({ Data: { SequenceNumber: 7n } });
24
+ const forEventSourceIdAndEventTypes = vi.fn().mockResolvedValue({ Data: [] });
25
+ const connection = { eventSequences: { tailSequenceNumber, forEventSourceIdAndEventTypes } } as unknown as ChronicleConnection;
26
+ return {
27
+ sequence: new EventSequence(EventSequenceId.eventLog, 'store', 'tenant', connection, {} as IUnitOfWorkManager),
28
+ tailSequenceNumber,
29
+ forEventSourceIdAndEventTypes
30
+ };
31
+ }
32
+
33
+ describe('when reading the tail sequence number without explicit routes', () => {
34
+ let request: { EventSourceType: string; EventStreamType: string; EventStreamId: string };
35
+ beforeEach(async () => {
36
+ const { sequence, tailSequenceNumber } = createSequence();
37
+ await sequence.getNextSequenceNumber();
38
+ request = tailSequenceNumber.mock.calls[0][0];
39
+ });
40
+ it('should not narrow the read to the legacy route', () => {
41
+ [request.EventSourceType, request.EventStreamType, request.EventStreamId].should.deep.equal(['', '', '']);
42
+ });
43
+ });
44
+
45
+ describe('when reading the tail sequence number with explicit routes', () => {
46
+ let request: { EventSourceType: string; EventStreamType: string; EventStreamId: string };
47
+ beforeEach(async () => {
48
+ const { sequence, tailSequenceNumber } = createSequence();
49
+ await sequence.getTailSequenceNumber(undefined, 'Default', 'Default', 'source');
50
+ request = tailSequenceNumber.mock.calls[0][0];
51
+ });
52
+ it('should send every supplied dimension unchanged', () => {
53
+ [request.EventSourceType, request.EventStreamType, request.EventStreamId].should.deep.equal(['Default', 'Default', 'source']);
54
+ });
55
+ });
56
+
57
+ describe('when reading the tail sequence number for an observer', () => {
58
+ let request: { EventSourceType: string; EventStreamType: string; EventStreamId: string; EventTypeIds: string };
59
+ beforeEach(async () => {
60
+ const { sequence, tailSequenceNumber } = createSequence();
61
+ await sequence.getTailSequenceNumberForObserver(RouteReadObserver);
62
+ request = tailSequenceNumber.mock.calls[0][0];
63
+ });
64
+ it('should not narrow the read to the legacy route', () => {
65
+ [request.EventSourceType, request.EventStreamType, request.EventStreamId].should.deep.equal(['', '', '']);
66
+ });
67
+ it('should still narrow to the event types the observer handles', () => {
68
+ request.EventTypeIds.should.equal('RouteReadRecorded');
69
+ });
70
+ });
71
+
72
+ describe('when reading events for an event source without an explicit stream type', () => {
73
+ let request: { EventStreamType: string; EventStreamId: string; EventSourceType: string };
74
+ beforeEach(async () => {
75
+ const { sequence, forEventSourceIdAndEventTypes } = createSequence();
76
+ await sequence.getForEventSourceIdAndEventTypes('source', [RouteReadRecorded]);
77
+ request = forEventSourceIdAndEventTypes.mock.calls[0][0];
78
+ });
79
+ it('should not hide events the kernel routed for an unrouted append', () => {
80
+ [request.EventStreamType, request.EventStreamId, request.EventSourceType].should.deep.equal(['', '', '']);
81
+ });
82
+ });
83
+
84
+ describe('when reading events for an event source with an explicit stream type', () => {
85
+ let request: { EventStreamType: string; EventStreamId: string };
86
+ beforeEach(async () => {
87
+ const { sequence, forEventSourceIdAndEventTypes } = createSequence();
88
+ await sequence.getForEventSourceIdAndEventTypes('source', [RouteReadRecorded], 'Default', 'source');
89
+ request = forEventSourceIdAndEventTypes.mock.calls[0][0];
90
+ });
91
+ it('should send the supplied stream scope unchanged', () => {
92
+ [request.EventStreamType, request.EventStreamId].should.deep.equal(['Default', 'source']);
93
+ });
94
+ });
95
+
96
+ // The event source type was accepted and dropped before it reached the request, so a caller narrowing by
97
+ // it silently got every source type back. The specification above exercised this call with a stream scope
98
+ // and never asserted on the source type, which is what kept the drop invisible. See Cratis/Chronicle#4049.
99
+ describe('when reading events for an event source narrowed to an event source type', () => {
100
+ let request: { EventSourceType: string; EventStreamType: string; EventStreamId: string };
101
+ beforeEach(async () => {
102
+ const { sequence, forEventSourceIdAndEventTypes } = createSequence();
103
+ await sequence.getForEventSourceIdAndEventTypes('source', [RouteReadRecorded], undefined, undefined, 'Order');
104
+ request = forEventSourceIdAndEventTypes.mock.calls[0][0];
105
+ });
106
+ it('should send the event source type', () => {
107
+ request.EventSourceType.should.equal('Order');
108
+ });
109
+ it('should leave the stream dimensions unnarrowed', () => {
110
+ [request.EventStreamType, request.EventStreamId].should.deep.equal(['', '']);
111
+ });
112
+ });
113
+
114
+ describe('when reading events for an event source narrowed to every dimension', () => {
115
+ let request: { EventSourceType: string; EventStreamType: string; EventStreamId: string };
116
+ beforeEach(async () => {
117
+ const { sequence, forEventSourceIdAndEventTypes } = createSequence();
118
+ await sequence.getForEventSourceIdAndEventTypes('source', [RouteReadRecorded], 'Default', 'source', 'Order');
119
+ request = forEventSourceIdAndEventTypes.mock.calls[0][0];
120
+ });
121
+ it('should send every supplied dimension unchanged', () => {
122
+ [request.EventSourceType, request.EventStreamType, request.EventStreamId].should.deep.equal(['Order', 'Default', 'source']);
123
+ });
124
+ });
@@ -5,6 +5,9 @@
5
5
  * Represents a single entry in the causation chain of an event.
6
6
  */
7
7
  export interface CausationEntry {
8
+ /** The occurrence time of the causing operation, when supplied by the kernel. */
9
+ readonly occurred?: Date;
10
+
8
11
  /** The type identifier of the causing operation. */
9
12
  readonly type: string;
10
13
 
@@ -4,6 +4,7 @@
4
4
  import { EventType } from './EventType';
5
5
  import { CausationEntry } from './CausationEntry';
6
6
  import { Tag } from './Tag';
7
+ import type { Identity } from '../identity/Identity';
7
8
 
8
9
  /**
9
10
  * Represents contextual information about an appended event.
@@ -15,6 +16,33 @@ export interface EventContext {
15
16
  /** The unique identifier of the event source. */
16
17
  readonly eventSourceId: string;
17
18
 
19
+ /** The event store, when included by the kernel. */
20
+ readonly eventStore?: string;
21
+
22
+ /** The namespace, when included by the kernel. */
23
+ readonly namespace?: string;
24
+
25
+ /** The source type returned by the kernel. */
26
+ readonly eventSourceType?: string;
27
+
28
+ /** The stream type returned by the kernel. */
29
+ readonly eventStreamType?: string;
30
+
31
+ /** The stream identifier returned by the kernel. */
32
+ readonly eventStreamId?: string;
33
+
34
+ /** The compliance subject returned by the kernel. */
35
+ readonly subject?: string;
36
+
37
+ /** The persisted event hash. */
38
+ readonly hash?: string;
39
+
40
+ /** The identity responsible for the event. */
41
+ readonly causedBy?: Identity;
42
+
43
+ /** The observation state carried by the delivery. */
44
+ readonly observationState?: number;
45
+
18
46
  /** The type of the event. */
19
47
  readonly eventType: EventType;
20
48
 
@@ -0,0 +1,14 @@
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 { AppendedEventResponse } from '@cratis/chronicle.contracts';
5
+
6
+ /** Shared metadata shape; generated services use distinct observation-state enum types. */
7
+ export type WireEventContext = Omit<NonNullable<AppendedEventResponse['Context']>, 'ObservationState'> & {
8
+ /** Numeric wire value shared by the generated observation-state enums. */
9
+ ObservationState: number;
10
+ /** Store metadata is carried by observer deliveries, but not every read response. */
11
+ EventStore?: string;
12
+ /** Namespace metadata is carried by observer deliveries, but not every read response. */
13
+ Namespace?: string;
14
+ };
@@ -0,0 +1,109 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it } from 'vitest';
5
+ import { EventContext as ContractsEventContext, EventType as ContractsEventType } from '@cratis/chronicle.contracts';
6
+ import type { IClientArtifactsProvider } from '../../../artifacts';
7
+ import type { ChronicleConnection } from '../../../connection';
8
+ import { ConnectionLifecycle } from '../../../connection/ConnectionLifecycle';
9
+ import { toContractsGuid } from '../../../connection/Guid';
10
+ import { EventSequence, EventSequenceId, EventSequenceNumber } from '../../../eventSequences';
11
+ import type { IEventLog } from '../../../eventSequences';
12
+ import { Reactors, reactor } from '../../../reactors';
13
+ import { Reducers, reducer } from '../../../reducers';
14
+ import type { IUnitOfWorkManager } from '../../../transactions';
15
+ import { eventType } from '../../eventTypeDecorator';
16
+ import type { EventContext } from '../../EventContext';
17
+ import { Guid } from '@cratis/fundamentals';
18
+
19
+ chai.should();
20
+ class CanonicalMetadataRecorded {}
21
+ eventType('CanonicalMetadataRecorded')(CanonicalMetadataRecorded);
22
+ const correlationId = '12345678-1234-5678-9abc-123456789abc';
23
+ const occurred = '2025-06-07T08:09:10.123Z';
24
+ function wireEvent() {
25
+ return {
26
+ Context: ContractsEventContext.fromPartial({
27
+ EventType: ContractsEventType.fromPartial({ Id: 'CanonicalMetadataRecorded', Generation: 2, Tombstone: true }),
28
+ EventStore: 'store', Namespace: 'tenant',
29
+ SequenceNumber: 9007199254740993n, EventSourceId: 'source', EventSourceType: 'Source',
30
+ EventStreamType: 'Stream', EventStreamId: 'stream-id', Subject: 'subject', Hash: 'persisted-hash',
31
+ Occurred: { Value: occurred }, CorrelationId: toContractsGuid(Guid.as(correlationId)),
32
+ Tags: ['one', 'two'], ObservationState: 1,
33
+ Causation: [{ Type: 'command', Occurred: { Value: occurred }, Properties: { key: 'value' } }],
34
+ CausedBy: { Subject: 'actor', Name: 'Actor', UserName: 'actor-name', OnBehalfOf: { Subject: 'delegate', Name: 'Delegate' } }
35
+ }),
36
+ Content: '{}'
37
+ };
38
+ }
39
+
40
+ async function receiveContext(kind: string): Promise<EventContext> {
41
+ if (kind === 'read') {
42
+ const connection = { eventSequences: { fromSequenceNumber: async () => ({ Data: [wireEvent()] }) } } as unknown as ChronicleConnection;
43
+ const sequence = new EventSequence(EventSequenceId.eventLog, 'store', 'tenant', connection, {} as IUnitOfWorkManager);
44
+ return (await sequence.getFromSequenceNumber(EventSequenceNumber.first))[0].context;
45
+ }
46
+
47
+ let received: EventContext | undefined;
48
+ class CapturingReactor { canonicalMetadataRecorded(_event: object, context: EventContext) { received = context; } }
49
+ class State { count = 0; }
50
+ class CapturingReducer {
51
+ canonicalMetadataRecorded(_event: object, _state: State | undefined, context: EventContext) {
52
+ received = context;
53
+ return { count: 1 };
54
+ }
55
+ }
56
+ reactor('metadata-reactor')(CapturingReactor);
57
+ reducer('metadata-reducer', undefined, State)(CapturingReducer);
58
+ const lifecycle = new ConnectionLifecycle();
59
+ let finish!: () => void;
60
+ const finished = new Promise<void>(resolve => { finish = resolve; });
61
+ async function* observe(queue: AsyncIterable<unknown>) {
62
+ const messages = queue[Symbol.asyncIterator]();
63
+ await messages.next();
64
+ yield { Events: [wireEvent()], Partition: 'source', ReplayState: 0, InitialState: '' };
65
+ await messages.next();
66
+ await lifecycle.disconnected(error => { throw error; });
67
+ finish();
68
+ }
69
+ const connection = { reactors: { observe }, reducers: { observe }, readModels: { registerMany: async () => ({}) } } as unknown as ChronicleConnection;
70
+ const artifacts = {
71
+ eventTypes: [CanonicalMetadataRecorded], reactors: [CapturingReactor], reducers: [CapturingReducer],
72
+ readModels: [], seeders: [], constraints: [], projections: [], webhooks: [], eventTypeMigrations: []
73
+ } as IClientArtifactsProvider;
74
+ const runtime = kind === 'reactor'
75
+ ? new Reactors(artifacts, connection, 'store', 'tenant', lifecycle, {} as IEventLog)
76
+ : new Reducers(artifacts, connection, 'store', 'tenant', lifecycle, 'default-sink');
77
+ await runtime.register();
78
+ await finished;
79
+ if (!received) throw new Error(`No context delivered to ${kind}`);
80
+ return received;
81
+ }
82
+
83
+ for (const kind of ['read', 'reactor', 'reducer']) {
84
+ describe(`when receiving kernel metadata through a ${kind}`, () => {
85
+ let context: EventContext;
86
+ beforeEach(async () => { context = await receiveContext(kind); });
87
+ it('should retain the exact route and subject', () => {
88
+ [context.eventSourceType, context.eventSourceId, context.eventStreamType, context.eventStreamId, context.subject]
89
+ .should.deep.equal(['Source', 'source', 'Stream', 'stream-id', 'subject']);
90
+ context.eventStore!.should.equal('store');
91
+ context.namespace!.should.equal('tenant');
92
+ });
93
+ it('should retain timestamp, sequence number, event type, and canonical correlation id', () => {
94
+ context.occurred.toISOString().should.equal(occurred);
95
+ context.sequenceNumber.should.equal(9007199254740993n);
96
+ context.eventType.generation.value.should.equal(2);
97
+ context.eventType.tombstone.should.equal(true);
98
+ context.correlationId.should.equal(correlationId);
99
+ });
100
+ it('should retain tags, causation, identity, hash, and observation state', () => {
101
+ context.tags.map(tag => tag.value).should.deep.equal(['one', 'two']);
102
+ context.causation.should.deep.equal([{ type: 'command', occurred: new Date(occurred), properties: { key: 'value' } }]);
103
+ context.causedBy!.subject.should.equal('actor');
104
+ context.causedBy!.onBehalfOf!.subject.should.equal('delegate');
105
+ context.hash!.should.equal('persisted-hash');
106
+ context.observationState!.should.equal(1);
107
+ });
108
+ });
109
+ }
@@ -0,0 +1,45 @@
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 { fromContractsGuid } from '../connection/Guid';
5
+ import { Identity } from '../identity/Identity';
6
+ import type { EventContext } from './EventContext';
7
+ import { EventType } from './EventType';
8
+ import { EventTypeGeneration } from './EventTypeGeneration';
9
+ import { EventTypeId } from './EventTypeId';
10
+ import { Tag } from './Tag';
11
+ import type { WireEventContext } from './WireEventContext';
12
+
13
+ /** Converts kernel metadata identically for reads, reactors, and reducers. */
14
+ export function toClientEventContext(context: WireEventContext): EventContext {
15
+ return {
16
+ eventStore: context.EventStore,
17
+ namespace: context.Namespace,
18
+ sequenceNumber: context.SequenceNumber,
19
+ eventSourceId: context.EventSourceId,
20
+ eventSourceType: context.EventSourceType,
21
+ eventStreamType: context.EventStreamType,
22
+ eventStreamId: context.EventStreamId,
23
+ subject: context.Subject,
24
+ hash: context.Hash,
25
+ observationState: context.ObservationState,
26
+ causedBy: toClientIdentity(context.CausedBy),
27
+ eventType: new EventType(
28
+ new EventTypeId(context.EventType?.Id ?? ''),
29
+ new EventTypeGeneration(context.EventType?.Generation ?? EventTypeGeneration.firstValue),
30
+ context.EventType?.Tombstone ?? false
31
+ ),
32
+ occurred: new Date(context.Occurred?.Value ?? ''),
33
+ correlationId: fromContractsGuid(context.CorrelationId).toString(),
34
+ causation: (context.Causation ?? []).map(causation => ({
35
+ type: causation.Type,
36
+ occurred: causation.Occurred ? new Date(causation.Occurred.Value) : undefined,
37
+ properties: { ...causation.Properties }
38
+ })),
39
+ tags: (context.Tags ?? []).map(value => new Tag(value))
40
+ };
41
+ }
42
+
43
+ function toClientIdentity(identity: WireEventContext['CausedBy']): Identity | undefined {
44
+ return identity ? new Identity(identity.Subject, identity.Name, identity.UserName, toClientIdentity(identity.OnBehalfOf)) : undefined;
45
+ }
@@ -0,0 +1,95 @@
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, vi } from 'vitest';
5
+ import { ChronicleClient } from '../../ChronicleClient';
6
+ import { ChronicleOptions } from '../../ChronicleOptions';
7
+ import { IncompatibleChronicleServer } from '../../connection/IncompatibleChronicleServer';
8
+
9
+ chai.should();
10
+
11
+ const transport = vi.hoisted(() => ({
12
+ resetChannel: vi.fn(),
13
+ connect: vi.fn(),
14
+ disconnect: vi.fn(),
15
+ getVersionInfo: vi.fn(),
16
+ allEventStores: vi.fn(),
17
+ ensureEventStore: vi.fn(),
18
+ append: vi.fn(),
19
+ lost: undefined as ((reason: string, error: unknown) => void) | undefined
20
+ }));
21
+
22
+ vi.mock('../../connection/ChronicleConnection', () => ({
23
+ ChronicleConnection: class {
24
+ resetChannel = transport.resetChannel;
25
+ connect = transport.connect;
26
+ disconnect = transport.disconnect;
27
+ server = { getVersionInfo: transport.getVersionInfo };
28
+ eventStores = { allEventStores: transport.allEventStores, ensureEventStore: transport.ensureEventStore };
29
+ eventSequences = { append: transport.append };
30
+ }
31
+ }));
32
+
33
+ vi.mock('../../connection/KernelKeepAlive', () => ({
34
+ KernelKeepAlive: class {
35
+ constructor(_connections: unknown, onLost: (reason: string, error: unknown) => void) {
36
+ transport.lost = onLost;
37
+ }
38
+ async start() {}
39
+ }
40
+ }));
41
+
42
+ describe.each(['watchdog', 'keep-alive', 'awaited operation'])('when %s recovery reaches an incompatible server', boundary => {
43
+ let client: ChronicleClient;
44
+ let rejection: IncompatibleChronicleServer;
45
+
46
+ beforeEach(async () => {
47
+ vi.useFakeTimers();
48
+ vi.clearAllMocks();
49
+ transport.resetChannel.mockResolvedValue(undefined);
50
+ transport.connect.mockResolvedValue(undefined);
51
+ transport.getVersionInfo.mockResolvedValue({});
52
+ transport.allEventStores.mockResolvedValue({ IsAuthorized: true, Data: [] });
53
+ client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', { discoveryPatterns: [] }));
54
+ await client.getEventStores();
55
+ rejection = new IncompatibleChronicleServer('The replacement server is incompatible.');
56
+ });
57
+
58
+ afterEach(() => {
59
+ client.dispose();
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ it('should contain background rejection and fail current and later callers without retrying writes', async () => {
64
+ let rejectVerification!: (reason: unknown) => void;
65
+ const verification = new Promise<void>((_resolve, reject) => { rejectVerification = reject; });
66
+ transport.connect.mockReturnValue(verification);
67
+ let awaited: Promise<PromiseSettledResult<unknown>[]> | undefined;
68
+ if (boundary === 'watchdog') {
69
+ transport.getVersionInfo.mockRejectedValue(new Error('connection unavailable'));
70
+ await vi.advanceTimersByTimeAsync(5000);
71
+ } else if (boundary === 'keep-alive') {
72
+ transport.lost!('keep-alive-ended', new Error('connection unavailable'));
73
+ await vi.advanceTimersByTimeAsync(0);
74
+ } else {
75
+ transport.allEventStores.mockRejectedValueOnce(new Error('connection unavailable'));
76
+ awaited = Promise.allSettled([client.getEventStores()]);
77
+ await vi.advanceTimersByTimeAsync(0);
78
+ }
79
+ transport.connect.mock.calls.should.have.lengthOf(2);
80
+ const waiting = Promise.allSettled([client.getEventStores()]);
81
+ rejectVerification(rejection);
82
+ await vi.advanceTimersByTimeAsync(0);
83
+ const results = [...await waiting, ...(awaited ? await awaited : []), ...await Promise.allSettled([client.getEventStores()])];
84
+ results.forEach(result => {
85
+ result.status.should.equal('rejected');
86
+ if (result.status === 'rejected') result.reason.should.equal(rejection);
87
+ });
88
+ await vi.advanceTimersByTimeAsync(15000);
89
+ transport.connect.mock.calls.should.have.lengthOf(2);
90
+ transport.allEventStores.mock.calls.should.have.lengthOf(boundary === 'awaited operation' ? 2 : 1);
91
+ transport.ensureEventStore.mock.calls.should.have.lengthOf(0);
92
+ transport.append.mock.calls.should.have.lengthOf(0);
93
+ transport.disconnect.mock.calls.length.should.be.greaterThan(0);
94
+ });
95
+ });
package/index.ts CHANGED
@@ -25,6 +25,7 @@ import * as eventStoreSubscriptions from './eventStoreSubscriptions';
25
25
  import * as compliance from './compliance';
26
26
 
27
27
  export { ChronicleClient } from './ChronicleClient';
28
+ export { IncompatibleChronicleServer } from './connection/IncompatibleChronicleServer';
28
29
  export type { IChronicleClient } from './IChronicleClient';
29
30
  export { ChronicleOptions } from './ChronicleOptions';
30
31
  export { EventStore } from './EventStore';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "5.1.0",
3
+ "version": "6.1.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",
@@ -160,7 +160,7 @@
160
160
  },
161
161
  "dependencies": {
162
162
  "@bufbuild/protobuf": "^2.15.0",
163
- "@cratis/chronicle.contracts": "18.2.0",
163
+ "@cratis/chronicle.contracts": "18.5.0",
164
164
  "@grpc/grpc-js": "^1.14.4",
165
165
  "@opentelemetry/api": "^1.9.1",
166
166
  "nice-grpc": "^2.1.17",
@@ -172,9 +172,10 @@
172
172
  "@cratis/fundamentals": "7.19.2",
173
173
  "@types/node": "^26.5.1",
174
174
  "typescript": "^6.0.3",
175
- "vitest": "^5.0.0"
175
+ "vite": "^8.3.0",
176
+ "vitest": "^5.0.1"
176
177
  },
177
178
  "peerDependencies": {
178
- "@cratis/fundamentals": "^7.14.0"
179
+ "@cratis/fundamentals": "^7.19.2"
179
180
  }
180
181
  }
@@ -98,6 +98,8 @@ describe('appendReactorSideEffects', () => {
98
98
  event: new SomethingHappened('a'),
99
99
  eventStreamType: 'other-stream-type',
100
100
  eventStreamId: 'other-stream-id',
101
+ eventSourceType: 'other-source-type',
102
+ occurred: new Date('2025-01-01T00:00:00Z'),
101
103
  subject: 'other-subject'
102
104
  };
103
105
 
@@ -9,10 +9,7 @@ import { IClientArtifactsProvider } from '../artifacts';
9
9
  import { ChronicleConnection } from '../connection';
10
10
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle';
11
11
  import { getEventTypeMetadata } from '../events/eventTypeDecorator';
12
- import { EventContext } from '../events/EventContext';
13
- import { EventTypeId } from '../events/EventTypeId';
14
- import { EventTypeGeneration } from '../events/EventTypeGeneration';
15
- import { Tag } from '../events/Tag';
12
+ import { toClientEventContext } from '../events/toClientEventContext';
16
13
  import { getTagsFor } from '../events/tagDecorator';
17
14
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator';
18
15
  import { EventSequenceId } from '../eventSequences/EventSequenceId';
@@ -296,19 +293,7 @@ export class Reactors implements IReactors {
296
293
 
297
294
  const content = JSON.parse(event.Content) as Record<string, unknown>;
298
295
  this._logger.debug('Event content', { reactorId: id, eventTypeId, contentKeys: Object.keys(content), rawContent: event.Content.substring(0, 200) });
299
- const context: EventContext = {
300
- sequenceNumber: event.Context!.SequenceNumber,
301
- eventSourceId: event.Context!.EventSourceId,
302
- eventType: {
303
- id: new EventTypeId(event.Context!.EventType!.Id),
304
- generation: new EventTypeGeneration(event.Context!.EventType!.Generation),
305
- tombstone: event.Context!.EventType!.Tombstone
306
- },
307
- occurred: new Date(event.Context!.Occurred?.Value ?? ''),
308
- correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '',
309
- causation: [],
310
- tags: (event.Context!.Tags ?? []).map(value => new Tag(value))
311
- };
296
+ const context = toClientEventContext(event.Context!);
312
297
 
313
298
  this._logger.info('Invoking reactor handler', {
314
299
  reactorId: id,
@@ -10,10 +10,7 @@ import { ChronicleConnection } from '../connection';
10
10
  import { toContractsGuid } from '../connection/Guid';
11
11
  import { ConnectionLifecycle } from '../connection/ConnectionLifecycle';
12
12
  import { getEventTypeMetadata } from '../events/eventTypeDecorator';
13
- import { EventContext } from '../events/EventContext';
14
- import { EventTypeId } from '../events/EventTypeId';
15
- import { EventTypeGeneration } from '../events/EventTypeGeneration';
16
- import { Tag } from '../events/Tag';
13
+ import { toClientEventContext } from '../events/toClientEventContext';
17
14
  import { getTagsFor } from '../events/tagDecorator';
18
15
  import { getFilterTagsFor } from '../events/filterEventsByTagDecorator';
19
16
  import { EventSequenceId } from '../eventSequences/EventSequenceId';
@@ -315,6 +312,7 @@ export class Reducers implements IReducers {
315
312
  Key: EVENT_SOURCE_ID_KEY
316
313
  })),
317
314
  ReadModel: readModelName,
315
+ Hash: '',
318
316
  IsActive: isActive,
319
317
  Tags: getTagsFor(reducerType).map(t => t.value),
320
318
  Filters: {
@@ -375,19 +373,7 @@ export class Reducers implements IReducers {
375
373
  }
376
374
 
377
375
  const content = JSON.parse(event.Content) as Record<string, unknown>;
378
- const context: EventContext = {
379
- sequenceNumber: event.Context!.SequenceNumber,
380
- eventSourceId: event.Context!.EventSourceId,
381
- eventType: {
382
- id: new EventTypeId(event.Context!.EventType!.Id),
383
- generation: new EventTypeGeneration(event.Context!.EventType!.Generation),
384
- tombstone: event.Context!.EventType!.Tombstone
385
- },
386
- occurred: new Date(event.Context!.Occurred?.Value ?? ''),
387
- correlationId: event.Context?.CorrelationId ? `${event.Context.CorrelationId.lo}-${event.Context.CorrelationId.hi}` : '',
388
- causation: [],
389
- tags: (event.Context!.Tags ?? []).map(value => new Tag(value))
390
- };
376
+ const context = toClientEventContext(event.Context!);
391
377
 
392
378
  this._logger.info('Invoking reducer handler', {
393
379
  reducerId: id,
package/vitest.config.ts CHANGED
@@ -5,7 +5,7 @@ import { defineConfig } from 'vitest/config';
5
5
 
6
6
  export default defineConfig({
7
7
  test: {
8
- include: ['**/*.spec.ts'],
8
+ include: ['**/*.spec.ts', '**/*.spec.js'],
9
9
  exclude: ['node_modules', 'dist'],
10
10
  environment: 'node'
11
11
  }