@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
@@ -6,6 +6,7 @@ import { diag } from '@opentelemetry/api';
6
6
  import { SpanStatusCode } from '@opentelemetry/api';
7
7
  import { ChronicleOptions } from './ChronicleOptions';
8
8
  import { ChronicleConnection } from './connection';
9
+ import { IncompatibleChronicleServer } from './connection/IncompatibleChronicleServer';
9
10
  import { ensureCommandSuccess, ensureQuerySuccess } from './connection/callResults';
10
11
  import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
11
12
  import { KernelKeepAlive } from './connection/KernelKeepAlive';
@@ -50,6 +51,7 @@ export class ChronicleClient implements IChronicleClient {
50
51
  private _isDisposed = false;
51
52
  private _keepAliveAbortController?: AbortController;
52
53
  private _healthCheckInFlight = false;
54
+ private _connectionFailure?: Error;
53
55
 
54
56
  /**
55
57
  * Creates a new {@link ChronicleClient} using the provided options.
@@ -219,14 +221,10 @@ export class ChronicleClient implements IChronicleClient {
219
221
  try {
220
222
  // Resolve (DNS SRV, when applicable) and select (load balancer strategy) a
221
223
  // server address and rebuild the gRPC channel from scratch on every attempt,
222
- // including the first — not just once at startup — so membership and load
223
- // changes are always picked up. This also guarantees a fresh IDLE channel: a
224
- // failed probe can leave a channel in TRANSIENT_FAILURE, which gRPC won't
225
- // recover from without a new channel. The contracts connect() is bypassed
226
- // here — it uses watchConnectivityState and rejects as soon as the state
227
- // changes to CONNECTING (not READY), making it unreliable for initial
228
- // connection establishment.
224
+ // including the first — so membership and load changes are picked up. Verify
225
+ // the new channel's descriptor before registration or append can produce effects.
229
226
  await this._connection.resetChannel();
227
+ await this._connection.connect();
230
228
 
231
229
  this._logger.debug('Connecting to Chronicle kernel', { attempt: attempt + 1 });
232
230
 
@@ -245,6 +243,10 @@ export class ChronicleClient implements IChronicleClient {
245
243
 
246
244
 
247
245
  } catch (error) {
246
+ if (error instanceof IncompatibleChronicleServer) {
247
+ this.failConnection(error);
248
+ throw error;
249
+ }
248
250
  attempt++;
249
251
  const delayMs = await this.backOff(attempt, 'Connection attempt failed, retrying', error);
250
252
  this._logger.verbose('Backed off before next connection attempt', { attempt, delayMs });
@@ -259,6 +261,11 @@ export class ChronicleClient implements IChronicleClient {
259
261
  throw new Error('ChronicleClient is disposed. Create a new client instance before making calls.');
260
262
  }
261
263
 
264
+ if (this._connectionFailure) throw this._connectionFailure;
265
+
266
+ // Callers arriving during recovery must share its verdict, not create a competing channel.
267
+ if (this._reconnectOperation) await this._reconnectOperation;
268
+
262
269
  if (this._lifecycle.isConnected) {
263
270
  return;
264
271
  }
@@ -273,6 +280,8 @@ export class ChronicleClient implements IChronicleClient {
273
280
  }
274
281
 
275
282
  private async reconnect(reason: string, error: unknown): Promise<void> {
283
+ if (this._connectionFailure) throw this._connectionFailure;
284
+
276
285
  if (!this._reconnectOperation) {
277
286
  this._reconnectOperation = (async () => {
278
287
  this._logger.warn('Reconnecting to Chronicle kernel', {
@@ -292,6 +301,7 @@ export class ChronicleClient implements IChronicleClient {
292
301
  while (!this._isDisposed) {
293
302
  try {
294
303
  await this._connection.resetChannel();
304
+ await this._connection.connect();
295
305
  await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
296
306
  this._logger.info('Reconnected to Chronicle kernel', { attempt: attempt + 1 });
297
307
  await this.startKernelKeepAlive();
@@ -302,6 +312,10 @@ export class ChronicleClient implements IChronicleClient {
302
312
  });
303
313
  return;
304
314
  } catch (reconnectError) {
315
+ if (reconnectError instanceof IncompatibleChronicleServer) {
316
+ this.failConnection(reconnectError);
317
+ throw reconnectError;
318
+ }
305
319
  attempt++;
306
320
  await this.backOff(attempt, 'Reconnect attempt failed, retrying', reconnectError);
307
321
  }
@@ -347,6 +361,7 @@ export class ChronicleClient implements IChronicleClient {
347
361
  }
348
362
 
349
363
  private shouldReconnect(error: unknown): boolean {
364
+ if (error instanceof IncompatibleChronicleServer) return false;
350
365
  const code = Number((error as { code?: number })?.code ?? -1);
351
366
  const details = String((error as { details?: string })?.details ?? '');
352
367
  const message = this.toErrorMessage(error);
@@ -380,7 +395,7 @@ export class ChronicleClient implements IChronicleClient {
380
395
 
381
396
  private startConnectionWatchdog(): void {
382
397
  this._watchdogHandle = setInterval(() => {
383
- void this.runHealthCheck();
398
+ void this.runHealthCheck().catch(error => this.backgroundConnectionFailed('watchdog-health-check', error));
384
399
  }, ChronicleClient._healthCheckIntervalMs);
385
400
 
386
401
  this._watchdogHandle.unref?.();
@@ -418,7 +433,7 @@ export class ChronicleClient implements IChronicleClient {
418
433
  return;
419
434
  }
420
435
 
421
- void this.reconnect(reason, error);
436
+ void this.reconnect(reason, error).catch(failure => this.backgroundConnectionFailed(reason, failure));
422
437
  });
423
438
 
424
439
  await keepAlive.start(
@@ -441,6 +456,24 @@ export class ChronicleClient implements IChronicleClient {
441
456
  });
442
457
  }
443
458
 
459
+ private failConnection(error: Error): void {
460
+ this._connectionFailure = error;
461
+ this._keepAliveAbortController?.abort();
462
+ this._connection.disconnect();
463
+ if (this._watchdogHandle) {
464
+ clearInterval(this._watchdogHandle);
465
+ this._watchdogHandle = undefined;
466
+ }
467
+ }
468
+
469
+ private backgroundConnectionFailed(reason: string, error: unknown): void {
470
+ this.failConnection(error instanceof Error ? error : new Error(String(error)));
471
+ this._logger.error('Background connection recovery failed; create a new client after correcting the server', {
472
+ reason,
473
+ error: this.toErrorMessage(error)
474
+ });
475
+ }
476
+
444
477
  private async registerArtifactsForStore(store: EventStore, reason: string): Promise<void> {
445
478
  this._logger.debug('Registering artifacts for event store', {
446
479
  eventStore: store.name.value,
@@ -26,7 +26,7 @@ import {
26
26
  type ConnectionServiceClient
27
27
  } from '@cratis/chronicle.contracts';
28
28
  import { ComplianceDefinition } from '../compliance/ComplianceContracts';
29
- import { createChannel, createClientFactory, waitForChannelReady } from 'nice-grpc';
29
+ import { createChannel, createClientFactory } from 'nice-grpc';
30
30
  import type { ClientMiddleware } from 'nice-grpc-common';
31
31
  import { Metadata } from 'nice-grpc-common';
32
32
  import { EventStoreSubscriptionsDefinition } from '../eventStoreSubscriptions/contracts';
@@ -34,6 +34,7 @@ import { ExternalServicesDefinition } from '../externalServices/ExternalServices
34
34
  import { AuthenticationMode, ChronicleConnectionString } from './ChronicleConnectionString';
35
35
  import { ChronicleServerAddressResolver } from './ChronicleServerAddressResolver';
36
36
  import { ChronicleServices } from './ChronicleServices';
37
+ import { CompatibilityPreflight } from './CompatibilityPreflight';
37
38
  import { formatServerAddress } from './formatServerAddress';
38
39
  import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
39
40
  import { createLoadBalancerStrategy } from './LoadBalancerStrategyFactory';
@@ -91,6 +92,7 @@ export class ChronicleConnection implements ChronicleServices {
91
92
  private _channel!: Channel;
92
93
  private _services!: ChronicleServices;
93
94
  private _connections!: ConnectionServiceClient;
95
+ private _compatibility!: CompatibilityPreflight;
94
96
  private readonly _connectionString: ChronicleConnectionString;
95
97
  private readonly _tokenProvider: ITokenProvider;
96
98
  private readonly _addressResolver: ChronicleServerAddressResolver;
@@ -223,8 +225,8 @@ export class ChronicleConnection implements ChronicleServices {
223
225
 
224
226
  async connect(): Promise<void> {
225
227
  await this._clientsReady;
226
- const deadline = new Date(Date.now() + (this._options.connectTimeout ?? 10_000));
227
- await waitForChannelReady(this._channel, deadline);
228
+ // The bounded compatibility RPC establishes transport readiness and contract support together.
229
+ await this._compatibility.verify();
228
230
  this._isConnected = true;
229
231
  }
230
232
 
@@ -276,12 +278,15 @@ export class ChronicleConnection implements ChronicleServices {
276
278
  this._channel = createChannel(serverAddress, credentials, channelOptions);
277
279
 
278
280
  const factory = createClientFactory().use(this.createAuthMiddleware());
281
+ this._connections = factory.create(ConnectionServiceDefinition, this._channel);
282
+ this._compatibility = new CompatibilityPreflight(this._connections, this._options.connectTimeout ?? 10_000);
283
+ const eventSequenceFactory = factory.use(this._compatibility.middleware());
279
284
  this._services = {
280
285
  eventStores: factory.create(EventStoresDefinition, this._channel),
281
286
  namespaces: factory.create(NamespacesDefinition, this._channel),
282
287
  recommendations: factory.create(RecommendationsDefinition, this._channel),
283
288
  identities: factory.create(IdentitiesDefinition, this._channel),
284
- eventSequences: factory.create(EventSequencesDefinition, this._channel),
289
+ eventSequences: eventSequenceFactory.create(EventSequencesDefinition, this._channel),
285
290
  eventTypes: factory.create(EventTypesDefinition, this._channel),
286
291
  constraints: factory.create(ConstraintsDefinition, this._channel),
287
292
  observers: factory.create(ObserversDefinition, this._channel),
@@ -300,7 +305,6 @@ export class ChronicleConnection implements ChronicleServices {
300
305
  compliance: factory.create(ComplianceDefinition as any, this._channel) as any,
301
306
  externalServices: factory.create(ExternalServicesDefinition, this._channel)
302
307
  };
303
- this._connections = factory.create(ConnectionServiceDefinition, this._channel);
304
308
  }
305
309
 
306
310
  private createTokenProvider(): ITokenProvider {
@@ -0,0 +1,62 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { createRequire } from 'node:module';
5
+ import { chronicleDescriptorSet, type ConnectionServiceClient } from '@cratis/chronicle.contracts';
6
+ import { ClientError, Status, type ClientMiddleware } from 'nice-grpc-common';
7
+ import { IncompatibleChronicleServer } from './IncompatibleChronicleServer';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const clientVersion = (require('@cratis/chronicle/package.json') as { version: string }).version;
11
+ const protocolVersion = (require('@cratis/chronicle.contracts/package.json') as { version: string }).version;
12
+
13
+ /** Verifies the installed wire contract before a channel can perform event-sequence operations. */
14
+ export class CompatibilityPreflight {
15
+ private _verification?: Promise<void>;
16
+
17
+ constructor(private readonly _connections: ConnectionServiceClient, private readonly _timeout: number) {}
18
+
19
+ /** Shares in-flight checks and caches verdicts for this channel; transient failures can be retried. */
20
+ verify(): Promise<void> {
21
+ if (!this._verification) {
22
+ const verification = this.check().catch(error => {
23
+ if (!(error instanceof IncompatibleChronicleServer) && this._verification === verification) {
24
+ this._verification = undefined;
25
+ }
26
+ throw error;
27
+ });
28
+ this._verification = verification;
29
+ }
30
+ return this._verification;
31
+ }
32
+
33
+ /** Gates even direct service calls that did not first call connect(). */
34
+ middleware(): ClientMiddleware {
35
+ const preflight = this;
36
+ return async function* compatibilityMiddleware(call, options) {
37
+ await preflight.verify();
38
+ return yield* call.next(call.request, options);
39
+ };
40
+ }
41
+
42
+ private async check(): Promise<void> {
43
+ if (!chronicleDescriptorSet?.length) {
44
+ throw new IncompatibleChronicleServer('The installed Chronicle contracts have no descriptor set; compatibility cannot be verified.');
45
+ }
46
+ const response = await this._connections.checkCompatibility({
47
+ ClientType: 'TypeScript',
48
+ ClientVersion: clientVersion,
49
+ ProtocolVersion: protocolVersion,
50
+ DescriptorSet: Buffer.from(chronicleDescriptorSet)
51
+ }, { signal: AbortSignal.timeout(this._timeout) }).catch(error => {
52
+ if (error instanceof ClientError && error.code === Status.UNIMPLEMENTED) {
53
+ throw new IncompatibleChronicleServer('The Chronicle server does not support contract compatibility checks; upgrade the kernel before connecting this client.');
54
+ }
55
+ throw error;
56
+ });
57
+ if (response?.IsCompatible !== true || !Array.isArray(response.Incompatibilities) || response.Incompatibilities.length !== 0) {
58
+ const reasons = response?.Incompatibilities?.join('; ') || 'missing, negative, or inconsistent compatibility verdict';
59
+ throw new IncompatibleChronicleServer(`Chronicle server ${response?.ServerVersion ?? 'unknown'} is incompatible with contracts ${protocolVersion}: ${reasons}`);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,10 @@
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
+ /** A permanent contract mismatch; reconnect backoff cannot make this operation safe. */
5
+ export class IncompatibleChronicleServer extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = 'IncompatibleChronicleServer';
9
+ }
10
+ }
@@ -23,6 +23,12 @@ describe('callResults', () => {
23
23
  });
24
24
  });
