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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ await client.verifyApiToken();

EventSourcingDB offers its v2 API since version 1.3, while older versions offer only the v1 API. This client SDK works with both. Creating a client does not send a request. Instead, the first request of a client asks the server whether it offers the v2 API, by sending a `GET` request to `/api/v2/system/ping`, and the client uses the v2 API if it does, and the v1 API otherwise. The client keeps the answer, so every later request goes straight to the API it found. If the server answers a path of the v2 API with 404 or 405, for example after it was rolled back to a version without it, the client asks again on the next request. If the client cannot find out, for example because the server is not reachable, the first request fails with that error, and the next request asks again.

A client that found only the v1 API asks again before a write with a recursive precondition, since the server may have been upgraded meanwhile, see [Using Preconditions Recursively](#using-preconditions-recursively). Every other request goes on through the v1 API meanwhile, without waiting. If the server offers the v2 API now, the client uses it from then on. If the client cannot find out, the write fails with that error, the client goes on with the v1 API, and only the next write with a recursive precondition asks again.

Through the v2 API, all functions that read use the HTTP method `QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)), which, unlike `GET`, carries a request body. If there is a proxy or a load balancer in front of EventSourcingDB, it has to let `QUERY` requests through. There is no fallback to `POST`.

### Configuring the Stall Timeout
Expand Down Expand Up @@ -154,7 +156,7 @@ try {
}
```

A write certainly was not stored, and throws the error itself, only if its request never went out, for example since the client SDK could not establish a connection, or since the write was aborted or stalled while connecting, or if the server refused the write before storing anything. A request counts as gone out once its headers have been sent. Through the v2 API, the server refuses a write with every error that it describes in its answer, except one with the code `ErrorCode.storeFailed`, and with the HTTP status `400`, `401`, `408`, `413`, `429`, or `503`. Through the v1 API, it refuses a write with an HTTP status of `4xx`, `503`, or `507`. A write that the server refuses with the HTTP status `429` may be sent again after `retryAfter` seconds.
A write certainly was not stored, and throws the error itself, only if its request never went out, for example since the client SDK refused to send it and threw an `IncompatibleServerError`, or could not establish a connection, or since the write was aborted or stalled while connecting, or if the server refused the write before storing anything. A request counts as gone out once its headers have been sent. Through the v2 API, the server refuses a write with every error that it describes in its answer, except one with the code `ErrorCode.storeFailed`, and with the HTTP status `400`, `401`, `408`, `413`, `429`, or `503`. Through the v1 API, it refuses a write with an HTTP status of `4xx`, `503`, or `507`. A write that the server refuses with the HTTP status `429` may be sent again after `retryAfter` seconds.

### Writing Events

Expand Down Expand Up @@ -223,6 +225,24 @@ const writtenEvents = await client.writeEvents([

*Note that according to the CloudEvents standard, event IDs must be of type string.*

#### Using Preconditions Recursively

If you want the `isSubjectPristine`, `isSubjectPopulated`, or `isSubjectOnEventId` precondition to apply not only to a subject (such as `/books/42`), but also to all its nested subjects, pass an options object as the last argument and set the `recursive` option to `true`. Then `isSubjectPristine` holds if neither the subject nor any of its nested subjects has an event, `isSubjectPopulated` holds if any of them has one, and `isSubjectOnEventId` holds if the last event among all of them has the given ID:

```typescript
import { isSubjectPristine } from 'eventsourcingdb';

const writtenEvents = await client.writeEvents([
// events
], [
isSubjectPristine('/books/42', { recursive: true })
]);
```

For `isSubjectOnEventId`, the options object is the third argument, as in `isSubjectOnEventId('/books/42', '0', { recursive: true })`. Without an options object, or with the `recursive` option set to `false`, a precondition applies to the subject alone.

*Note that only the v2 API checks a precondition recursively. If the client found that the server does not offer the v2 API, it asks the server again before a write with a recursive precondition, since the server may have been upgraded meanwhile, and sends the write through the v2 API if the server offers it now. If it still does not, the write throws an `IncompatibleServerError` before it is sent, so nothing is written. If the client cannot find out, for example because the server is not reachable, the write throws that error, again without being sent. Without the v2 API, the client SDK takes any options other than `{ recursive: false }` as recursive, for example an empty object, since they may have been meant that way.*

#### Using the `isEventQlQueryTrue` precondition

If you want to write events depending on an EventQL query, import the `isEventQlQueryTrue` function and pass it as an array of preconditions in the second argument:
Expand Down
38 changes: 37 additions & 1 deletion src/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EventCandidate } from './EventCandidate.js';
import type { EventType } from './EventType.js';
import { isAbortError } from './http/isAbortError.js';
import { waitUnlessAborted } from './http/waitUnlessAborted.js';
import { IncompatibleServerError } from './IncompatibleServerError.js';
import type { ObserveEventsOptions } from './ObserveEventsOptions.js';
import type { Precondition } from './Precondition.js';
import type { ReadEventsOptions } from './ReadEventsOptions.js';
Expand Down Expand Up @@ -67,6 +68,14 @@ class Client {

signal?.throwIfAborted();

return await this.#findOut(signal);
}

// Finds out which API the server offers, and keeps the transport for it, as
// soon as the server answers. Everyone who asks meanwhile shares the same
// request and its answer. A request that fails leaves the transport that
// the client keeps as it is.
async #findOut(signal?: AbortSignal): Promise<Transport> {
if (this.#detection === undefined) {
const detection = detectApiVersion(this.#transportOptions).then(apiVersion => {
this.#transport =
Expand Down Expand Up @@ -122,13 +131,40 @@ class Client {
// A write that fails once it may have reached the server fails with an
// OutcomeUnknownError, since it may have been stored nonetheless. This
// includes aborting the signal once the write has been sent.
//
// The v1 API cannot carry a write with a recursive precondition, so its
// transport refuses such a write before sending it. The server may offer
// the v2 API by now, for example after an upgrade, so the client finds out
// again, as on its first request, and sends the write through the API it
// finds. If that is still the v1 API, the write fails with an
// IncompatibleServerError, again without being sent, and if the client
// fails to find out, the write fails with that error. Every other request
// meanwhile goes on with the transport that the client keeps, without
// waiting, and keeps it if finding out fails. If the client replaced the
// transport since the write started, the write uses the new one rather
// than find out once more. Since the first attempt never sends the write,
// it is sent at most once.
public async writeEvents(
events: EventCandidate[],
preconditions: Precondition[] = [],
signal?: AbortSignal,
): Promise<Event[]> {
const transport = await this.#getTransport(signal);
return await transport.writeEvents(events, preconditions, signal);

try {
return await transport.writeEvents(events, preconditions, signal);
} catch (error) {
if (!(error instanceof IncompatibleServerError)) {
throw error;
}
}

const currentTransport =
this.#transport === transport
? await this.#findOut(signal)
: await this.#getTransport(signal);

return await currentTransport.writeEvents(events, preconditions, signal);
}

public readEvents(
Expand Down
12 changes: 12 additions & 0 deletions src/IncompatibleServerError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// A request that the server cannot handle as asked, since it does not offer
// what the request needs, as the client SDK found out again right before. The
// client SDK does not send such a request, rather than have the server handle
// it otherwise.
class IncompatibleServerError extends Error {
public constructor() {
super('The server does not offer the v2 API, which a recursive precondition needs.');
this.name = 'IncompatibleServerError';
}
}

export { IncompatibleServerError };
5 changes: 5 additions & 0 deletions src/Precondition.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type { PreconditionOptions } from './PreconditionOptions.js';

interface IsSubjectPristinePrecondition {
subject: string;
options?: PreconditionOptions;
}

interface IsSubjectPopulatedPrecondition {
subject: string;
options?: PreconditionOptions;
}

interface IsSubjectOnEventIdPrecondition {
subject: string;
eventId: string;
options?: PreconditionOptions;
}

interface IsEventQlQueryTruePrecondition {
Expand Down
7 changes: 7 additions & 0 deletions src/PreconditionOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface PreconditionOptions {
// Whether the precondition applies to the subjects below the subject as
// well, or only to the subject itself.
recursive: boolean;
}

export type { PreconditionOptions };
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import type { Event } from './Event.js';
import type { EventCandidate } from './EventCandidate.js';
import type { EventType } from './EventType.js';
import { HashVerificationFailedError } from './HashVerificationFailedError.js';
import { IncompatibleServerError } from './IncompatibleServerError.js';
import { isEventQlQueryTrue } from './isEventQlQueryTrue.js';
import { isSubjectOnEventId } from './isSubjectOnEventId.js';
import { isSubjectPopulated } from './isSubjectPopulated.js';
import { isSubjectPristine } from './isSubjectPristine.js';
import type { ObserveEventsOptions } from './ObserveEventsOptions.js';
import { OutcomeUnknownError } from './OutcomeUnknownError.js';
import type { Precondition } from './Precondition.js';
import type { PreconditionOptions } from './PreconditionOptions.js';
import type { ReadEventsOptions } from './ReadEventsOptions.js';
import type { ReadSubjectsOptions } from './ReadSubjectsOptions.js';
import { SignatureVerificationFailedError } from './SignatureVerificationFailedError.js';
Expand All @@ -29,6 +31,7 @@ export type {
EventType,
ObserveEventsOptions,
Precondition,
PreconditionOptions,
ReadEventsOptions,
ReadSubjectsOptions,
};
Expand All @@ -39,6 +42,7 @@ export {
DbApiError,
ErrorCode,
HashVerificationFailedError,
IncompatibleServerError,
isEventQlQueryTrue,
isSubjectOnEventId,
isSubjectPopulated,
Expand Down
10 changes: 8 additions & 2 deletions src/isSubjectOnEventId.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { Precondition } from './Precondition.js';
import type { PreconditionOptions } from './PreconditionOptions.js';
import { withPreconditionOptions } from './withPreconditionOptions.js';

const isSubjectOnEventId = (subject: string, eventId: string): Precondition => ({
const isSubjectOnEventId = (
subject: string,
eventId: string,
options?: PreconditionOptions,
): Precondition => ({
type: 'isSubjectOnEventId',
payload: { subject, eventId },
payload: { subject, eventId, ...withPreconditionOptions(options) },
});

export { isSubjectOnEventId };
6 changes: 4 additions & 2 deletions src/isSubjectPopulated.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Precondition } from './Precondition.js';
import type { PreconditionOptions } from './PreconditionOptions.js';
import { withPreconditionOptions } from './withPreconditionOptions.js';

const isSubjectPopulated = (subject: string): Precondition => ({
const isSubjectPopulated = (subject: string, options?: PreconditionOptions): Precondition => ({
type: 'isSubjectPopulated',
payload: { subject },
payload: { subject, ...withPreconditionOptions(options) },
});

export { isSubjectPopulated };
6 changes: 4 additions & 2 deletions src/isSubjectPristine.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Precondition } from './Precondition.js';
import type { PreconditionOptions } from './PreconditionOptions.js';
import { withPreconditionOptions } from './withPreconditionOptions.js';

const isSubjectPristine = (subject: string): Precondition => ({
const isSubjectPristine = (subject: string, options?: PreconditionOptions): Precondition => ({
type: 'isSubjectPristine',
payload: { subject },
payload: { subject, ...withPreconditionOptions(options) },
});

export { isSubjectPristine };
Loading
Loading