Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
```typescript
import { NamespacesDefinition } from '@cratis/chronicle.contracts';

const getNamespacesMethod = NamespacesDefinition.methods.getNamespaces;
const allNamespacesMethod = NamespacesDefinition.methods.allNamespaces;

console.log(`${NamespacesDefinition.fullName}.${getNamespacesMethod.name}`);
console.log(`${NamespacesDefinition.fullName}.${allNamespacesMethod.name}`);
```
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
```typescript
import { EnsureEventStore, EnsureNamespace, GetNamespacesRequest } from '@cratis/chronicle.contracts';
import { EnsureEventStoreRequest, EnsureNamespaceRequest, AllNamespacesRequest } from '@cratis/chronicle.contracts';

const eventStore = EnsureEventStore.create({ Name: 'shopping' });
const namespace = EnsureNamespace.create({ EventStore: eventStore.Name, Name: 'tenant-one' });
const namespaces = GetNamespacesRequest.create({ EventStore: eventStore.Name });
const eventStore = EnsureEventStoreRequest.create({ Name: 'shopping' });
const namespace = EnsureNamespaceRequest.create({ EventStore: eventStore.Name, Namespace: 'tenant-one' });
const namespaces = AllNamespacesRequest.create({ EventStore: eventStore.Name });

console.log(eventStore, namespace, namespaces);
```
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
import { EventStoresClient, NamespacesClient } from '@cratis/chronicle.contracts';

async function readAvailableNamespaces(eventStores: EventStoresClient, namespaces: NamespacesClient): Promise<string[]> {
await eventStores.ensure({ Name: 'shopping' });
const result = await namespaces.getNamespaces({ EventStore: 'shopping' });
await eventStores.ensureEventStore({ Name: 'shopping' });

return result.items;
// Queries stream results so they can also be observed; one-shot callers take the first result.
for await (const result of namespaces.allNamespaces({ EventStore: 'shopping' })) {
return result.Data;
}

return [];
}
```
4 changes: 2 additions & 2 deletions Documentation/client-snippets/jobs/index/get-one.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
```typescript
import { IEventStore } from '@cratis/chronicle';
import { Job } from '@cratis/chronicle.contracts';
import { JobSummaryResponse } from '@cratis/chronicle.contracts';