25
25
 
26
+ describe('when the server explicitly denies authorization without a message', () => {
27
+ it('should report failure', () => {
28
+ expect(isCallSuccess({ ...successfulResult, IsAuthorized: false })).toBe(false);
29
+ });
30
+ });
31
+
26
32
  describe('when checking a result with validation results', () => {
27
33
  it('should report failure', () => {
28
34
  expect(isCallSuccess({ ...successfulResult, ValidationResults: [{ Message: 'invalid', Members: [] }] })).toBe(false);
@@ -12,14 +12,12 @@ export interface CallValidationResult {
12
12
  /**
13
13
  * Structural shape shared by every Chronicle command and query result envelope.
14
14
  *
15
- * The envelopes also carry an `IsAuthorized` boolean, but it is deliberately not part of
16
- * this shape: the kernel declares it with a protobuf-net default of `true`, which means
17
- * the field is omitted from the wire whenever it is true. A proto3 client decodes that
18
- * absence as `false`, so the boolean always reads as `false` and carries no signal.
19
- * Authorization failures are reported through `AuthorizationFailureReason` and gRPC
20
- * status codes instead.
15
+ * Kernels from 18.4.1 transmit authorization verdicts explicitly, including true values.
16
+ * Older kernels omitted true values; the connection compatibility check must reject those kernels
17
+ * before operations rather than ignoring an authorization failure.
21
18
  */
22
19
  export interface CallResultLike {
20
+ IsAuthorized?: boolean;
23
21
  ValidationResults: CallValidationResult[];
24
22
  ExceptionMessages: string[];
25
23
  AuthorizationFailureReason?: string;
@@ -72,7 +70,7 @@ export class ChronicleCallFailed extends Error {
72
70
  * @returns True when the call succeeded, false otherwise.
73
71
  */
74
72
  export function isCallSuccess(result: CallResultLike): boolean {
75
- return !result.AuthorizationFailureReason &&
73
+ return result.IsAuthorized !== false && !result.AuthorizationFailureReason &&
76
74
  (result.ValidationResults ?? []).length === 0 &&
77
75
  (result.ExceptionMessages ?? []).length === 0;
78
76
  }
@@ -0,0 +1,148 @@
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 { createRequire } from 'node:module';
5
+ import { afterEach, beforeEach, chai, describe, it, vi } from 'vitest';
6
+ import { createServer, ServerError, Status, type CallContext, type ServiceImplementation } from 'nice-grpc';
7
+ import { chronicleDescriptorSet, ConnectionServiceDefinition, EventSequencesDefinition, type CompatibilityRequest } from '@cratis/chronicle.contracts';
8
+ import { ChronicleConnection } from '../../ChronicleConnection';
9
+ import { ChronicleClient } from '../../../ChronicleClient';
10
+ import { ChronicleOptions } from '../../../ChronicleOptions';
11
+ import { IncompatibleChronicleServer } from '../../IncompatibleChronicleServer';
12
+
13
+ chai.should();
14
+ const installedContractsVersion = (createRequire(import.meta.url)('@cratis/chronicle.contracts/package.json') as { version: string }).version;
15
+
16
+ describe('when checking server compatibility before append', () => {
17
+ let server: ReturnType<typeof createServer>;
18
+ let connection: ChronicleConnection;
19
+ let compatible: boolean;
20
+ let address: string;
21
+ let check: ReturnType<typeof vi.fn>;
22
+ let append: ReturnType<typeof vi.fn>;
23
+ let appendMany: ReturnType<typeof vi.fn>;
24
+ beforeEach(async () => {
25
+ compatible = true;
26
+ server = createServer();
27
+ check = vi.fn(async (_request: CompatibilityRequest, _context: CallContext) => ({
28
+ IsCompatible: compatible, Incompatibilities: compatible ? [] : ['missing field'], ServerVersion: 'server'
29
+ }));
30
+ append = vi.fn(async () => ({ Response: { SequenceNumber: 0n } }));
31
+ appendMany = vi.fn(async () => ({ Response: { SequenceNumbers: [0n] } }));
32
+ const unsupported = async () => { throw new ServerError(Status.UNIMPLEMENTED, 'Unexpected RPC in specification'); };
33
+ const connectionMethods = Object.fromEntries(Object.keys(ConnectionServiceDefinition.methods).map(name => [name, unsupported]));
34
+ const eventSequenceMethods = Object.fromEntries(Object.keys(EventSequencesDefinition.methods).map(name => [name, unsupported]));
35
+ server.add(ConnectionServiceDefinition, { ...connectionMethods, checkCompatibility: check } as ServiceImplementation<typeof ConnectionServiceDefinition>);
36
+ server.add(EventSequencesDefinition, { ...eventSequenceMethods, append, appendManyForEventSources: appendMany } as ServiceImplementation<typeof EventSequencesDefinition>);
37
+ const port = await server.listen('127.0.0.1:0');
38
+ address = `chronicle://127.0.0.1:${port}/?disableTls=true&apiKey=test-key`;
39
+ connection = new ChronicleConnection({ connectionString: address });
40
+ });
41
+ afterEach(async () => {
42
+ connection?.dispose();
43
+ await server.shutdown();
44
+ });
45
+ it('should send the installed descriptor and share one check across concurrent writes', async () => {
46
+ await connection.connect();
47
+ await Promise.all([connection.eventSequences.append({}), connection.eventSequences.appendManyForEventSources({})]);
48
+ check.mock.calls.should.have.lengthOf(1);
49
+ check.mock.calls[0][0].DescriptorSet.should.deep.equal(Buffer.from(chronicleDescriptorSet));
50
+ check.mock.calls[0][0].ProtocolVersion.should.equal(installedContractsVersion);
51
+ append.mock.calls.should.have.lengthOf(1);
52
+ appendMany.mock.calls.should.have.lengthOf(1);
53
+ });
54
+ it('should reject both append paths before writing even without connect', async () => {
55
+ compatible = false;
56
+ await connection.resetChannel();
57
+ const results = await Promise.allSettled([connection.eventSequences.append({}), connection.eventSequences.appendManyForEventSources({})]);
58
+ results.map(result => result.status).should.deep.equal(['rejected', 'rejected']);
59
+ check.mock.calls.should.have.lengthOf(1);
60
+ append.mock.calls.should.have.lengthOf(0);
61
+ appendMany.mock.calls.should.have.lengthOf(0);
62
+ connection.isConnected.should.equal(false);
63
+ });
64
+ it('should reject an unavailable compatibility endpoint without trying the append', async () => {
65
+ check.mockRejectedValue(new ServerError(Status.UNAVAILABLE, 'compatibility endpoint unavailable'));
66
+ await connection.resetChannel();
67
+ const results = await Promise.allSettled([connection.eventSequences.append({})]);
68
+ results[0].status.should.equal('rejected');
69
+ append.mock.calls.should.have.lengthOf(0);
70
+ });
71
+ it('should retry a transient failed check before allowing a later write', async () => {
72
+ check.mockRejectedValueOnce(new ServerError(Status.UNAVAILABLE, 'temporarily unavailable'));
73
+ await connection.resetChannel();
74
+ const failed = await Promise.allSettled([connection.eventSequences.append({})]);
75
+ failed[0].status.should.equal('rejected');
76
+ append.mock.calls.should.have.lengthOf(0);
77
+ await connection.eventSequences.append({});
78
+ check.mock.calls.should.have.lengthOf(2);
79
+ append.mock.calls.should.have.lengthOf(1);
80
+ });
81
+ it('should fail a high-level client connection instead of retrying an incompatible server forever', async () => {
82
+ compatible = false;
83
+ const client = new ChronicleClient(ChronicleOptions.fromConnectionString(address, { discoveryPatterns: [] }));
84
+ try {
85
+ const results = await Promise.allSettled([client.getEventStore('store')]);
86
+ results[0].status.should.equal('rejected');
87
+ if (results[0].status === 'rejected') results[0].reason.should.be.instanceOf(IncompatibleChronicleServer);
88
+ check.mock.calls.should.have.lengthOf(1);
89
+ append.mock.calls.should.have.lengthOf(0);
90
+ } finally {
91
+ client.dispose();
92
+ }
93
+ });
94
+ it('should reject a missing compatibility RPC permanently rather than reconnect forever', async () => {
95
+ check.mockRejectedValue(new ServerError(Status.UNIMPLEMENTED, 'compatibility not supported'));
96
+ const client = new ChronicleClient(ChronicleOptions.fromConnectionString(address, { discoveryPatterns: [] }));
97
+ try {
98
+ const results = await Promise.allSettled([client.getEventStore('store')]);
99
+ results[0].status.should.equal('rejected');
100
+ if (results[0].status === 'rejected') results[0].reason.should.be.instanceOf(IncompatibleChronicleServer);
101
+ check.mock.calls.should.have.lengthOf(1);
102
+ append.mock.calls.should.have.lengthOf(0);
103
+ } finally {
104
+ client.dispose();
105
+ }
106
+ });
107
+ it('should retain an incompatible verdict until the channel is replaced', async () => {
108
+ compatible = false;
109
+ await connection.resetChannel();
110
+ await Promise.allSettled([connection.eventSequences.append({})]);
111
+ const results = await Promise.allSettled([connection.eventSequences.append({})]);
112
+ results[0].status.should.equal('rejected');
113
+ check.mock.calls.should.have.lengthOf(1);
114
+ append.mock.calls.should.have.lengthOf(0);
115
+ compatible = true;
116
+ await connection.reconnect();
117
+ await connection.eventSequences.append({});
118
+ check.mock.calls.should.have.lengthOf(2);
119
+ append.mock.calls.should.have.lengthOf(1);
120
+ });
121
+ it('should bound a stalled compatibility check without allowing writes', async () => {
122
+ check.mockImplementation((_request, context) => new Promise((_resolve, reject) => {
123
+ context.signal.addEventListener('abort', () => reject(new ServerError(Status.CANCELLED, 'cancelled')), { once: true });
124
+ }));
125
+ connection.dispose();
126
+ connection = new ChronicleConnection({ connectionString: address, connectTimeout: 50 });
127
+ const results = await Promise.allSettled([connection.connect()]);
128
+ results[0].status.should.equal('rejected');
129
+ connection.isConnected.should.be.false;
130
+ append.mock.calls.should.have.lengthOf(0);
131
+ });
132
+ it('should reject a contradictory compatibility verdict', async () => {
133
+ check.mockResolvedValue({ IsCompatible: true, Incompatibilities: ['missing field'], ServerVersion: 'server' });
134
+ await connection.resetChannel();
135
+ const failed = await Promise.allSettled([connection.eventSequences.append({})]);
136
+ failed[0].status.should.equal('rejected');
137
+ append.mock.calls.should.have.lengthOf(0);
138
+ });
139
+ it('should recheck after reconnect and refuse a newly incompatible channel', async () => {
140
+ await connection.connect();
141
+ compatible = false;
142
+ const results = await Promise.allSettled([connection.reconnect()]);
143
+ results[0].status.should.equal('rejected');
144
+ check.mock.calls.should.have.lengthOf(2);
145
+ connection.isConnected.should.equal(false);
146
+ append.mock.calls.should.have.lengthOf(0);
147
+ });
148
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { beforeEach, chai, describe, it, vi } from 'vitest';
5
+ import { CompatibilityResponse, type ConnectionServiceClient } from '@cratis/chronicle.contracts';
6
+ import { CompatibilityPreflight } from '../../CompatibilityPreflight';
7
+
8
+ chai.should();
9
+
10
+ describe('when verifying with concurrent calls', () => {
11
+ let check: ReturnType<typeof vi.fn>;
12
+ let preflight: CompatibilityPreflight;
13
+ let first: Promise<void>;
14
+ let second: Promise<void>;
15
+ let complete: (response: CompatibilityResponse) => void;
16
+
17
+ beforeEach(() => {
18
+ const response = new Promise<CompatibilityResponse>(resolve => { complete = resolve; });
19
+ check = vi.fn(() => response);
20
+ preflight = new CompatibilityPreflight({ checkCompatibility: check } as unknown as ConnectionServiceClient, 1000);
21
+ first = preflight.verify();
22
+ second = preflight.verify();
23
+ });
24
+
25
+ it('should share the same flight and issue exactly one RPC', async () => {
26
+ first.should.equal(second);
27
+ check.mock.calls.should.have.lengthOf(1);
28
+ complete(CompatibilityResponse.create({ IsCompatible: true }));
29
+ await Promise.all([first, second]);
30
+ });
31
+
32
+ it('should retain the successful verdict for later calls', async () => {
33
+ complete(CompatibilityResponse.create({ IsCompatible: true }));
34
+ await Promise.all([first, second]);
35
+ await preflight.verify();
36
+ check.mock.calls.should.have.lengthOf(1);
37
+ });
38
+ });
@@ -31,6 +31,7 @@ export declare class ChronicleClient implements IChronicleClient {
31
31
  private _isDisposed;
32
32
  private _keepAliveAbortController?;
33
33
  private _healthCheckInFlight;
34
+ private _connectionFailure?;
34
35
  /**
35
36
  * Creates a new {@link ChronicleClient} using the provided options.
36
37
  * @param options - The options to configure the client, including the connection string.
@@ -57,6 +58,8 @@ export declare class ChronicleClient implements IChronicleClient {
57
58
  private startConnectionWatchdog;
58
59
  private runHealthCheck;
59
60
  private startKernelKeepAlive;
61
+ private failConnection;
62
+ private backgroundConnectionFailed;
60
63
  private registerArtifactsForStore;
61
64
  }
62
65
  //# sourceMappingURL=ChronicleClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ChronicleClient.d.ts","sourceRoot":"","sources":["../ChronicleClient.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK5C;;;;;;;;;;;;GAYG;AACH,qBAAa,eAAgB,YAAW,gBAAgB;IAwBxC,QAAQ,CAAC,OAAO,EAAE,gBAAgB;IAvB9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IACtD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAU;IAE/C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IAExD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAErB;IAEH,OAAO,CAAC,eAAe,CAAC,CAAiC;IACzD,OAAO,CAAC,iBAAiB,CAAC,CAAgB;IAC1C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,yBAAyB,CAAC,CAAkB;IACpD,OAAO,CAAC,oBAAoB,CAAS;IAErC;;;OAGG;gBACkB,OAAO,EAAE,gBAAgB;IAgD9C,kBAAkB;IACZ,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,uBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC;IA0DtH,kBAAkB;IACZ,cAAc,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IA0BjD,kBAAkB;IAClB,OAAO,IAAI,IAAI;YAsBD,gBAAgB;YA0ChB,eAAe;YAkBf,SAAS;IA0CvB;;;;OAIG;YACW,OAAO;YAcP,aAAa;IAa3B,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,uBAAuB;YAQjB,cAAc;YAkBd,oBAAoB;YAqCpB,yBAAyB;CAmB1C"}
1
+ {"version":3,"file":"ChronicleClient.d.ts","sourceRoot":"","sources":["../ChronicleClient.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAOtD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK5C;;;;;;;;;;;;GAYG;AACH,qBAAa,eAAgB,YAAW,gBAAgB;IAyBxC,QAAQ,CAAC,OAAO,EAAE,gBAAgB;IAxB9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IACtD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAU;IAE/C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IAExD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAErB;IAEH,OAAO,CAAC,eAAe,CAAC,CAAiC;IACzD,OAAO,CAAC,iBAAiB,CAAC,CAAgB;IAC1C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,yBAAyB,CAAC,CAAkB;IACpD,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,kBAAkB,CAAC,CAAQ;IAEnC;;;OAGG;gBACkB,OAAO,EAAE,gBAAgB;IAgD9C,kBAAkB;IACZ,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,uBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC;IA0DtH,kBAAkB;IACZ,cAAc,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IA0BjD,kBAAkB;IAClB,OAAO,IAAI,IAAI;YAsBD,gBAAgB;YA0ChB,eAAe;YAuBf,SAAS;IAiDvB;;;;OAIG;YACW,OAAO;YAcP,aAAa;IAa3B,OAAO,CAAC,eAAe;IAyBvB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,uBAAuB;YAQjB,cAAc;YAkBd,oBAAoB;IAqClC,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,0BAA0B;YAQpB,yBAAyB;CAmB1C"}