@cratis/chronicle 2.0.0 → 2.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 (96) hide show
  1. package/ChronicleClient.ts +69 -60
  2. package/connection/ChronicleConnection.ts +32 -9
  3. package/connection/ChronicleConnectionString.spec.ts +227 -0
  4. package/connection/ChronicleConnectionString.ts +290 -40
  5. package/connection/ChronicleServerAddressResolver.spec.ts +41 -0
  6. package/connection/ChronicleServerAddressResolver.ts +33 -0
  7. package/connection/ChronicleSrvResolutionError.ts +13 -0
  8. package/connection/ChronicleSrvResolver.spec.ts +103 -0
  9. package/connection/ChronicleSrvResolver.ts +57 -0
  10. package/connection/ConnectionLifecycle.spec.ts +109 -0
  11. package/connection/ILoadBalancerStrategy.ts +16 -0
  12. package/connection/KernelKeepAlive.spec.ts +175 -0
  13. package/connection/KernelKeepAlive.ts +161 -0
  14. package/connection/LeastConnectionsLoadBalancerStrategy.spec.ts +159 -0
  15. package/connection/LeastConnectionsLoadBalancerStrategy.ts +117 -0
  16. package/connection/LoadBalancerMode.ts +25 -0
  17. package/connection/LoadBalancerStrategyFactory.ts +28 -0
  18. package/connection/RandomLoadBalancerStrategy.spec.ts +50 -0
  19. package/connection/RandomLoadBalancerStrategy.ts +19 -0
  20. package/connection/RoundRobinLoadBalancerStrategy.spec.ts +61 -0
  21. package/connection/RoundRobinLoadBalancerStrategy.ts +29 -0
  22. package/connection/TokenProvider.ts +5 -4
  23. package/connection/formatServerAddress.ts +16 -0
  24. package/dist/ChronicleClient.d.ts +8 -1
  25. package/dist/ChronicleClient.d.ts.map +1 -1
  26. package/dist/ChronicleClient.js +63 -56
  27. package/dist/ChronicleClient.js.map +1 -1
  28. package/dist/connection/ChronicleConnection.d.ts +4 -1
  29. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  30. package/dist/connection/ChronicleConnection.js +29 -9
  31. package/dist/connection/ChronicleConnection.js.map +1 -1
  32. package/dist/connection/ChronicleConnectionString.d.ts +77 -3
  33. package/dist/connection/ChronicleConnectionString.d.ts.map +1 -1
  34. package/dist/connection/ChronicleConnectionString.js +251 -37
  35. package/dist/connection/ChronicleConnectionString.js.map +1 -1
  36. package/dist/connection/ChronicleServerAddressResolver.d.ts +23 -0
  37. package/dist/connection/ChronicleServerAddressResolver.d.ts.map +1 -0
  38. package/dist/connection/ChronicleServerAddressResolver.js +32 -0
  39. package/dist/connection/ChronicleServerAddressResolver.js.map +1 -0
  40. package/dist/connection/ChronicleSrvResolutionError.d.ts +8 -0
  41. package/dist/connection/ChronicleSrvResolutionError.d.ts.map +1 -0
  42. package/dist/connection/ChronicleSrvResolutionError.js +13 -0
  43. package/dist/connection/ChronicleSrvResolutionError.js.map +1 -0
  44. package/dist/connection/ChronicleSrvResolver.d.ts +28 -0
  45. package/dist/connection/ChronicleSrvResolver.d.ts.map +1 -0
  46. package/dist/connection/ChronicleSrvResolver.js +51 -0
  47. package/dist/connection/ChronicleSrvResolver.js.map +1 -0
  48. package/dist/connection/ILoadBalancerStrategy.d.ts +13 -0
  49. package/dist/connection/ILoadBalancerStrategy.d.ts.map +1 -0
  50. package/dist/connection/ILoadBalancerStrategy.js +4 -0
  51. package/dist/connection/ILoadBalancerStrategy.js.map +1 -0
  52. package/dist/connection/KernelKeepAlive.d.ts +73 -0
  53. package/dist/connection/KernelKeepAlive.d.ts.map +1 -0
  54. package/dist/connection/KernelKeepAlive.js +115 -0
  55. package/dist/connection/KernelKeepAlive.js.map +1 -0
  56. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts +30 -0
  57. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts.map +1 -0
  58. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js +95 -0
  59. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js.map +1 -0
  60. package/dist/connection/LoadBalancerMode.d.ts +21 -0
  61. package/dist/connection/LoadBalancerMode.d.ts.map +1 -0
  62. package/dist/connection/LoadBalancerMode.js +24 -0
  63. package/dist/connection/LoadBalancerMode.js.map +1 -0
  64. package/dist/connection/LoadBalancerStrategyFactory.d.ts +11 -0
  65. package/dist/connection/LoadBalancerStrategyFactory.d.ts.map +1 -0
  66. package/dist/connection/LoadBalancerStrategyFactory.js +26 -0
  67. package/dist/connection/LoadBalancerStrategyFactory.js.map +1 -0
  68. package/dist/connection/RandomLoadBalancerStrategy.d.ts +9 -0
  69. package/dist/connection/RandomLoadBalancerStrategy.d.ts.map +1 -0
  70. package/dist/connection/RandomLoadBalancerStrategy.js +15 -0
  71. package/dist/connection/RandomLoadBalancerStrategy.js.map +1 -0
  72. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts +12 -0
  73. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts.map +1 -0
  74. package/dist/connection/RoundRobinLoadBalancerStrategy.js +22 -0
  75. package/dist/connection/RoundRobinLoadBalancerStrategy.js.map +1 -0
  76. package/dist/connection/TokenProvider.d.ts +2 -1
  77. package/dist/connection/TokenProvider.d.ts.map +1 -1
  78. package/dist/connection/TokenProvider.js +6 -4
  79. package/dist/connection/TokenProvider.js.map +1 -1
  80. package/dist/connection/formatServerAddress.d.ts +10 -0
  81. package/dist/connection/formatServerAddress.d.ts.map +1 -0
  82. package/dist/connection/formatServerAddress.js +14 -0
  83. package/dist/connection/formatServerAddress.js.map +1 -0
  84. package/dist/reactors/Reactors.d.ts +13 -0
  85. package/dist/reactors/Reactors.d.ts.map +1 -1
  86. package/dist/reactors/Reactors.js +42 -4
  87. package/dist/reactors/Reactors.js.map +1 -1
  88. package/dist/reducers/Reducers.d.ts +14 -0
  89. package/dist/reducers/Reducers.d.ts.map +1 -1
  90. package/dist/reducers/Reducers.js +43 -8
  91. package/dist/reducers/Reducers.js.map +1 -1
  92. package/dist/tsconfig.tsbuildinfo +1 -1
  93. package/package.json +8 -5
  94. package/reactors/Reactors.ts +53 -4
  95. package/reducers/Reducers.ts +55 -8
  96. package/vitest.config.ts +12 -0
