@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.
- package/ChronicleClient.ts +42 -9
- package/connection/ChronicleConnection.ts +9 -5
- package/connection/CompatibilityPreflight.ts +62 -0
- package/connection/IncompatibleChronicleServer.ts +10 -0
- package/connection/callResults.spec.ts +6 -0
- package/connection/callResults.ts +5 -7
- package/connection/for_ChronicleConnection/when_checking_compatibility/before_append.spec.ts +148 -0
- package/connection/for_CompatibilityPreflight/when_verifying/with_concurrent_calls.spec.ts +38 -0
- package/dist/ChronicleClient.d.ts +3 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +41 -9
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +1 -0
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +9 -5
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/CompatibilityPreflight.d.ts +15 -0
- package/dist/connection/CompatibilityPreflight.d.ts.map +1 -0
- package/dist/connection/CompatibilityPreflight.js +61 -0
- package/dist/connection/CompatibilityPreflight.js.map +1 -0
- package/dist/connection/IncompatibleChronicleServer.d.ts +5 -0
- package/dist/connection/IncompatibleChronicleServer.d.ts.map +1 -0
- package/dist/connection/IncompatibleChronicleServer.js +10 -0
- package/dist/connection/IncompatibleChronicleServer.js.map +1 -0
- package/dist/connection/callResults.d.ts +4 -6
- package/dist/connection/callResults.d.ts.map +1 -1
- package/dist/connection/callResults.js +1 -1
- package/dist/connection/callResults.js.map +1 -1
- package/dist/eventSequences/AppendOptions.d.ts +11 -1
- package/dist/eventSequences/AppendOptions.d.ts.map +1 -1
- package/dist/eventSequences/EventForEventSourceId.d.ts +6 -4
- package/dist/eventSequences/EventForEventSourceId.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts +1 -1
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +29 -38
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/IEventSequence.d.ts +12 -3
- package/dist/eventSequences/IEventSequence.d.ts.map +1 -1
- package/dist/events/CausationEntry.d.ts +2 -0
- package/dist/events/CausationEntry.d.ts.map +1 -1
- package/dist/events/EventContext.d.ts +19 -0
- package/dist/events/EventContext.d.ts.map +1 -1
- package/dist/events/WireEventContext.d.ts +11 -0
- package/dist/events/WireEventContext.d.ts.map +1 -0
- package/dist/events/WireEventContext.js +4 -0
- package/dist/events/WireEventContext.js.map +1 -0
- package/dist/events/toClientEventContext.d.ts +5 -0
- package/dist/events/toClientEventContext.d.ts.map +1 -0
- package/dist/events/toClientEventContext.js +37 -0
- package/dist/events/toClientEventContext.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +2 -16
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +3 -16
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/AppendOptions.ts +16 -1
- package/eventSequences/EventForEventSourceId.ts +7 -4
- package/eventSequences/EventSequence.spec.ts +4 -4
- package/eventSequences/EventSequence.ts +29 -42
- package/eventSequences/IEventSequence.ts +12 -3
- package/eventSequences/for_EventSequence/when_appending/with_metadata.spec.ts +131 -0
- package/eventSequences/for_EventSequence/when_reading/with_route_filters.spec.ts +124 -0
- package/events/CausationEntry.ts +3 -0
- package/events/EventContext.ts +28 -0
- package/events/WireEventContext.ts +14 -0
- package/events/for_EventContext/when_receiving/with_kernel_metadata.spec.ts +109 -0
- package/events/toClientEventContext.ts +45 -0
- package/for_ChronicleClient/when_reconnecting/and_the_server_becomes_incompatible.spec.ts +95 -0
- package/index.ts +1 -0
- package/package.json +5 -4
- package/reactors/ReactorSideEffects.spec.ts +2 -0
- package/reactors/Reactors.ts +2 -17
- package/reducers/Reducers.ts +3 -17
- package/vitest.config.ts +1 -1
package/ChronicleClient.ts
CHANGED
|
@@ -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 —
|
|
223
|
-
//
|
|
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
|
|
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
|
-
|
|
227
|
-
await
|
|
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:
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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;
|
|
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"}
|