diff --git a/README.md b/README.md index 265fdc1..8be95a5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: diff --git a/src/Client.ts b/src/Client.ts index 0fc8126..fe7a0ba 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -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'; @@ -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 { if (this.#detection === undefined) { const detection = detectApiVersion(this.#transportOptions).then(apiVersion => { this.#transport = @@ -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 { 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( diff --git a/src/IncompatibleServerError.ts b/src/IncompatibleServerError.ts new file mode 100644 index 0000000..b193ae7 --- /dev/null +++ b/src/IncompatibleServerError.ts @@ -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 }; diff --git a/src/Precondition.ts b/src/Precondition.ts index 0cc45ca..521860b 100644 --- a/src/Precondition.ts +++ b/src/Precondition.ts @@ -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 { diff --git a/src/PreconditionOptions.ts b/src/PreconditionOptions.ts new file mode 100644 index 0000000..1ada4ed --- /dev/null +++ b/src/PreconditionOptions.ts @@ -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 }; diff --git a/src/index.ts b/src/index.ts index 8b9b5d0..7559223 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ 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'; @@ -16,6 +17,7 @@ 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'; @@ -29,6 +31,7 @@ export type { EventType, ObserveEventsOptions, Precondition, + PreconditionOptions, ReadEventsOptions, ReadSubjectsOptions, }; @@ -39,6 +42,7 @@ export { DbApiError, ErrorCode, HashVerificationFailedError, + IncompatibleServerError, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, diff --git a/src/isSubjectOnEventId.ts b/src/isSubjectOnEventId.ts index bdb88bd..86578a7 100644 --- a/src/isSubjectOnEventId.ts +++ b/src/isSubjectOnEventId.ts @@ -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 }; diff --git a/src/isSubjectPopulated.ts b/src/isSubjectPopulated.ts index 571a7b0..5898c4e 100644 --- a/src/isSubjectPopulated.ts +++ b/src/isSubjectPopulated.ts @@ -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 }; diff --git a/src/isSubjectPristine.ts b/src/isSubjectPristine.ts index 5e74179..3f3c76a 100644 --- a/src/isSubjectPristine.ts +++ b/src/isSubjectPristine.ts @@ -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 }; diff --git a/src/preconditionOptions.test.ts b/src/preconditionOptions.test.ts new file mode 100644 index 0000000..139eaed --- /dev/null +++ b/src/preconditionOptions.test.ts @@ -0,0 +1,393 @@ +import assert from 'node:assert/strict'; +import type { ServerResponse } from 'node:http'; +import { afterEach, beforeEach, suite, test } from 'node:test'; +import { setTimeout } from 'node:timers/promises'; +import { Client } from './Client.js'; +import { DbApiError } from './DbApiError.js'; +import type { ApiVersion } from './detectApiVersion.js'; +import type { EventCandidate } from './EventCandidate.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 { Precondition } from './Precondition.js'; +import type { PreconditionOptions } from './PreconditionOptions.js'; +import { type Answer, answers, lines } from './testing/answers.js'; +import { answerWithText, type FakeRequest, FakeServer, writeHead } from './testing/FakeServer.js'; +import { isRecord } from './types/isRecord.js'; + +const event: EventCandidate = { + source: 'https://www.eventsourcingdb.io', + subject: '/test', + type: 'io.eventsourcingdb.test', + data: { value: 23 }, +}; + +interface PreconditionOnSubject { + name: string; + create: (options?: PreconditionOptions) => Precondition; + + // The payload that the precondition has without options. + payload: Record; +} + +const preconditionsOnSubject: PreconditionOnSubject[] = [ + { + name: 'isSubjectPristine', + create: (options?: PreconditionOptions): Precondition => isSubjectPristine('/test', options), + payload: { subject: '/test' }, + }, + { + name: 'isSubjectPopulated', + create: (options?: PreconditionOptions): Precondition => isSubjectPopulated('/test', options), + payload: { subject: '/test' }, + }, + { + name: 'isSubjectOnEventId', + create: (options?: PreconditionOptions): Precondition => + isSubjectOnEventId('/test', '0', options), + payload: { subject: '/test', eventId: '0' }, + }, +]; + +// Reads the preconditions that a write sent, as the server received them. +const getSentPreconditions = (request: FakeRequest | undefined): unknown => { + const body: unknown = JSON.parse(request?.body ?? 'null'); + return isRecord(body) ? body.preconditions : undefined; +}; + +const isProbe = (request: FakeRequest): boolean => + request.method === 'GET' && request.path === '/api/v2/system/ping'; + +const answersByPath: Record = { + '/api/v1/verify-api-token': answers.v1.verifyApiToken, + '/api/v1/write-events': answers.v1.writeEvents, + '/api/v1/read-events': { contentType: 'application/x-ndjson', body: lines.v1.event('0') }, + '/api/v2/system/ping': answers.v2.ping, + '/api/v2/system/verify-token': answers.v2.verifyApiToken, + '/api/v2/data/write-events': answers.v2.writeEvents, +}; + +const consume = async (iterator: AsyncGenerator): Promise => { + const items: unknown[] = []; + for await (const item of iterator) { + items.push(item); + } + return items; +}; + +// Fails with the defect unless the call ends within a generous bound, since a +// call that waits for a probe that the test holds would never end. +const endsWithoutWaiting = async ( + call: Promise, + defect: string, +): Promise => { + const watchdog = new AbortController(); + const timeout = setTimeout(5000, undefined, { signal: watchdog.signal }).then((): never => { + throw new Error(defect); + }); + + try { + return await Promise.race([call, timeout]); + } finally { + watchdog.abort(); + } +}; + +const isIncompatibleServerError = (error: unknown): boolean => + error instanceof IncompatibleServerError && + error.name === 'IncompatibleServerError' && + error.message === 'The server does not offer the v2 API, which a recursive precondition needs.'; + +suite('preconditionOptions', { timeout: 30_000 }, () => { + let server: FakeServer; + let client: Client; + let offersV2: boolean; + let heldProbe: PromiseWithResolvers | undefined; + + // The server answers the first probe itself. It answers every later probe, + // and every other path of the v2 API, as a server with or without the v2 API + // does, as the test tells it to. A test may hold the next probe instead, to + // answer it itself. + const respond = (request: FakeRequest, response: ServerResponse): void => { + if (isProbe(request) && heldProbe !== undefined) { + heldProbe.resolve(response); + heldProbe = undefined; + return; + } + + const answer = answersByPath[request.path]; + if (answer === undefined || (request.path.startsWith('/api/v2/') && !offersV2)) { + answerWithText(response, 404, '404 page not found'); + return; + } + + writeHead(response, 200, answer.contentType); + response.end(answer.body); + }; + + const startServer = async (apiVersion: ApiVersion): Promise => { + server = new FakeServer(apiVersion); + offersV2 = apiVersion === 'v2'; + heldProbe = undefined; + server.respond = respond; + await server.start(); + + client = new Client(server.url, 'secret'); + }; + + const getPaths = (): string[] => + server.requests.filter(request => !isProbe(request)).map(request => request.path); + + afterEach(async () => { + await server.stop(); + }); + + suite('v2', () => { + beforeEach(async () => { + await startServer('v2'); + }); + + const defineRecursiveTestFor = ( + { name, create, payload }: PreconditionOnSubject, + recursive: boolean, + ): void => { + test(`sends the recursive option as ${recursive}.`, async (): Promise => { + await client.writeEvents([event], [create({ recursive })]); + + const [request] = server.requests; + assert.equal(request?.path, '/api/v2/data/write-events'); + assert.deepEqual(getSentPreconditions(request), [ + { type: name, payload: { ...payload, options: { recursive } } }, + ]); + }); + }; + + const defineTestsFor = (preconditionOnSubject: PreconditionOnSubject): void => { + const { name, create, payload } = preconditionOnSubject; + + suite(name, () => { + for (const recursive of [true, false]) { + defineRecursiveTestFor(preconditionOnSubject, recursive); + } + + test('sends the precondition without options if it has none.', async (): Promise => { + await client.writeEvents([event], [create()]); + + const [request] = server.requests; + assert.deepEqual(getSentPreconditions(request), [{ type: name, payload }]); + }); + + test('sends the options as the caller passed them, so that the server refuses a misspelled key.', async (): Promise => { + // A caller without types may pass options that the types do not + // allow. + const precondition: Precondition = Reflect.apply(create, undefined, [{ recursiv: true }]); + await client.writeEvents([event], [precondition]); + + const [request] = server.requests; + assert.deepEqual(getSentPreconditions(request), [ + { type: name, payload: { ...payload, options: { recursiv: true } } }, + ]); + }); + }); + }; + + for (const preconditionOnSubject of preconditionsOnSubject) { + defineTestsFor(preconditionOnSubject); + } + }); + + suite('v1', () => { + beforeEach(async () => { + await startServer('v1'); + }); + + const defineTestsFor = ({ name, create, payload }: PreconditionOnSubject): void => { + suite(name, () => { + test('fails without sending the write if the precondition is recursive, since the v1 API cannot check it.', async (): Promise => { + await assert.rejects( + client.writeEvents([event], [create({ recursive: true })]), + isIncompatibleServerError, + ); + + assert.deepEqual(getPaths(), []); + }); + + test('fails without sending the write if any of its preconditions is recursive.', async (): Promise => { + await assert.rejects( + client.writeEvents( + [event], + [ + isEventQlQueryTrue('FROM e IN events PROJECT INTO COUNT() == 0'), + create({ recursive: false }), + create({ recursive: true }), + ], + ), + isIncompatibleServerError, + ); + + assert.deepEqual(getPaths(), []); + }); + + test('fails without sending the write if the options are anything but recursive set to false, since they may have been meant as recursive.', async (): Promise => { + for (const options of [ + {}, + { recursiv: false }, + { recursive: 'false' }, + { recursive: 0 }, + { recursive: false, recursiv: true }, + null, + ]) { + // A caller without types may pass options that the types do not + // allow. + const precondition: Precondition = Reflect.apply(create, undefined, [options]); + + await assert.rejects( + client.writeEvents([event], [precondition]), + isIncompatibleServerError, + `Options ${JSON.stringify(options)} must be refused.`, + ); + } + + assert.deepEqual(getPaths(), []); + }); + + test('sends a precondition that is not recursive as one without options.', async (): Promise => { + await client.writeEvents([event], [create({ recursive: false })]); + + const [request] = server.requests; + assert.equal(request?.path, '/api/v1/write-events'); + assert.deepEqual(getSentPreconditions(request), [{ type: name, payload }]); + }); + + test('sends the precondition without options if it has none.', async (): Promise => { + await client.writeEvents([event], [create()]); + + const [request] = server.requests; + assert.deepEqual(getSentPreconditions(request), [{ type: name, payload }]); + }); + }); + }; + + for (const preconditionOnSubject of preconditionsOnSubject) { + defineTestsFor(preconditionOnSubject); + } + }); + + suite('a client that found the server without the v2 API', () => { + const recursivePrecondition = isSubjectPristine('/test', { recursive: true }); + + beforeEach(async () => { + await startServer('v1'); + await client.verifyApiToken(); + }); + + test('finds out again before a recursive write, and sends it once through the v2 API, which it keeps using, once the server offers it.', async (): Promise => { + offersV2 = true; + + const writtenEvents = await client.writeEvents([event], [recursivePrecondition]); + assert.equal(writtenEvents.length, 1); + await client.verifyApiToken(); + + assert.equal(server.probeCount, 2); + assert.deepEqual(getPaths(), [ + '/api/v1/verify-api-token', + '/api/v2/data/write-events', + '/api/v2/system/verify-token', + ]); + + const write = server.requests.find(request => request.path === '/api/v2/data/write-events'); + assert.deepEqual(getSentPreconditions(write), [recursivePrecondition]); + }); + + test('finds out again only once for recursive writes that start together, and sends each of them once.', async (): Promise => { + offersV2 = true; + + await Promise.all([ + client.writeEvents([event], [recursivePrecondition]), + client.writeEvents([event], [recursivePrecondition]), + ]); + + assert.equal(server.probeCount, 2); + assert.deepEqual(getPaths(), [ + '/api/v1/verify-api-token', + '/api/v2/data/write-events', + '/api/v2/data/write-events', + ]); + }); + + test('finds out again before a recursive write, and fails without sending it, if the server still does not offer the v2 API.', async (): Promise => { + await assert.rejects( + client.writeEvents([event], [recursivePrecondition]), + isIncompatibleServerError, + ); + await client.verifyApiToken(); + + assert.equal(server.probeCount, 2); + assert.deepEqual(getPaths(), ['/api/v1/verify-api-token', '/api/v1/verify-api-token']); + }); + + test('goes on with the v1 API for every other request while it finds out again, and uses the v2 API once it finds it.', async (): Promise => { + const probe = Promise.withResolvers(); + heldProbe = probe; + offersV2 = true; + + const write = client.writeEvents([event], [recursivePrecondition]); + const probeResponse = await probe.promise; + + const defect = 'The request waited for finding out again.'; + await endsWithoutWaiting(consume(client.readEvents('/test', { recursive: false })), defect); + await endsWithoutWaiting(client.writeEvents([event]), defect); + await endsWithoutWaiting(client.verifyApiToken(), defect); + + writeHead(probeResponse, 200, answers.v2.ping.contentType); + probeResponse.end(answers.v2.ping.body); + await write; + await client.verifyApiToken(); + + assert.equal(server.probeCount, 2); + assert.deepEqual(getPaths(), [ + '/api/v1/verify-api-token', + '/api/v1/read-events', + '/api/v1/write-events', + '/api/v1/verify-api-token', + '/api/v2/data/write-events', + '/api/v2/system/verify-token', + ]); + }); + + test('goes on with the v1 API without finding out again, if finding out again fails.', async (): Promise => { + const probe = Promise.withResolvers(); + heldProbe = probe; + + const write = client.writeEvents([event], [recursivePrecondition]); + answerWithText(await probe.promise, 500, 'failed'); + await assert.rejects( + write, + (error: unknown): boolean => error instanceof DbApiError && error.status === 500, + ); + + await client.verifyApiToken(); + + assert.equal(server.probeCount, 2); + assert.deepEqual(getPaths(), ['/api/v1/verify-api-token', '/api/v1/verify-api-token']); + }); + + test('keeps the v1 API for every other request, although the server offers the v2 API meanwhile.', async (): Promise => { + offersV2 = true; + + await client.writeEvents([event], [isSubjectPristine('/test', { recursive: false })]); + await client.writeEvents([event]); + await client.verifyApiToken(); + + assert.equal(server.probeCount, 1); + assert.deepEqual(getPaths(), [ + '/api/v1/verify-api-token', + '/api/v1/write-events', + '/api/v1/write-events', + '/api/v1/verify-api-token', + ]); + }); + }); +}); diff --git a/src/v1/V1Transport.ts b/src/v1/V1Transport.ts index f2e1d9c..a19b46e 100644 --- a/src/v1/V1Transport.ts +++ b/src/v1/V1Transport.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util'; import { convertCloudEventToEvent } from '../convertCloudEventToEvent.js'; import { DbApiError } from '../DbApiError.js'; import type { Event } from '../Event.js'; @@ -7,6 +8,7 @@ import { assertEventSourcingDb } from '../http/assertEventSourcingDb.js'; import { createDbApiErrorFromResponse } from '../http/createDbApiErrorFromResponse.js'; import { createStreamEndedError } from '../http/createStreamEndedError.js'; import { runWrite } from '../http/runWrite.js'; +import { IncompatibleServerError } from '../IncompatibleServerError.js'; import { isCloudEvent } from '../isCloudEvent.js'; import { getRawJsonElements } from '../json/getRawJsonElements.js'; import { getRawJsonMember } from '../json/getRawJsonMember.js'; @@ -87,6 +89,27 @@ const getStreamBody = async ( const createStreamError = (error: string): DbApiError => new DbApiError({ status: 200, description: error }); +// The v1 API knows no options of a precondition, and checks a precondition on +// a subject for the subject alone. So a precondition goes without its options +// only if they are exactly the recursive option set to false. Any other +// options, even an empty object or a misspelled key, may have been meant to +// apply to the subjects below as well, so the write fails before it is sent, +// rather than have the server check less than was asked. The client then +// finds out again whether the server offers the v2 API by now. +const toV1Preconditions = (preconditions: Precondition[]): Record[] => + preconditions.map(precondition => { + if (precondition.type === 'isEventQlQueryTrue' || precondition.payload.options === undefined) { + return precondition; + } + + if (!isDeepStrictEqual(precondition.payload.options, { recursive: false })) { + throw new IncompatibleServerError(); + } + + const { options: _options, ...payload } = precondition.payload; + return { ...precondition, payload }; + }); + // Sends the requests through the v1 API, which EventSourcingDB 1.2.0 offers. class V1Transport implements Transport { readonly #url: URL; @@ -169,7 +192,7 @@ class V1Transport implements Transport { const stallTimeout = this.#stallTimeout; const body = JSON.stringify({ events, - preconditions, + preconditions: toV1Preconditions(preconditions), }); return await runWrite( diff --git a/src/v2/toV2RequestBody.ts b/src/v2/toV2RequestBody.ts index 080452f..57d98e3 100644 --- a/src/v2/toV2RequestBody.ts +++ b/src/v2/toV2RequestBody.ts @@ -6,7 +6,11 @@ import type { ReadEventsOptions } from '../ReadEventsOptions.js'; // The v2 API refuses unknown fields and null, so these build the bodies of its // requests from the fields it knows, and leave out the ones without a value. -// JSON.stringify leaves out a field whose value is undefined. +// JSON.stringify leaves out a field whose value is undefined. The payload of a +// precondition is the exception: it goes out as the precondition holds it, +// with its options as the caller passed them, on purpose. Taking only the +// fields known here would drop a misspelled option silently and check less +// than was asked, while the server refuses it. const toBound = (bound: Bound | undefined): Record | undefined => bound ? { id: bound.id, type: bound.type } : undefined; diff --git a/src/withPreconditionOptions.ts b/src/withPreconditionOptions.ts new file mode 100644 index 0000000..f06d9d1 --- /dev/null +++ b/src/withPreconditionOptions.ts @@ -0,0 +1,12 @@ +import type { PreconditionOptions } from './PreconditionOptions.js'; + +// Returns the options as part of the payload of a precondition. It leaves them +// out unless the caller passes them, since a server whose v2 API does not know +// them refuses them as an unknown field. It takes them as the caller passed +// them, so that the server refuses a misspelled key, rather than check less +// than was asked. +const withPreconditionOptions = ( + options: PreconditionOptions | undefined, +): { options?: PreconditionOptions } => (options === undefined ? {} : { options }); + +export { withPreconditionOptions }; diff --git a/src/writeEvents.test.ts b/src/writeEvents.test.ts index 291f286..5bc2ed2 100644 --- a/src/writeEvents.test.ts +++ b/src/writeEvents.test.ts @@ -1,22 +1,103 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, suite, test } from 'node:test'; +import type { Client } from './Client.js'; import { Container } from './Container.js'; import { DbApiError } from './DbApiError.js'; import { ErrorCode } from './ErrorCode.js'; import type { Event } from './Event.js'; import type { EventCandidate } from './EventCandidate.js'; import { getImageTag } from './getImageTag.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 { Precondition } from './Precondition.js'; import { containerStartTimeout } from './testing/containerStartTimeout.js'; +import { getApiVersionOf } from './testing/getApiVersionOf.js'; import { integrationTimeout } from './testing/integrationTimeout.js'; // Both APIs report a precondition that does not hold with the same code. const isPreconditionViolated = (error: unknown): boolean => error instanceof DbApiError && error.code === ErrorCode.preconditionViolated; +const getEvent = (subject: string): EventCandidate => ({ + source: 'https://www.eventsourcingdb.io', + subject, + type: 'io.eventsourcingdb.test', + data: { + value: 23, + }, +}); + +const readAllEvents = async (client: Client): Promise => { + const events: Event[] = []; + for await (const event of client.readEvents('/', { recursive: true })) { + events.push(event); + } + + return events; +}; + +interface PreconditionOptionsCase { + name: string; + recursive: boolean; + + // The subjects of the events that the case writes first. + subjects: string[]; + + // A precondition that does not hold after these events, and one that does. + // At least one of the two would turn out the other way with the other value + // of the recursive option, so that the case tells the two values apart. + violated: Precondition; + holding: Precondition; +} + +const preconditionOptionsCases: PreconditionOptionsCase[] = [ + { + name: 'isSubjectPristine', + recursive: true, + subjects: ['/test/1'], + violated: isSubjectPristine('/test', { recursive: true }), + holding: isSubjectPristine('/test/2', { recursive: true }), + }, + { + name: 'isSubjectPristine', + recursive: false, + subjects: ['/test/1'], + violated: isSubjectPristine('/test/1', { recursive: false }), + holding: isSubjectPristine('/test', { recursive: false }), + }, + { + name: 'isSubjectPopulated', + recursive: true, + subjects: ['/test/1'], + violated: isSubjectPopulated('/test/2', { recursive: true }), + holding: isSubjectPopulated('/test', { recursive: true }), + }, + { + name: 'isSubjectPopulated', + recursive: false, + subjects: ['/test/1'], + violated: isSubjectPopulated('/test', { recursive: false }), + holding: isSubjectPopulated('/test/1', { recursive: false }), + }, + { + name: 'isSubjectOnEventId', + recursive: true, + subjects: ['/test', '/test/1'], + violated: isSubjectOnEventId('/test', '0', { recursive: true }), + holding: isSubjectOnEventId('/test', '1', { recursive: true }), + }, + { + name: 'isSubjectOnEventId', + recursive: false, + subjects: ['/test', '/test/1'], + violated: isSubjectOnEventId('/test', '1', { recursive: false }), + holding: isSubjectOnEventId('/test', '0', { recursive: false }), + }, +]; + suite('writeEvents', () => { let container: Container; @@ -262,4 +343,49 @@ suite('writeEvents', () => { }, isPreconditionViolated); }, ); + + const definePreconditionOptionsTest = ({ + name, + recursive, + subjects, + violated, + holding, + }: PreconditionOptionsCase): void => { + test( + `supports the ${name} precondition with the recursive option set to ${recursive}.`, + integrationTimeout, + async (): Promise => { + const client = container.getClient(); + const apiVersion = await getApiVersionOf(container); + + for (const subject of subjects) { + await client.writeEvents([getEvent(subject)]); + } + + // The v1 API cannot check a recursive precondition, so the write fails + // before it is sent, whether the precondition would hold or not. + if (recursive && apiVersion === 'v1') { + for (const precondition of [violated, holding]) { + await assert.rejects(async () => { + await client.writeEvents([getEvent('/test')], [precondition]); + }, IncompatibleServerError); + } + + assert.equal((await readAllEvents(client)).length, subjects.length); + return; + } + + await assert.rejects(async () => { + await client.writeEvents([getEvent('/test')], [violated]); + }, isPreconditionViolated); + + const writtenEvents = await client.writeEvents([getEvent('/test')], [holding]); + assert.equal(writtenEvents.length, 1); + }, + ); + }; + + for (const preconditionOptionsCase of preconditionOptionsCases) { + definePreconditionOptionsTest(preconditionOptionsCase); + } });