UNPKG

fauna

Version:

A driver to query Fauna databases in browsers, Node.js, and other Javascript runtimes

1,251 lines (1,130 loc) 37.6 kB
import { endpoints, type ClientConfiguration, type FeedClientConfiguration, type StreamClientConfiguration, } from "./client-configuration"; import { ClientClosedError, ClientError, FaunaError, getServiceError, NetworkError, ProtocolError, ServiceError, ThrottlingError, } from "./errors"; import { FaunaAPIPaths, StreamAdapter, getDefaultHTTPClient, isHTTPResponse, isStreamClient, type HTTPClient, type HTTPRequest, type HTTPResponse, type HTTPStreamRequest, type HTTPStreamClient, } from "./http-client"; import { Query } from "./query-builder"; import { TaggedTypeFormat } from "./tagged-type"; import { getDriverEnv } from "./util/environment"; import { withRetries } from "./util/retryable"; import { FeedPage, isEventSource, Page, SetIterator, type EmbeddedSet, type EventSource, } from "./values"; import { isQueryFailure, isQuerySuccess, type EncodedObject, type FeedError, type FeedRequest, type FeedSuccess, type QueryOptions, type QueryRequest, type QuerySuccess, type QueryValue, type StreamEvent, type StreamEventData, type StreamEventStatus, } from "./wire-protocol"; import { ConsoleLogHandler, parseDebugLevel, LOG_LEVELS, type LogHandler, } from "./util/logging"; type RequiredClientConfig = ClientConfiguration & Required< Pick< ClientConfiguration, | "client_timeout_buffer_ms" | "endpoint" | "fetch_keepalive" | "http2_max_streams" | "http2_session_idle_ms" | "logger" | "secret" // required default query options | "format" | "long_type" | "query_timeout_ms" | "max_attempts" | "max_backoff" > >; const DEFAULT_CLIENT_CONFIG: Omit< ClientConfiguration & RequiredClientConfig, "secret" | "endpoint" | "logger" > = { client_timeout_buffer_ms: 5000, format: "tagged", http2_session_idle_ms: 5000, http2_max_streams: 100, long_type: "number", fetch_keepalive: false, query_timeout_ms: 5000, max_attempts: 3, max_backoff: 20, }; /** * Client for calling Fauna. */ export class Client { /** A static copy of the driver env header to send with each request */ static readonly #driverEnvHeader = getDriverEnv(); /** The {@link ClientConfiguration} */ readonly #clientConfiguration: RequiredClientConfig; /** The underlying {@link HTTPClient} client. */ readonly #httpClient: HTTPClient & Partial<HTTPStreamClient>; /** The last transaction timestamp this client has seen */ #lastTxnTs?: number; /** true if this client is closed false otherwise */ #isClosed = false; /** * Constructs a new {@link Client}. * @param clientConfiguration - the {@link ClientConfiguration} to apply. Defaults to recommended ClientConfiguraiton. * @param httpClient - The underlying {@link HTTPClient} that will execute the actual HTTP calls. Defaults to recommended HTTPClient. * @example * ```typescript * const myClient = new Client( * { * endpoint: endpoints.cloud, * secret: "foo", * query_timeout_ms: 60_000, * } * ); * ``` */ constructor( clientConfiguration?: ClientConfiguration, httpClient?: HTTPClient, ) { this.#clientConfiguration = { ...DEFAULT_CLIENT_CONFIG, ...clientConfiguration, secret: this.#getSecret(clientConfiguration), endpoint: this.#getEndpoint(clientConfiguration), logger: this.#getLogger(clientConfiguration), }; this.#validateConfiguration(); if (!httpClient) { this.#httpClient = getDefaultHTTPClient({ url: this.#clientConfiguration.endpoint.toString(), http2_session_idle_ms: this.#clientConfiguration.http2_session_idle_ms, http2_max_streams: this.#clientConfiguration.http2_max_streams, fetch_keepalive: this.#clientConfiguration.fetch_keepalive, }); } else { this.#httpClient = httpClient; } } /** * @returns the last transaction time seen by this client, or undefined if this client has not seen a transaction time. */ get lastTxnTs(): number | undefined { return this.#lastTxnTs; } /** * Sets the last transaction time of this client. * @param ts - the last transaction timestamp to set, as microseconds since * the epoch. If `ts` is less than the existing `#lastTxnTs` value or is * undefined , then no change is made. */ set lastTxnTs(ts: number | undefined) { if (ts !== undefined) { this.#lastTxnTs = this.#lastTxnTs ? Math.max(ts, this.#lastTxnTs) : ts; } } /** * Return the {@link ClientConfiguration} of this client. */ get clientConfiguration(): ClientConfiguration { const { ...copy } = this.#clientConfiguration; return copy; } /** * Closes the underlying HTTP client. Subsequent query or close calls * will fail. */ close() { if (this.#isClosed) { throw new ClientClosedError( "Your client is closed. You cannot close it again.", ); } this.#httpClient.close(); this.#isClosed = true; } /** * Creates an iterator to yield pages of data. If additional pages exist, the * iterator will lazily fetch addition pages on each iteration. Pages will * be retried in the event of a ThrottlingError up to the client's configured * max_attempts, inclusive of the initial call. * * @typeParam T - The expected type of the items returned from Fauna on each * iteration. T can be inferred if the provided query used a type parameter. * @param iterable - a {@link Query} or an existing fauna Set ({@link Page} or * {@link EmbeddedSet}) * @param options - a {@link QueryOptions} to apply to the queries. Optional. * @returns A {@link SetIterator} that lazily fetches new pages of data on * each iteration * * @example * ```javascript * const userIterator = await client.paginate(fql` * Users.all() * `); * * for await (const users of userIterator) { * for (const user of users) { * // do something with each user * } * } * ``` * * @example * The {@link SetIterator.flatten} method can be used so the iterator yields * items directly. Each item is fetched asynchronously and hides when * additional pages are fetched. * * ```javascript * const userIterator = await client.paginate(fql` * Users.all() * `); * * for await (const user of userIterator.flatten()) { * // do something with each user * } * ``` */ paginate<T extends QueryValue>( iterable: Page<T> | EmbeddedSet | Query<T | Page<T>>, options?: QueryOptions, ): SetIterator<T> { if (iterable instanceof Query) { return SetIterator.fromQuery(this, iterable, options); } return SetIterator.fromPageable(this, iterable, options) as SetIterator<T>; } /** * Queries Fauna. Queries will be retried in the event of a ThrottlingError up to the client's configured * max_attempts, inclusive of the initial call. * * @typeParam T - The expected type of the response from Fauna. T can be inferred if the * provided query used a type parameter. * @param query - a {@link Query} to execute in Fauna. * Note, you can embed header fields in this object; if you do that there's no need to * pass the headers parameter. * @param options - optional {@link QueryOptions} to apply on top of the request input. * Values in this headers parameter take precedence over the same values in the {@link ClientConfiguration}. * @returns Promise&lt;{@link QuerySuccess}&gt;. * * @throws {@link ServiceError} Fauna emitted an error. The ServiceError will be * one of ServiceError's child classes if the error can be further categorized, * or a concrete ServiceError if it cannot. * You can use either the type, or the underlying httpStatus + code to determine * the root cause. * @throws {@link ProtocolError} the client a HTTP error not sent by Fauna. * @throws {@link NetworkError} the client encountered a network issue * connecting to Fauna. * @throws A {@link ClientError} the client fails to submit the request * @throws {@link ClientClosedError} if a query is issued after the client is closed. * due to an internal error. */ async query<T extends QueryValue>( query: Query<T>, options?: QueryOptions, ): Promise<QuerySuccess<T>> { if (this.#isClosed) { throw new ClientClosedError( "Your client is closed. No further requests can be issued.", ); } const request: QueryRequest = { query: query.encode(), }; if (options?.arguments) { request.arguments = TaggedTypeFormat.encode( options.arguments, ) as EncodedObject; } return this.#queryWithRetries(request, options); } /** * Initialize a streaming request to Fauna * @typeParam T - The expected type of the response from Fauna. T can be inferred * if the provided query used a type parameter. * @param tokenOrQuery - A string-encoded token for an {@link EventSource}, or a {@link Query} * @returns A {@link StreamClient} that which can be used to listen to a stream * of events * * @example * ```javascript * const stream = client.stream(fql`MyCollection.all().eventSource()`) * * try { * for await (const event of stream) { * switch (event.type) { * case "update": * case "add": * case "remove": * console.log("Stream update:", event); * // ... * break; * } * } * } catch (error) { * // An error will be handled here if Fauna returns a terminal, "error" event, or * // if Fauna returns a non-200 response when trying to connect, or * // if the max number of retries on network errors is reached. * * // ... handle fatal error * }; * ``` * * @example * ```javascript * const stream = client.stream(fql`MyCollection.all().eventSource()`) * * stream.start( * function onEvent(event) { * switch (event.type) { * case "update": * case "add": * case "remove": * console.log("Stream update:", event); * // ... * break; * } * }, * function onError(error) { * // An error will be handled here if Fauna returns a terminal, "error" event, or * // if Fauna returns a non-200 response when trying to connect, or * // if the max number of retries on network errors is reached. * * // ... handle fatal error * } * ); * ``` */ stream<T extends QueryValue>( tokenOrQuery: EventSource | Query<EventSource>, options?: Partial<StreamClientConfiguration>, ): StreamClient<T> { if (this.#isClosed) { throw new ClientClosedError( "Your client is closed. No further requests can be issued.", ); } const streamClient = this.#httpClient; if (isStreamClient(streamClient)) { const streamClientConfig: StreamClientConfiguration = { ...this.#clientConfiguration, httpStreamClient: streamClient, logger: this.#clientConfiguration.logger, ...options, }; if ( streamClientConfig.cursor !== undefined && tokenOrQuery instanceof Query ) { throw new ClientError( "The `cursor` configuration can only be used with a stream token.", ); } const tokenOrGetToken = tokenOrQuery instanceof Query ? () => this.query<EventSource>(tokenOrQuery).then((res) => res.data) : tokenOrQuery; return new StreamClient(tokenOrGetToken, streamClientConfig); } else { throw new ClientError("Streaming is not supported by this client."); } } /** * Initialize a event feed in Fauna and returns an asynchronous iterator of * feed events. * @typeParam T - The expected type of the response from Fauna. T can be inferred * if the provided query used a type parameter. * @param tokenOrQuery - A string-encoded token for an {@link EventSource}, or a {@link Query} * @returns A {@link FeedClient} that which can be used to listen to a feed * of events * * @example * ```javascript * const feed = client.feed(fql`MyCollection.all().eventSource()`) * * try { * for await (const page of feed) { * for (const event of page.events) { * // ... handle event * } * } * } catch (error) { * // An error will be handled here if Fauna returns a terminal, "error" event, or * // if Fauna returns a non-200 response when trying to connect, or * // if the max number of retries on network errors is reached. * * // ... handle fatal error * }; * ``` * @example * The {@link FeedClient.flatten} method can be used so the iterator yields * events directly. Each event is fetched asynchronously and hides when * additional pages are fetched. * * ```javascript * const feed = client.feed(fql`MyCollection.all().eventSource()`) * * for await (const user of feed.flatten()) { * // do something with each event * } * ``` */ feed<T extends QueryValue>( tokenOrQuery: EventSource | Query<EventSource>, options?: Partial<FeedClientConfiguration>, ): FeedClient<T> { if (this.#isClosed) { throw new ClientClosedError( "Your client is closed. No further requests can be issued.", ); } const clientConfiguration: FeedClientConfiguration = { ...this.#clientConfiguration, httpClient: this.#httpClient, logger: this.#clientConfiguration.logger, ...options, }; const tokenOrGetToken = tokenOrQuery instanceof Query ? () => this.query<EventSource>(tokenOrQuery).then((res) => res.data) : tokenOrQuery; return new FeedClient(tokenOrGetToken, clientConfiguration); } async #queryWithRetries<T extends QueryValue>( queryRequest: QueryRequest, queryOptions?: QueryOptions, attempt = 0, ): Promise<QuerySuccess<T>> { const maxBackoff = this.clientConfiguration.max_backoff ?? DEFAULT_CLIENT_CONFIG.max_backoff; const maxAttempts = this.clientConfiguration.max_attempts ?? DEFAULT_CLIENT_CONFIG.max_attempts; const backoffMs = Math.min(Math.random() * 2 ** attempt, maxBackoff) * 1_000; attempt += 1; try { return await this.#query<T>(queryRequest, queryOptions, attempt); } catch (error) { if (error instanceof ThrottlingError && attempt < maxAttempts) { await wait(backoffMs); return this.#queryWithRetries<T>(queryRequest, queryOptions, attempt); } throw error; } } #getError(e: any): ClientError | NetworkError | ProtocolError | ServiceError { // the error was already handled by the driver if ( e instanceof ClientError || e instanceof NetworkError || e instanceof ProtocolError || e instanceof ServiceError ) { return e; } // the HTTP request succeeded, but there was an error if (isHTTPResponse(e)) { // we got an error from the fauna service if (isQueryFailure(e.body)) { const failure = e.body; const status = e.status; return getServiceError(failure, status); } // we got a different error from the protocol layer return new ProtocolError({ message: `Response is in an unkown format: ${e.body}`, httpStatus: e.status, }); } // unknown error return new ClientError( "A client level error occurred. Fauna was not called.", { cause: e, }, ); } #getSecret(partialClientConfig?: ClientConfiguration): string { let env_secret = undefined; if ( typeof process !== "undefined" && process && typeof process === "object" && process.env && typeof process.env === "object" ) { env_secret = process.env["FAUNA_SECRET"]; } const maybeSecret = partialClientConfig?.secret ?? env_secret; if (maybeSecret === undefined) { throw new TypeError( "You must provide a secret to the driver. Set it \ in an environmental variable named FAUNA_SECRET or pass it to the Client\ constructor.", ); } return maybeSecret; } #getEndpoint(partialClientConfig?: ClientConfiguration): URL { // If the user explicitly sets the endpoint to undefined, we should throw a // TypeError, rather than override with the default endpoint. if ( partialClientConfig && "endpoint" in partialClientConfig && partialClientConfig.endpoint === undefined ) { throw new TypeError( `ClientConfiguration option endpoint must be defined.`, ); } let env_endpoint: URL | undefined = undefined; if ( typeof process !== "undefined" && process && typeof process === "object" && process.env && typeof process.env === "object" ) { env_endpoint = process.env["FAUNA_ENDPOINT"] ? new URL(process.env["FAUNA_ENDPOINT"]) : undefined; } return partialClientConfig?.endpoint ?? env_endpoint ?? endpoints.default; } #getLogger(partialClientConfig?: ClientConfiguration): LogHandler { if ( partialClientConfig && "logger" in partialClientConfig && partialClientConfig.logger === undefined ) { throw new TypeError(`ClientConfiguration option logger must be defined.`); } if (partialClientConfig?.logger) { return partialClientConfig.logger; } if ( typeof process !== "undefined" && process && typeof process === "object" && process.env && typeof process.env === "object" ) { const env_debug = parseDebugLevel(process.env["FAUNA_DEBUG"]); return new ConsoleLogHandler(env_debug); } return new ConsoleLogHandler(LOG_LEVELS.OFF); } async #query<T extends QueryValue>( queryRequest: QueryRequest, queryOptions?: QueryOptions, attempt = 0, ): Promise<QuerySuccess<T>> { try { const requestConfig = { ...this.#clientConfiguration, ...queryOptions, }; const headers = { Authorization: `Bearer ${requestConfig.secret}`, }; this.#setHeaders(requestConfig, headers); const isTaggedFormat: boolean = requestConfig.format === "tagged"; const client_timeout_ms: number = requestConfig.query_timeout_ms + this.#clientConfiguration.client_timeout_buffer_ms; const method = "POST"; this.#clientConfiguration.logger.debug( "Fauna HTTP %s request to %s (timeout: %s), headers: %s", method, FaunaAPIPaths.QUERY, client_timeout_ms.toString(), JSON.stringify(headers), ); const response: HTTPResponse = await this.#httpClient.request({ client_timeout_ms, data: queryRequest, headers, method, }); this.#clientConfiguration.logger.debug( "Fauna HTTP response %s from %s, headers: %s", response.status, FaunaAPIPaths.QUERY, JSON.stringify(response.headers), ); // Receiving a 200 with no body/content indicates an issue with core router if ( response.status === 200 && (response.body.length === 0 || response.headers["content-length"] === "0") ) { throw new ProtocolError({ message: "There was an issue communicating with Fauna. Response is empty. Please try again.", httpStatus: 500, }); } let parsedResponse; try { parsedResponse = { ...response, body: isTaggedFormat ? TaggedTypeFormat.decode(response.body, { long_type: requestConfig.long_type, }) : JSON.parse(response.body), }; if (parsedResponse.body.query_tags) { const tags_array = (parsedResponse.body.query_tags as string) .split(",") .map((tag) => tag.split("=")); parsedResponse.body.query_tags = Object.fromEntries(tags_array); } } catch (error: unknown) { throw new ProtocolError({ message: `Error parsing response as JSON: ${error}`, httpStatus: response.status, }); } // Response is not from Fauna if (!isQuerySuccess(parsedResponse.body)) { throw this.#getError(parsedResponse); } const txn_ts = parsedResponse.body.txn_ts; if ( (this.#lastTxnTs === undefined && txn_ts !== undefined) || (txn_ts !== undefined && this.#lastTxnTs !== undefined && this.#lastTxnTs < txn_ts) ) { this.#lastTxnTs = txn_ts; } const res = parsedResponse.body as QuerySuccess<T>; if (res.stats) { res.stats.attempts = attempt; } return res; } catch (e: any) { throw this.#getError(e); } } #setHeaders( fromObject: QueryOptions, headerObject: Record<string, string | number>, ): void { const setHeader = <V>( header: string, value: V | undefined, transform: (v: V) => string | number = (v) => String(v), ) => { if (value !== undefined) { headerObject[header] = transform(value); } }; setHeader("x-format", fromObject.format); setHeader("x-typecheck", fromObject.typecheck); setHeader("x-performance-hints", fromObject.performance_hints); setHeader("x-query-timeout-ms", fromObject.query_timeout_ms); setHeader("x-linearized", fromObject.linearized); setHeader("x-max-contention-retries", fromObject.max_contention_retries); setHeader("traceparent", fromObject.traceparent); setHeader("x-query-tags", fromObject.query_tags, (tags) => Object.entries(tags) .map((tag) => tag.join("=")) .join(","), ); setHeader("x-last-txn-ts", this.#lastTxnTs, (v) => v); // x-last-txn-ts doesn't get stringified setHeader("x-driver-env", Client.#driverEnvHeader); } #validateConfiguration() { const config = this.#clientConfiguration; const required_options: (keyof RequiredClientConfig)[] = [ "client_timeout_buffer_ms", "endpoint", "format", "http2_session_idle_ms", "long_type", "query_timeout_ms", "fetch_keepalive", "http2_max_streams", "max_backoff", "max_attempts", ]; required_options.forEach((option) => { if (config[option] === undefined) { throw new TypeError( `ClientConfiguration option '${option}' must be defined.`, ); } }); if (config.http2_max_streams <= 0) { throw new RangeError(`'http2_max_streams' must be greater than zero.`); } if (config.client_timeout_buffer_ms <= 0) { throw new RangeError( `'client_timeout_buffer_ms' must be greater than zero.`, ); } if (config.query_timeout_ms <= 0) { throw new RangeError(`'query_timeout_ms' must be greater than zero.`); } if (config.max_backoff <= 0) { throw new RangeError(`'max_backoff' must be greater than zero.`); } if (config.max_attempts <= 0) { throw new RangeError(`'max_attempts' must be greater than zero.`); } } } /** * A class to listen to Fauna streams. */ export class StreamClient<T extends QueryValue = any> { /** Whether or not this stream has been closed */ closed = false; /** The stream client options */ #clientConfiguration: StreamClientConfiguration; /** A tracker for the number of connection attempts */ #connectionAttempts = 0; /** A lambda that returns a promise for a {@link EventSource} */ #query: () => Promise<EventSource>; /** The last `txn_ts` value received from events */ #last_ts?: number; /** The last `cursor` value received from events */ #last_cursor?: string; /** A common interface to operate a stream from any HTTPStreamClient */ #streamAdapter?: StreamAdapter; /** A saved copy of the EventSource once received */ #eventSource?: EventSource; /** A LogHandler instance. */ #logger: LogHandler; /** * * @param token - A lambda that returns a promise for a {@link EventSource} * @param clientConfiguration - The {@link ClientConfiguration} to apply * @example * ```typescript * const streamClient = client.stream(eventSource); * ``` */ constructor( token: EventSource | (() => Promise<EventSource>), clientConfiguration: StreamClientConfiguration, ) { if (isEventSource(token)) { this.#query = () => Promise.resolve(token); } else { this.#query = token; } this.#clientConfiguration = clientConfiguration; this.#logger = clientConfiguration.logger; this.#validateConfiguration(); } /** * A synchronous method to start listening to the stream and handle events * using callbacks. * @param onEvent - A callback function to handle each event * @param onError - An Optional callback function to handle errors. If none is * provided, error will not be handled, and the stream will simply end. */ start( onEvent: (event: StreamEventData<T> | StreamEventStatus) => void, onError?: (error: Error) => void, ) { if (typeof onEvent !== "function") { throw new TypeError( `Expected a function as the 'onEvent' argument, but received ${typeof onEvent}. Please provide a valid function.`, ); } if (onError && typeof onError !== "function") { throw new TypeError( `Expected a function as the 'onError' argument, but received ${typeof onError}. Please provide a valid function.`, ); } const run = async () => { try { for await (const event of this) { onEvent(event); } } catch (error) { if (onError) { onError(error as Error); } } }; run(); } async *[Symbol.asyncIterator](): AsyncGenerator< StreamEventData<T> | StreamEventStatus > { if (this.closed) { throw new ClientError("The stream has been closed and cannot be reused."); } if (!this.#eventSource) { this.#eventSource = await this.#query().then((maybeStreamToken) => { if (!isEventSource(maybeStreamToken)) { throw new ClientError( `Error requesting a stream token. Expected a EventSource as the query result, but received ${typeof maybeStreamToken}. Your query must return the result of '<Set>.eventSource' or '<Set>.eventsOn')\n` + `Query result: ${JSON.stringify(maybeStreamToken, null)}`, ); } return maybeStreamToken; }); } this.#connectionAttempts = 1; while (!this.closed) { const backoffMs = Math.min( Math.random() * 2 ** this.#connectionAttempts, this.#clientConfiguration.max_backoff, ) * 1_000; try { for await (const event of this.#startStream()) { yield event; } } catch (error: any) { if ( error instanceof FaunaError || this.#connectionAttempts >= this.#clientConfiguration.max_attempts ) { // A terminal error from Fauna this.close(); throw error; } this.#connectionAttempts += 1; await wait(backoffMs); } } } close() { if (this.#streamAdapter) { this.#streamAdapter.close(); this.#streamAdapter = undefined; } this.closed = true; } get last_ts(): number | undefined { return this.#last_ts; } async *#startStream(): AsyncGenerator< StreamEventData<T> | StreamEventStatus > { // Safety: This method must only be called after a stream token has been acquired const eventSource = this.#eventSource as EventSource; const headers = { Authorization: `Bearer ${this.#clientConfiguration.secret}`, }; const request: HTTPStreamRequest = { data: { token: eventSource.token, cursor: this.#last_cursor || this.#clientConfiguration.cursor, }, headers, method: "POST", }; const streamAdapter = this.#clientConfiguration.httpStreamClient.stream(request); this.#streamAdapter = streamAdapter; this.#clientConfiguration.logger.debug( "Fauna HTTP %s request to '%s', headers: %s", request.method, FaunaAPIPaths.STREAM, JSON.stringify(request.headers), ); for await (const event of streamAdapter.read) { // stream events are always tagged const deserializedEvent: StreamEvent<T> = TaggedTypeFormat.decode(event, { long_type: this.#clientConfiguration.long_type, }); if (deserializedEvent.type === "error") { // Errors sent from Fauna are assumed fatal this.close(); throw getServiceError(deserializedEvent); } this.#last_ts = deserializedEvent.txn_ts; this.#last_cursor = deserializedEvent.cursor; // TODO: remove this once all environments have updated the events to use "status" instead of "start" if ((deserializedEvent.type as any) === "start") { deserializedEvent.type = "status"; } if ( !this.#clientConfiguration.status_events && deserializedEvent.type === "status" ) { continue; } yield deserializedEvent; } } #validateConfiguration() { const config = this.#clientConfiguration; const required_options: (keyof StreamClientConfiguration)[] = [ "long_type", "httpStreamClient", "max_backoff", "max_attempts", "secret", ]; required_options.forEach((option) => { if (config[option] === undefined) { throw new TypeError( `ClientConfiguration option '${option}' must be defined.`, ); } }); if (config.max_backoff <= 0) { throw new RangeError(`'max_backoff' must be greater than zero.`); } if (config.max_attempts <= 0) { throw new RangeError(`'max_attempts' must be greater than zero.`); } } } /** * A class to iterate through to a Fauna event feed. */ export class FeedClient<T extends QueryValue = any> { /** A static copy of the driver env header to send with each request */ static readonly #driverEnvHeader = getDriverEnv(); /** A lambda that returns a promise for a {@link EventSource} */ #query: () => Promise<EventSource>; /** The event feed's client options */ #clientConfiguration: FeedClientConfiguration; /** The last `cursor` value received for the current page */ #lastCursor?: string; /** A saved copy of the EventSource once received */ #eventSource?: EventSource; /** Whether or not another page can be fetched by the client */ #isDone?: boolean; /** * * @param token - A lambda that returns a promise for a {@link EventSource} * @param clientConfiguration - The {@link FeedClientConfiguration} to apply * @example * ```typescript * const feed = client.feed(eventSource); * ``` */ constructor( token: EventSource | (() => Promise<EventSource>), clientConfiguration: FeedClientConfiguration, ) { if (isEventSource(token)) { this.#query = () => Promise.resolve(token); } else { this.#query = token; } this.#clientConfiguration = clientConfiguration; this.#lastCursor = clientConfiguration.cursor; this.#validateConfiguration(); } #getHeaders(): Record<string, string> { return { Authorization: `Bearer ${this.#clientConfiguration.secret}`, "x-format": "tagged", "x-driver-env": FeedClient.#driverEnvHeader, "x-query-timeout-ms": this.#clientConfiguration.query_timeout_ms.toString(), }; } async #nextPageHttpRequest() { // If we never resolved the stream token, do it now since we need it here when // building the payload if (!this.#eventSource) { this.#eventSource = await this.#resolveEventSource(this.#query); } const headers = this.#getHeaders(); const client_timeout_ms: number = this.#clientConfiguration.client_timeout_buffer_ms + this.#clientConfiguration.query_timeout_ms; const method: string = "POST"; const req: HTTPRequest<FeedRequest> = { headers, client_timeout_ms, method, data: { token: this.#eventSource.token }, path: FaunaAPIPaths.EVENT_FEED, }; // Set the page size if it is available if (this.#clientConfiguration.page_size) { req.data.page_size = this.#clientConfiguration.page_size; } // If we have a cursor, use that. Otherwise, use the start_ts if available. // When the config is validated, if both are set, an error is thrown. if (this.#lastCursor) { req.data.cursor = this.#lastCursor; } else if (this.#clientConfiguration.start_ts) { req.data.start_ts = this.#clientConfiguration.start_ts; } return req; } async *[Symbol.asyncIterator](): AsyncGenerator<FeedPage<T>> { while (!this.#isDone) { yield await this.nextPage(); } } /** * Fetches the next page of the event feed. If there are no more pages to * fetch, this method will throw a {@link ClientError}. */ async nextPage(): Promise<FeedPage<T>> { if (this.#isDone) { throw new ClientError("The event feed has no more pages to fetch."); } const { httpClient } = this.#clientConfiguration; const request: HTTPRequest<FeedRequest> = await this.#nextPageHttpRequest(); this.#clientConfiguration.logger.debug( "Fauna HTTP %s request to '%s' (timeout: %s), headers: %s", request.method, FaunaAPIPaths.EVENT_FEED, request.client_timeout_ms, JSON.stringify(request.headers), ); const response = await withRetries(() => httpClient.request(request), { maxAttempts: this.#clientConfiguration.max_attempts, maxBackoff: this.#clientConfiguration.max_backoff, shouldRetry: (error) => error instanceof ThrottlingError, }); this.#clientConfiguration.logger.debug( "Fauna HTTP response '%s' from %s, headers: %s", response.status, FaunaAPIPaths.EVENT_FEED, JSON.stringify(response.headers), ); let body: FeedSuccess<T> | FeedError; try { body = TaggedTypeFormat.decode(response.body, { long_type: this.#clientConfiguration.long_type, }); } catch (error: unknown) { throw new ProtocolError({ message: `Error parsing response as JSON: ${error}`, httpStatus: response.status, }); } if (isQueryFailure(body)) { throw getServiceError(body, response.status); } const page = new FeedPage<T>(body); this.#lastCursor = page.cursor; this.#isDone = !page.hasNext; return page; } /** * Returns an async generator that yields the events of the event feed * directly. * * @example * ```javascript * const feed = client.feed(fql`MyCollection.all().eventSource()`) * * for await (const user of feed.flatten()) { * // do something with each event * } * ``` */ async *flatten(): AsyncGenerator<StreamEventData<T>> { for await (const page of this) { for (const event of page.events) { yield event; } } } async #resolveEventSource( fn: () => Promise<EventSource>, ): Promise<EventSource> { return await fn().then((maybeEventSource) => { if (!isEventSource(maybeEventSource)) { throw new ClientError( `Error requesting a stream token. Expected a EventSource as the query result, but received ${typeof maybeEventSource}. Your query must return the result of '<Set>.eventSource' or '<Set>.eventsOn')\n` + `Query result: ${JSON.stringify(maybeEventSource, null)}`, ); } return maybeEventSource; }); } #validateConfiguration() { const config = this.#clientConfiguration; const required_options: (keyof FeedClientConfiguration)[] = [ "long_type", "httpClient", "max_backoff", "max_attempts", "client_timeout_buffer_ms", "query_timeout_ms", "secret", ]; required_options.forEach((option) => { if (config[option] === undefined) { throw new TypeError( `ClientConfiguration option '${option}' must be defined.`, ); } }); if (config.max_backoff <= 0) { throw new RangeError(`'max_backoff' must be greater than zero.`); } if (config.max_attempts <= 0) { throw new RangeError(`'max_attempts' must be greater than zero.`); } if (config.query_timeout_ms <= 0) { throw new RangeError(`'query_timeout_ms' must be greater than zero.`); } if (config.client_timeout_buffer_ms < 0) { throw new RangeError( `'client_timeout_buffer_ms' must be greater than or equal to zero.`, ); } if (config.start_ts !== undefined && config.cursor !== undefined) { throw new TypeError( "Only one of 'start_ts' or 'cursor' can be defined in the client configuration.", ); } if (config.cursor !== undefined && typeof config.cursor !== "string") { throw new TypeError("'cursor' must be a string."); } } } // Private types and constants for internal logic. function wait(ms: number) { return new Promise((r) => setTimeout(r, ms)); }