@@ -1,11 +1,13 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ import * as os from 'os';
4
5
  import { diag } from '@opentelemetry/api';
5
6
  import { SpanStatusCode } from '@opentelemetry/api';
6
7
  import { ChronicleOptions } from './ChronicleOptions';
7
8
  import { ChronicleConnection } from './connection';
8
9
  import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
10
+ import { KernelKeepAlive } from './connection/KernelKeepAlive';
9
11
  import { EventStore } from './EventStore';
10
12
  import { EventStoreName } from './EventStoreName';
11
13
  import { EventStoreNamespaceName } from './EventStoreNamespaceName';
@@ -30,6 +32,7 @@ import { TypeDiscoverer } from './types';
30
32
  */
31
33
  export class ChronicleClient implements IChronicleClient {
32
34
  private static readonly _healthCheckIntervalMs = 5000;
35
+ private static readonly _maxBackoffMs = 30_000;
33
36
 
34
37
  private readonly _connection: ChronicleConnection;
35
38
  private readonly _stores: Map<string, EventStore> = new Map();
@@ -45,6 +48,7 @@ export class ChronicleClient implements IChronicleClient {
45
48
  private _discoveryOperation?: Promise<void>;
46
49
  private _isDisposed = false;
47
50
  private _keepAliveAbortController?: AbortController;
51
+ private _healthCheckInFlight = false;
48
52
 
49
53
  /**
50
54
  * Creates a new {@link ChronicleClient} using the provided options.
@@ -212,15 +216,16 @@ export class ChronicleClient implements IChronicleClient {
212
216
 
213
217
  while (!this._isDisposed) {
214
218
  try {
215
- if (attempt > 0) {
216
- // Recreate the gRPC channel so we start from IDLE. A failed
217
- // probe can leave the channel in TRANSIENT_FAILURE, which gRPC
218
- // won't recover without a fresh channel. The contracts connect()
219
- // is also bypassed here it uses watchConnectivityState and
220
- // rejects as soon as the state changes to CONNECTING (not READY),
221
- // making it unreliable for initial connection establishment.
222
- this._connection.resetChannel();
223
- }
219
+ // Resolve (DNS SRV, when applicable) and select (load balancer strategy) a
220
+ // server address and rebuild the gRPC channel from scratch on every attempt,
221
+ // including the first not just once at startup — so membership and load
222
+ // changes are always picked up. This also guarantees a fresh IDLE channel: a
223
+ // failed probe can leave a channel in TRANSIENT_FAILURE, which gRPC won't
224
+ // recover from without a new channel. The contracts connect() is bypassed
225
+ // here it uses watchConnectivityState and rejects as soon as the state
226
+ // changes to CONNECTING (not READY), making it unreliable for initial
227
+ // connection establishment.
228
+ await this._connection.resetChannel();
224
229
 
225
230
  this._logger.debug('Connecting to Chronicle kernel', { attempt: attempt + 1 });
226
231
 
@@ -240,13 +245,8 @@ export class ChronicleClient implements IChronicleClient {
240
245
 
241
246
  } catch (error) {
242
247
  attempt++;
243
- const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
244
- this._logger.warn('Connection attempt failed, retrying', {
245
- attempt,
246
- delayMs,
247
- error: this.toErrorMessage(error)
248
- });
249
- await new Promise(resolve => setTimeout(resolve, delayMs));
248
+ const delayMs = await this.backOff(attempt, 'Connection attempt failed, retrying', error);
249
+ this._logger.verbose('Backed off before next connection attempt', { attempt, delayMs });
250
250
  }
251
251
  }
252
252
 
@@ -290,7 +290,7 @@ export class ChronicleClient implements IChronicleClient {
290
290
  let attempt = 0;
291
291
  while (!this._isDisposed) {
292
292
  try {
293
- this._connection.resetChannel();
293
+ await this._connection.resetChannel();
294
294
  await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
295
295
  this._logger.info('Reconnected to Chronicle kernel', { attempt: attempt + 1 });
296
296
  await this.startKernelKeepAlive();
@@ -302,13 +302,7 @@ export class ChronicleClient implements IChronicleClient {
302
302
  return;
303
303
  } catch (reconnectError) {
304
304
  attempt++;
305
- const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
306
- this._logger.warn('Reconnect attempt failed, retrying', {
307
- attempt,
308
- delayMs,
309
- error: this.toErrorMessage(reconnectError)
310
- });
311
- await new Promise(resolve => setTimeout(resolve, delayMs));
305
+ await this.backOff(attempt, 'Reconnect attempt failed, retrying', reconnectError);
312
306
  }
313
307
  }
314
308
  })().finally(() => {
@@ -319,6 +313,25 @@ export class ChronicleClient implements IChronicleClient {
319
313
  await this._reconnectOperation;
320
314
  }
321
315
 
316
+ /**
317
+ * Waits out the exponential backoff for a failed attempt and reports why.
318
+ * The delay is jittered so a fleet of clients that lost the same kernel does
319
+ * not come back in lockstep and knock it over again.
320
+ */
321
+ private async backOff(attempt: number, message: string, error: unknown): Promise<number> {
322
+ const ceiling = Math.min(1000 * Math.pow(2, attempt - 1), ChronicleClient._maxBackoffMs);
323
+ const delayMs = Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
324
+
325
+ this._logger.warn(message, {
326
+ attempt,
327
+ delayMs,
328
+ error: this.toErrorMessage(error)
329
+ });
330
+
331
+ await new Promise(resolve => setTimeout(resolve, delayMs));
332
+ return delayMs;
333
+ }
334
+
322
335
  private async withReconnect<T>(operation: string, action: () => Promise<T>): Promise<T> {
323
336
  try {
324
337
  return await action();
@@ -373,15 +386,20 @@ export class ChronicleClient implements IChronicleClient {
373
386
  }
374
387
 
375
388
  private async runHealthCheck(): Promise<void> {
376
- if (this._isDisposed || !this._lifecycle.isConnected) {
389
+ // A black-holed connection makes the probe itself hang, so without the
390
+ // in-flight guard every tick would stack another one that never returns.
391
+ if (this._isDisposed || !this._lifecycle.isConnected || this._healthCheckInFlight) {
377
392
  return;
378
393
  }
379
394
 
395
+ this._healthCheckInFlight = true;
380
396
  try {
381
- await this._connection.server.getVersionInfo({});
397
+ await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
382
398
  this._logger.verbose('Connection health check passed');
383
399
  } catch (error) {
384
400
  await this.reconnect('watchdog-health-check', error);
401
+ } finally {
402
+ this._healthCheckInFlight = false;
385
403
  }
386
404
  }
387
405
 
@@ -390,45 +408,36 @@ export class ChronicleClient implements IChronicleClient {
390
408
  this._keepAliveAbortController = new AbortController();
391
409
  const { signal } = this._keepAliveAbortController;
392
410
 
393
- const keepAliveStream = this._connection.connections.connect(
394
- { ConnectionId: this._lifecycle.connectionId, ClientVersion: '1.0.0', IsRunningWithDebugger: false },
395
- { signal }
411
+ // Losing the keep-alive means the kernel has stopped counting us as
412
+ // connected, so observers are already being torn down server-side.
413
+ // Reconnecting is the only way back — without this the client sits on a
414
+ // dead session, appends keep working, and reactors never fire again.
415
+ const keepAlive = new KernelKeepAlive(this._connection.connections, (reason, error) => {
416
+ if (this._isDisposed || signal.aborted) {
417
+ return;
418
+ }
419
+
420
+ void this.reconnect(reason, error);
421
+ });
422
+
423
+ await keepAlive.start(
424
+ {
425
+ ConnectionId: this._lifecycle.connectionId,
426
+ // TODO: Not derived from this package's own version anywhere yet; kept as
427
+ // the pre-existing hardcoded placeholder until such a mechanism exists.
428
+ ClientVersion: '1.0.0',
429
+ IsRunningWithDebugger: false,
430
+ ProcessId: process.pid,
431
+ ProcessPath: process.execPath,
432
+ MachineName: os.hostname(),
433
+ ClientType: 'TypeScript'
434
+ },
435
+ signal
396
436
  );
397
- const iterator = keepAliveStream[Symbol.asyncIterator]();
398
- const firstResult = await iterator.next();
399
- if (firstResult.done) {
400
- throw new Error('Connection service stream ended before sending first keep-alive');
401
- }
402
437
 
403
- await this._connection.connections.connectionKeepAlive(firstResult.value);
404
438
  this._logger.info('Client registered with kernel keep-alive mechanism', {
405
439
  connectionId: this._lifecycle.connectionId
406
440
  });
407
-
408
- void this.runKeepAliveLoop(iterator, signal);
409
- }
410
-
411
- private async runKeepAliveLoop(iterator: AsyncIterator<unknown, void>, signal: AbortSignal): Promise<void> {
412
- try {
413
- while (!signal.aborted) {
414
- const result = await iterator.next();
415
- if (result.done) {
416
- this._logger.info('Keep-alive stream ended');
417
- break;
418
- }
419
-
420
- await this._connection.connections.connectionKeepAlive(result.value as object);
421
- this._logger.verbose('Keep-alive ping responded', {
422
- connectionId: this._lifecycle.connectionId
423
- });
424
- }
425
- } catch (err) {
426
- if (!signal.aborted) {
427
- this._logger.warn('Keep-alive loop ended with error', {
428
- error: this.toErrorMessage(err)
429
- });
430
- }
431
- }
432
441
  }
433
442
 
434
443
  private async registerArtifactsForStore(store: EventStore, reason: string): Promise<void> {
@@ -30,7 +30,11 @@ import type { ClientMiddleware } from 'nice-grpc-common';
30
30
  import { Metadata } from 'nice-grpc-common';
31
31
  import { EventStoreSubscriptionsDefinition } from '../eventStoreSubscriptions/contracts';
32
32
  import { AuthenticationMode, ChronicleConnectionString } from './ChronicleConnectionString';
33
+ import { ChronicleServerAddressResolver } from './ChronicleServerAddressResolver';
33
34
  import { ChronicleServices } from './ChronicleServices';
35
+ import { formatServerAddress } from './formatServerAddress';
36
+ import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
37
+ import { createLoadBalancerStrategy } from './LoadBalancerStrategyFactory';
34
38
  import { ITokenProvider, NoOpTokenProvider, OAuthTokenProvider } from './TokenProvider';
35
39
 
36
40
  /**
@@ -87,7 +91,10 @@ export class ChronicleConnection implements ChronicleServices {
87
91
  private _connections!: ConnectionServiceClient;
88
92
  private readonly _connectionString: ChronicleConnectionString;
89
93
  private readonly _tokenProvider: ITokenProvider;
94
+ private readonly _addressResolver: ChronicleServerAddressResolver;
95
+ private readonly _loadBalancerStrategy: ILoadBalancerStrategy;
90
96
  private _isConnected = false;
97
+ private _clientsReady: Promise<void>;
91
98
 
92
99
  constructor(private readonly _options: ChronicleConnectionOptions) {
93
100
  if (_options.connectionString) {
@@ -101,7 +108,15 @@ export class ChronicleConnection implements ChronicleServices {
101
108
  }
102
109
 
103
110
  this._tokenProvider = this.createTokenProvider();
104
- this.createClients();
111
+ this._addressResolver = new ChronicleServerAddressResolver();
112
+ this._loadBalancerStrategy = createLoadBalancerStrategy(this._connectionString.loadBalancer, this._connectionString.skipTlsValidation);
113
+
114
+ this._clientsReady = this.createClients();
115
+ // Building the initial channel is async (address resolution + load balancer
116
+ // selection), so the constructor cannot await it. Real failures still surface to
117
+ // callers that await connect()/resetChannel(); this only prevents an unhandled
118
+ // rejection warning from the fire-and-forget initial build.
119
+ this._clientsReady.catch(() => {});
105
120
  }
106
121
 
107
122
  get connectionString(): ChronicleConnectionString {
@@ -197,37 +212,39 @@ export class ChronicleConnection implements ChronicleServices {
197
212
  }
198
213
 
199
214
  async connect(): Promise<void> {
215
+ await this._clientsReady;
200
216
  const deadline = new Date(Date.now() + (this._options.connectTimeout ?? 10_000));
201
217
  await waitForChannelReady(this._channel, deadline);
202
218
  this._isConnected = true;
203
219
  }
204
220
 
205
- resetChannel(): void {
221
+ async resetChannel(): Promise<void> {
206
222
  try {
207
- this._channel.close();
223
+ this._channel?.close();
208
224
  } catch {
209
225
  // Best-effort shutdown before recreating the channel.
210
226
  }
211
227
 
212
228
  this._isConnected = false;
213
- this.createClients();
229
+ this._clientsReady = this.createClients();
230
+ await this._clientsReady;
214
231
  }
215
232
 
216
233
  async reconnect(): Promise<void> {
217
- this.resetChannel();
234
+ await this.resetChannel();
218
235
  await this.connect();
219
236
  }
220
237
 
221
238
  disconnect(): void {
222
239
  this._isConnected = false;
223
- this._channel.close();
240
+ this._channel?.close();
224
241
  }
225
242
 
226
243
  dispose(): void {
227
244
  this.disconnect();
228
245
  }
229
246
 
230
- private createClients(): void {
247
+ private async createClients(): Promise<void> {
231
248
  const channelOptions: ChannelOptions = {};
232
249
 
233
250
  if (this._options.maxReceiveMessageSize !== undefined) {
@@ -238,7 +255,12 @@ export class ChronicleConnection implements ChronicleServices {
238
255
  channelOptions['grpc.max_send_message_length'] = this._options.maxSendMessageSize;
239
256
  }
240
257
 
241
- const serverAddress = `${this._connectionString.serverAddress.host}:${this._connectionString.serverAddress.port}`;
258
+ // Re-resolved (DNS SRV, when applicable) and re-selected (load balancer strategy)
259
+ // on every call, so every connect/reconnect attempt picks up membership and load
260
+ // changes rather than pinning to whatever was selected at startup.
261
+ const candidates = await this._addressResolver.resolve(this._connectionString);
262
+ const selected = await this._loadBalancerStrategy.select(candidates);
263
+ const serverAddress = formatServerAddress(selected);
242
264
  const credentials = this._options.credentials ?? this._connectionString.createCredentials();
243
265
 
244
266
  this._channel = createChannel(serverAddress, credentials, channelOptions);
@@ -312,7 +334,8 @@ export class ChronicleConnection implements ChronicleServices {
312
334
  return new OAuthTokenProvider(
313
335
  `${scheme}://${authorityHost}:${authorityPort}/connect/token`,
314
336
  username,
315
- password
337
+ password,
338
+ this._connectionString.skipTlsValidation
316
339
  );
317
340
  }
318
341
 
@@ -0,0 +1,227 @@
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 { describe, expect, it, vi } from 'vitest';
5
+ import { AuthenticationMode, ChronicleConnectionString, ChronicleConnectionStringBuilder } from './ChronicleConnectionString';
6
+ import { LoadBalancerMode } from './LoadBalancerMode';
7
+
8
+ describe('ChronicleConnectionStringBuilder', () => {
9
+ describe('when parsing a single host', () => {
10
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000');
11
+
12
+ it('should have the host', () => expect(builder.host).toBe('localhost'));
13
+ it('should have the port', () => expect(builder.port).toBe(35000));
14
+ it('should have one server address', () => expect(builder.hosts).toEqual([{ host: 'localhost', port: 35000 }]));
15
+ it('should not be srv', () => expect(builder.isSrv).toBe(false));
16
+ });
17
+
18
+ describe('when parsing a host without an explicit port', () => {
19
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost');
20
+
21
+ it('should default the port to 35000', () => expect(builder.port).toBe(35000));
22
+ });
23
+
24
+ describe('when parsing multiple hosts', () => {
25
+ const builder = new ChronicleConnectionStringBuilder('chronicle://host1:35001,host2,host3:35003');
26
+
27
+ it('should have the first host as host', () => expect(builder.host).toBe('host1'));
28
+ it('should have the first port as port', () => expect(builder.port).toBe(35001));
29
+ it('should have three server addresses', () => expect(builder.hosts).toHaveLength(3));
30
+ it('should have the first server address', () => expect(builder.hosts[0]).toEqual({ host: 'host1', port: 35001 }));
31
+ it('should default the second host port', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35000 }));
32
+ it('should have the third server address', () => expect(builder.hosts[2]).toEqual({ host: 'host3', port: 35003 }));
33
+ });
34
+
35
+ describe('when parsing multiple hosts with authentication and options', () => {
36
+ const builder = new ChronicleConnectionStringBuilder('chronicle://admin:secret@host1,host2:35002/?skipTlsValidation=true');
37
+
38
+ it('should have the username', () => expect(builder.username).toBe('admin'));
39
+ it('should have the password', () => expect(builder.password).toBe('secret'));
40
+ it('should have two server addresses', () => expect(builder.hosts).toHaveLength(2));
41
+ it('should default the first host port', () => expect(builder.hosts[0]).toEqual({ host: 'host1', port: 35000 }));
42
+ it('should have the second server address', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35002 }));
43
+ it('should skip tls validation', () => expect(builder.skipTlsValidation).toBe(true));
44
+ });
45
+
46
+ describe('when parsing an IPv6 host in a multi-host list', () => {
47
+ const builder = new ChronicleConnectionStringBuilder('chronicle://[::1]:35001,host2');
48
+
49
+ it('should have the bracketed host without brackets', () => expect(builder.hosts[0]).toEqual({ host: '::1', port: 35001 }));
50
+ it('should have the second host', () => expect(builder.hosts[1]).toEqual({ host: 'host2', port: 35000 }));
51
+ });
52
+
53
+ describe('when parsing an IPv6 host without an explicit port', () => {
54
+ const builder = new ChronicleConnectionStringBuilder('chronicle://[::1]');
55
+
56
+ it('should default the port', () => expect(builder.hosts[0]).toEqual({ host: '::1', port: 35000 }));
57
+ });
58
+
59
+ describe('when parsing a srv url', () => {
60
+ const builder = new ChronicleConnectionStringBuilder('chronicle+srv://cluster.example.com');
61
+
62
+ it('should have the srv scheme', () => expect(builder.scheme).toBe('chronicle+srv'));
63
+ it('should be srv', () => expect(builder.isSrv).toBe(true));
64
+ it('should have the correct host', () => expect(builder.host).toBe('cluster.example.com'));
65
+ it('should have the default port', () => expect(builder.port).toBe(35000));
66
+ });
67
+
68
+ describe('when parsing a srv url with multiple hosts', () => {
69
+ it('should throw', () => expect(() => new ChronicleConnectionStringBuilder('chronicle+srv://host1,host2')).toThrow());
70
+ });
71
+
72
+ describe('when parsing a srv url with a name server', () => {
73
+ const builder = new ChronicleConnectionStringBuilder('chronicle+srv://cluster.example.com/?srvNameServer=127.0.0.1:5353');
74
+
75
+ it('should be srv', () => expect(builder.isSrv).toBe(true));
76
+ it('should have the name server', () => expect(builder.srvNameServer).toBe('127.0.0.1:5353'));
77
+ it('should include the name server when building', () =>
78
+ expect(builder.build()).toBe('chronicle+srv://cluster.example.com:35000?srvNameServer=127.0.0.1%3A5353'));
79
+ });
80
+
81
+ describe('when parsing a url with a trailing slash before the query string', () => {
82
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?apiKey=my-key');
83
+
84
+ it('should still parse the host', () => expect(builder.host).toBe('localhost'));
85
+ it('should still parse the query string', () => expect(builder.apiKey).toBe('my-key'));
86
+ });
87
+
88
+ describe('when parsing a url with a load balancer option', () => {
89
+ const builder = new ChronicleConnectionStringBuilder('chronicle://host1,host2/?loadBalancer=random');
90
+
91
+ it('should have the load balancer', () => expect(builder.loadBalancer).toBe(LoadBalancerMode.Random));
92
+ it('should include the load balancer when building', () =>
93
+ expect(builder.build()).toBe('chronicle://host1:35000,host2:35000?loadBalancer=random'));
94
+ });
95
+
96
+ describe('when no load balancer option is given', () => {
97
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000');
98
+
99
+ it('should default to least-connections', () => expect(builder.loadBalancer).toBe(LoadBalancerMode.LeastConnections));
100
+ });
101
+
102
+ describe('when parsing a url with an unknown load balancer option', () => {
103
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?loadBalancer=nonsense');
104
+
105
+ it('should throw', () => expect(() => builder.loadBalancer).toThrow());
106
+ });
107
+
108
+ describe('when building a connection string with multiple hosts', () => {
109
+ const builder = new ChronicleConnectionStringBuilder();
110
+ builder.hosts = [{ host: 'host1', port: 35001 }, { host: 'host2', port: 35000 }];
111
+
112
+ it('should include all hosts in the url', () => expect(builder.build()).toBe('chronicle://host1:35001,host2:35000'));
113
+ it('should round-trip through parsing', () => expect(new ChronicleConnectionStringBuilder(builder.build()).hosts).toEqual(builder.hosts));
114
+ });
115
+
116
+ describe('when building a connection string with an IPv6 host', () => {
117
+ const builder = new ChronicleConnectionStringBuilder();
118
+ builder.hosts = [{ host: '::1', port: 35000 }];
119
+
120
+ it('should bracket the IPv6 host', () => expect(builder.build()).toBe('chronicle://[::1]:35000'));
121
+ });
122
+
123
+ describe('when building a connection string with tls validation not skipped', () => {
124
+ const builder = new ChronicleConnectionStringBuilder();
125
+ builder.host = 'localhost';
126
+ builder.port = 35000;
127
+ builder.skipTlsValidation = false;
128
+
129
+ it('should include skipTlsValidation in the query string', () => expect(builder.build()).toBe('chronicle://localhost:35000?skipTlsValidation=false'));
130
+ });
131
+
132
+ describe('when setting host after hosts were set to multiple servers', () => {
133
+ const builder = new ChronicleConnectionStringBuilder();
134
+ builder.hosts = [{ host: 'host1', port: 35001 }, { host: 'host2', port: 35002 }];
135
+ builder.host = 'newhost';
136
+
137
+ it('should collapse to a single server', () => expect(builder.hosts).toEqual([{ host: 'newhost', port: 35001 }]));
138
+ });
139
+
140
+ describe('when getting authentication mode with client credentials', () => {
141
+ const builder = new ChronicleConnectionStringBuilder('chronicle://user:pass@localhost:35000');
142
+
143
+ it('should be client credentials', () => expect(builder.authenticationMode).toBe(AuthenticationMode.ClientCredentials));
144
+ });
145
+
146
+ describe('when getting authentication mode with an api key', () => {
147
+ const builder = new ChronicleConnectionStringBuilder('chronicle://localhost:35000/?apiKey=my-key');
148
+
149
+ it('should be api key', () => expect(builder.authenticationMode).toBe(AuthenticationMode.ApiKey));
150
+ });
151
+
152
+ describe('when getting authentication mode with both credentials and an api key', () => {
153
+ const builder = new ChronicleConnectionStringBuilder('chronicle://user:pass@localhost:35000/?apiKey=my-key');
154
+
155
+ it('should throw', () => expect(() => builder.authenticationMode).toThrow());
156
+ });
157
+ });
158
+
159
+ describe('ChronicleConnectionString', () => {
160
+ describe('when parsing a connection string with multiple hosts', () => {
161
+ const connectionString = new ChronicleConnectionString('chronicle://host1:35001,host2:35002');
162
+
163
+ it('should have all server addresses', () =>
164
+ expect(connectionString.serverAddresses).toEqual([{ host: 'host1', port: 35001 }, { host: 'host2', port: 35002 }]));
165
+ it('should have the first as the convenience server address', () =>
166
+ expect(connectionString.serverAddress).toEqual({ host: 'host1', port: 35001 }));
167
+ });
168
+
169
+ describe('when using the development connection string', () => {
170
+ const { Development } = ChronicleConnectionString;
171
+
172
+ it('should skip tls validation', () => expect(Development.skipTlsValidation).toBe(true));
173
+ it('should use the development client credentials', () => expect(Development.username).toBe(ChronicleConnectionString.DEVELOPMENT_CLIENT));
174
+ });
175
+
176
+ describe('when creating credentials without skipTlsValidation specified', () => {
177
+ it('should skip certificate chain validation by default', async () => {
178
+ vi.resetModules();
179
+ const createSsl = vi.fn().mockReturnValue('insecure-tls-credentials');
180
+ const createInsecure = vi.fn();
181
+ vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
182
+
183
+ const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
184
+ const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000');
185
+ const credentials = connectionString.createCredentials();
186
+
187
+ expect(createSsl).toHaveBeenCalledWith(null, null, null, { rejectUnauthorized: false });
188
+ expect(credentials).toBe('insecure-tls-credentials');
189
+ vi.doUnmock('@grpc/grpc-js');
190
+ });
191
+ });
192
+
193
+ describe('when creating credentials with skipTlsValidation explicitly false', () => {
194
+ it('should validate the certificate chain', async () => {
195
+ vi.resetModules();
196
+ const createSsl = vi.fn().mockReturnValue('secure-credentials');
197
+ const createInsecure = vi.fn();
198
+ vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
199
+
200
+ const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
201
+ const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000/?skipTlsValidation=false');
202
+ const credentials = connectionString.createCredentials();
203
+
204
+ expect(createSsl).toHaveBeenCalledWith();
205
+ expect(credentials).toBe('secure-credentials');
206
+ vi.doUnmock('@grpc/grpc-js');
207
+ });
208
+ });
209
+
210
+ describe('when creating credentials with disableTls', () => {
211
+ it('should create insecure credentials', async () => {
212
+ vi.resetModules();
213
+ const createSsl = vi.fn();
214
+ const createInsecure = vi.fn().mockReturnValue('plaintext-credentials');
215
+ vi.doMock('@grpc/grpc-js', () => ({ credentials: { createSsl, createInsecure } }));
216
+
217
+ const { ChronicleConnectionString: MockedChronicleConnectionString } = await import('./ChronicleConnectionString');
218
+ const connectionString = new MockedChronicleConnectionString('chronicle://localhost:35000/?disableTls=true');
219
+ const credentials = connectionString.createCredentials();
220
+
221
+ expect(createInsecure).toHaveBeenCalled();
222
+ expect(createSsl).not.toHaveBeenCalled();
223
+ expect(credentials).toBe('plaintext-credentials');
224
+ vi.doUnmock('@grpc/grpc-js');
225
+ });
226
+ });
227
+ });