class JobsIndexGetOne {
constructor(private readonly store: IEventStore) {}

async getJob(jobId: string): Promise<Job | undefined> {
async getJob(jobId: string): Promise<JobSummaryResponse | undefined> {
return this.store.jobs.getJob(jobId);
}
}
Expand Down
4 changes: 2 additions & 2 deletions Documentation/client-snippets/jobs/index/get-steps.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
```typescript
import { IEventStore } from '@cratis/chronicle';
import { JobStep } from '@cratis/chronicle.contracts';
import { JobStepSummaryResponse } from '@cratis/chronicle.contracts';

class JobsIndexGetSteps {
constructor(private readonly store: IEventStore) {}

async getSteps(jobId: string): Promise<JobStep[]> {
async getSteps(jobId: string): Promise<JobStepSummaryResponse[]> {
return this.store.jobs.getJobSteps(jobId);
}
}
Expand Down
4 changes: 2 additions & 2 deletions Documentation/client-snippets/jobs/index/list-all.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
```typescript
import { IEventStore } from '@cratis/chronicle';
import { Job } from '@cratis/chronicle.contracts';
import { JobSummaryResponse } from '@cratis/chronicle.contracts';

class JobsIndexListAll {
constructor(private readonly store: IEventStore) {}

async getAllJobs(): Promise<Job[]> {
async getAllJobs(): Promise<JobSummaryResponse[]> {
return this.store.jobs.getJobs();
}
}
Expand Down
7 changes: 4 additions & 3 deletions Source/ChronicleClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { diag } from '@opentelemetry/api';
import { SpanStatusCode } from '@opentelemetry/api';
import { ChronicleOptions } from './ChronicleOptions';
import { ChronicleConnection } from './connection';
import { ensureCommandSuccess, ensureQuerySuccess, firstQueryResult } from './connection/callResults';
import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
import { KernelKeepAlive } from './connection/KernelKeepAlive';
import { EventStore } from './EventStore';
Expand Down Expand Up @@ -131,7 +132,7 @@ export class ChronicleClient implements IChronicleClient {
this._logger.debug('Ensuring event store exists in kernel', {
eventStore: storeName.value
});
await this._connection.eventStores.ensure({ Name: storeName.value });
ensureCommandSuccess('ensure event store', await this._connection.eventStores.ensureEventStore({ Name: storeName.value }));

const created = new EventStore(storeName, namespaceName, this._connection, this._lifecycle, this.options.defaultSinkTypeId);
this._stores.set(key, created);
Expand Down Expand Up @@ -167,9 +168,9 @@ export class ChronicleClient implements IChronicleClient {
try {
const response = await this.withReconnect('get_event_stores', async () => {
await this.ensureConnected();
return this._connection.eventStores.getEventStores({});
return firstQueryResult('get event stores', this._connection.eventStores.allEventStores({}));
});
const result = (response.items ?? []).map((name: string) => new EventStoreName(name));
const result = ensureQuerySuccess('get event stores', response).map((name: string) => new EventStoreName(name));
this._logger.verbose('Retrieved event stores from kernel', {
count: result.length
});
Expand Down
5 changes: 3 additions & 2 deletions Source/EventStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { diag } from '@opentelemetry/api';
import { SpanStatusCode } from '@opentelemetry/api';
import { ChronicleConnection } from './connection';
import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
import { ensureQuerySuccess, firstQueryResult } from './connection/callResults';
import { EventLog } from './eventSequences/EventLog';
import { EventSequence } from './eventSequences/EventSequence';
import { EventSequenceId } from './eventSequences/EventSequenceId';
Expand Down Expand Up @@ -163,8 +164,8 @@ export class EventStore implements IEventStore {
return ChronicleTracer.startActiveSpan('chronicle.event_store.get_namespaces', async span => {
span.setAttribute('chronicle.event_store', this.name.value);
try {
const response = await this._connection.namespaces.getNamespaces({ EventStore: this.name.value });
const result = (response.items ?? []).map((namespace: string) => new EventStoreNamespaceName(namespace));
const response = await firstQueryResult('get namespaces', this._connection.namespaces.allNamespaces({ EventStore: this.name.value }));
const result = ensureQuerySuccess('get namespaces', response).map((namespace: string) => new EventStoreNamespaceName(namespace));
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
Expand Down
90 changes: 90 additions & 0 deletions Source/connection/callResults.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { describe, expect, it } from 'vitest';
import { ChronicleCallFailed, ensureCommandSuccess, ensureQuerySuccess, firstQueryResult, isCallSuccess } from './callResults';

const successfulResult = {
ValidationResults: [],
ExceptionMessages: [],
AuthorizationFailureReason: ''
};

describe('callResults', () => {
describe('when checking a successful result', () => {
it('should report success', () => {
expect(isCallSuccess(successfulResult)).toBe(true);
});
});

describe('when checking a result with an authorization failure reason', () => {
it('should report failure', () => {
expect(isCallSuccess({ ...successfulResult, AuthorizationFailureReason: 'no access' })).toBe(false);
});
});

describe('when checking a result with validation results', () => {
it('should report failure', () => {
expect(isCallSuccess({ ...successfulResult, ValidationResults: [{ Message: 'invalid', Members: [] }] })).toBe(false);
});
});

describe('when checking a result with exception messages', () => {
it('should report failure', () => {
expect(isCallSuccess({ ...successfulResult, ExceptionMessages: ['boom'] })).toBe(false);
});
});

describe('when ensuring a successful command', () => {
it('should not throw', () => {
expect(() => ensureCommandSuccess('operation', successfulResult)).not.toThrow();
});
});

describe('when ensuring a failed command', () => {
it('should throw with the failure reasons', () => {
expect(() => ensureCommandSuccess('operation', { ...successfulResult, ExceptionMessages: ['boom'] }))
.toThrow(ChronicleCallFailed);
expect(() => ensureCommandSuccess('operation', { ...successfulResult, ExceptionMessages: ['boom'] }))
.toThrow(/operation.*boom/);
});
});

describe('when ensuring a successful query', () => {
it('should return the data', () => {
expect(ensureQuerySuccess('operation', { ...successfulResult, Data: ['a', 'b'] })).toEqual(['a', 'b']);
});
});

describe('when ensuring a failed query', () => {
it('should throw with the failure reasons', () => {
expect(() => ensureQuerySuccess('operation', { ...successfulResult, AuthorizationFailureReason: 'no access', Data: [] }))
.toThrow(/no access/);
});
});

describe('when taking the first result from a stream', () => {
it('should return the first item and cancel the stream', async () => {
let cleanedUp = false;
async function* stream() {
try {
yield 'first';
yield 'second';
} finally {
cleanedUp = true;
}
}

const result = await firstQueryResult('operation', stream());
expect(result).toBe('first');
expect(cleanedUp).toBe(true);
});
});

describe('when taking the first result from an empty stream', () => {
it('should throw', async () => {
async function* stream() { /* produces nothing */ }
await expect(firstQueryResult('operation', stream())).rejects.toThrow(/completed without producing a result/);
});
});
});
112 changes: 112 additions & 0 deletions Source/connection/callResults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* Shape of a validation result carried by Chronicle command and query results.
*/
export interface CallValidationResult {
Message: string;
Members: string[];
}

/**
* Structural shape shared by every Chronicle command and query result envelope.
*
* The envelopes also carry an `IsAuthorized` boolean, but it is deliberately not part of
* this shape: the kernel declares it with a protobuf-net default of `true`, which means
* the field is omitted from the wire whenever it is true. A proto3 client decodes that
* absence as `false`, so the boolean always reads as `false` and carries no signal.
* Authorization failures are reported through `AuthorizationFailureReason` and gRPC
* status codes instead.
*/
export interface CallResultLike {
ValidationResults: CallValidationResult[];
ExceptionMessages: string[];
AuthorizationFailureReason?: string;
}

/**
* Structural shape shared by every Chronicle query result envelope.
*/
export interface QueryResultLike<TData> extends CallResultLike {
Data: TData;
}

/**
* Error thrown when a Chronicle command or query did not succeed.
*/
export class ChronicleCallFailed extends Error {
/**
* Creates a new {@link ChronicleCallFailed}.
* @param operation - The operation that failed.
* @param result - The result envelope returned by the kernel.
*/
constructor(operation: string, result: CallResultLike) {
const reasons: string[] = [];
if (result.AuthorizationFailureReason) {
reasons.push(result.AuthorizationFailureReason);
}
for (const validationResult of result.ValidationResults ?? []) {
reasons.push(validationResult.Message);
}
for (const exceptionMessage of result.ExceptionMessages ?? []) {
reasons.push(exceptionMessage);
}

super(`Chronicle operation '${operation}' failed: ${reasons.join(', ') || 'unknown reason'}`);
this.name = 'ChronicleCallFailed';
}
}

/**
* Determines whether a Chronicle result envelope represents success: no authorization
* failure reason, no validation results, and no exceptions.
* @param result - The result envelope to check.
* @returns True when the call succeeded, false otherwise.
*/
export function isCallSuccess(result: CallResultLike): boolean {
return !result.AuthorizationFailureReason &&
(result.ValidationResults ?? []).length === 0 &&
(result.ExceptionMessages ?? []).length === 0;
}

/**
* Ensures a Chronicle command executed successfully, throwing when it did not.
* @param operation - The operation the result belongs to, used for error reporting.
* @param result - The command result envelope returned by the kernel.
*/
export function ensureCommandSuccess(operation: string, result: CallResultLike): void {
if (!isCallSuccess(result)) {
throw new ChronicleCallFailed(operation, result);
}
}

/**
* Ensures a Chronicle query executed successfully, returning its data or throwing when it did not.
* @param operation - The operation the result belongs to, used for error reporting.
* @param result - The query result envelope returned by the kernel.
* @returns The data produced by the query.
*/
export function ensureQuerySuccess<TData>(operation: string, result: QueryResultLike<TData>): TData {
if (!isCallSuccess(result)) {
throw new ChronicleCallFailed(operation, result);
}

return result.Data;
}

/**
* Takes the first result from a server-streaming Chronicle query and cancels the stream.
* Chronicle streams queries so they can also be observed; one-shot callers only need the
* first snapshot.
* @param operation - The operation the stream belongs to, used for error reporting.
* @param stream - The server-streaming query response.
* @returns The first result produced by the stream.
*/
export async function firstQueryResult<TResult>(operation: string, stream: AsyncIterable<TResult>): Promise<TResult> {
for await (const result of stream) {
return result;
}

throw new Error(`Chronicle operation '${operation}' completed without producing a result.`);
}
6 changes: 3 additions & 3 deletions Source/events/constraints/Constraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class Constraints implements IConstraints {
return {
Name: capture.name,
Type: ConstraintType.Unique,
RemovedWith: uc.removedWithEventTypeId ?? '',
RemovedWith: uc.removedWithEventTypeId ? [uc.removedWithEventTypeId] : [],
Definition: {
Value0: {
EventDefinitions: uc.eventDefinitions.map(ed => ({
Expand All @@ -80,7 +80,7 @@ export class Constraints implements IConstraints {
return {
Name: uet.name ?? capture.name,
Type: ConstraintType.UniqueEventType,
RemovedWith: '',
RemovedWith: [],
Definition: {
Value0: undefined,
Value1: {
Expand All @@ -94,7 +94,7 @@ export class Constraints implements IConstraints {
return {
Name: capture.name,
Type: ConstraintType.Unknown,
RemovedWith: '',
RemovedWith: [],
Definition: undefined,
Scope: scope
};
Expand Down
8 changes: 4 additions & 4 deletions Source/jobs/IJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { Guid } from '@cratis/fundamentals';
import type { Job, JobStep } from '@cratis/chronicle.contracts';
import type { JobSummaryResponse, JobStepSummaryResponse } from '@cratis/chronicle.contracts';
import { JobId } from './JobId';

/**
Expand Down Expand Up @@ -32,18 +32,18 @@ export interface IJobs {
* @param jobId - The job identifier.
* @returns The job, or undefined when not found.
*/
getJob(jobId: JobId | Guid | string): Promise<Job | undefined>;
getJob(jobId: JobId | Guid | string): Promise<JobSummaryResponse | undefined>;

/**
* Gets all jobs for the event store namespace.
* @returns All jobs.
*/
getJobs(): Promise<Job[]>;
getJobs(): Promise<JobSummaryResponse[]>;

/**
* Gets all steps for a specific job.
* @param jobId - The job identifier.
* @returns The job steps.
*/
getJobSteps(jobId: JobId | Guid | string): Promise<JobStep[]>;
getJobSteps(jobId: JobId | Guid | string): Promise<JobStepSummaryResponse[]>;
}
Loading
Loading