# Fauna v10 JavaScript client driver (current) | Version: 2.4.1 | Repository: fauna/fauna-js | | --- | --- | --- | --- | Fauna’s JavaScript client driver lets you run FQL queries from JavaScript or TypeScript applications. This guide shows how to set up the driver and use it to run FQL queries. ## [](#supported-runtimes)Supported runtimes The driver supports the following runtime environments. ### [](#server-side)Server-side Node.js - [Current and active LTS versions](https://nodejs.org/en/about/releases/): * Current - v20 * LTS - v18 ### [](#cloud-providers)Cloud providers * Cloudflare Workers * AWS Lambda (See [AWS Lambda connections](#aws-lambda-connections)) * Netlify * Vercel ### [](#browsers)Browsers Stable versions of: * Chrome 69+ * Firefox 62+ * Safari 12.1+ * Edge 79+ ## [](#installation)Installation The driver is available on [npm](https://www.npmjs.com/package/fauna). Install it using your preferred package manager: ```bash npm install fauna ``` Browsers can import the driver using a CDN link: ```html ``` ## [](#api-reference)API reference API reference documentation for the driver is available at [https://fauna.github.io/fauna-js/](https://fauna.github.io/fauna-js/). ## [](#sample-app)Sample app For a practical example, check out the [JavaScript sample app](https://github.com/fauna/js-sample-app). This sample app is an e-commerce application that uses Node.js and the Fauna JavaScript driver. The source code includes comments highlighting best practices for using the driver and composing FQL queries. ## [](#basic-usage)Basic usage The following application: * Initializes a client instance to connect to Fauna * Composes a basic FQL query using an `fql` string template * Runs the query using `query()` ```javascript import { Client, fql, FaunaError } from "fauna"; // Use `require` for CommonJS: // const { Client, fql, FaunaError } = require('fauna'); // Initialize the client to connect to Fauna const client = new Client({ secret: 'FAUNA_SECRET' }); try { // Compose a query const query = fql` Product.sortedByPriceLowToHigh() { name, description, price }`; // Run the query const response = await client.query(query); console.log(response.data); } catch (error) { if (error instanceof FaunaError) { console.log(error); } } finally { // Clean up any remaining resources client.close(); } ``` ## [](#connect-to-fauna)Connect to Fauna Each Fauna query is an independently authenticated request to the Core HTTP API’s [Query endpoint](../../../reference/http/reference/core-api/#operation/query). You authenticate with Fauna using an [authentication secret](../../../learn/security/authentication/#secrets). ### [](#get-an-authentication-secret)Get an authentication secret Fauna supports several [secret types](../../../learn/security/authentication/#secret-types). For testing, you can create a [key](../../../learn/security/keys/), which is a type of secret: 1. Log in to the [Fauna Dashboard](https://dashboard.fauna.com/). 2. On the **Explorer** page, create a database. 3. In the database’s **Keys** tab, click **Create Key**. 4. Choose a **Role** of **server**. 5. Click **Save**. 6. Copy the **Key Secret**. The secret is scoped to the database. ### [](#initialize-a-client)Initialize a client To send query requests to Fauna, initialize a `Client` instance using a Fauna authentication secret: ```javascript const client = new Client({ secret: 'FAUNA_SECRET' }); ``` If not specified, `secret` defaults to the `FAUNA_SECRET` environment variable. For other configuration options, see [Client configuration](#config). ### [](#connect-to-a-child-database)Connect to a child database A [scoped key](../../../learn/security/keys/#scoped-keys) lets you use a parent database’s admin key to send query requests to its child databases. For example, if you have an admin key for a parent database and want to connect to a child database named `childDB`, you can create a scoped key using the following format: ``` // Scoped key that impersonates an `admin` key for // the `childDB` child database. fn...:childDB:admin ``` You can then initialize a `Client` instance using the scoped key: ```javascript const client = new Client({ secret: 'fn...:childDB:admin' }); ``` ### [](#multiple-connections)Multiple connections You can use a single client instance to run multiple asynchronous queries at once. The driver manages HTTP connections as needed. Your app doesn’t need to implement connection pools or other connection management strategies. You can create multiple client instances to connect to Fauna using different credentials or client configurations. ### [](#aws-lambda-connections)AWS Lambda connections AWS Lambda freezes, thaws, and reuses execution environments for Lambda functions. See [Lambda execution environment](https://docs.aws.amazon.com/lambda/latest/dg/running-lambda-code.html). When an execution environment is thawed, Lambda only runs the function’s handler code. Objects declared outside of the handler method remain initialized from before the freeze. Lambda doesn’t re-run initialization code outside the handler. Fauna drivers keep socket connections that can time out during long freezes, causing `ECONNRESET` errors when thawed. To prevent timeouts, create Fauna client connections inside function handlers. Fauna drivers use lightweight HTTP connections. You can create new connections for each request while maintaining good performance. ## [](#run-fql-queries)Run FQL queries Use `fql` string templates to compose FQL queries. Run the queries using `query()`: ```javascript const query = fql`Product.sortedByPriceLowToHigh()`; client.query(query) ``` By default, `query()` uses query options from the [Client configuration](#config). You can pass options to `query()` to override these defaults. See [Query options](#query-opts). You can only compose FQL queries using string templates. ### [](#var)Variable interpolation Use `${}` to pass native JavaScript variables to `fql` queries: ```javascript // Create a native JS var const collectionName = "Product"; // Pass the var to an FQL query const query = fql` let collection = Collection(${collectionName}) collection.sortedByPriceLowToHigh()`; client.query(query); ``` The driver encodes interpolated variables to an appropriate [FQL type](../../../reference/fql/types/) and uses the [wire protocol](../../../reference/http/reference/wire-protocol/) to pass the query to the Core HTTP API’s [Query endpoint](../../../reference/http/reference/core-api/#operation/query). This helps prevent injection attacks. ### [](#query-composition)Query composition You can use variable interpolation to pass FQL string templates as query fragments to compose an FQL query: ```javascript // Create a reusable query fragment. const product = fql`Product.byName("pizza").first()`; // Use the fragment in another FQL query. const query = fql` let product = ${product} product { name, price }`; client.query(query); ``` ### [](#pagination)Pagination Use `paginate()` to iterate through a Set that contains more than one page of results. `paginate()` accepts the same [Query options](#query-opts) as `query()`. ```javascript // Adjust `pageSize()` size as needed. const query = fql` Product.sortedByPriceLowToHigh() .pageSize(2)`; const pages = client.paginate(query); for await (const products of pages) { for (const product of products) { console.log(product) // ... } } ``` Use `flatten()` to get paginated results as a single, flat array: ```javascript const pages = client.paginate(query); for await (const product of pages.flatten()) { console.log(product) } ``` ### [](#query-stats)Query stats Successful query responses and `ServiceError` errors include [query stats](../../../reference/http/reference/query-stats/): ```javascript try { const response = await client.query(fql`"Hello world"`); console.log(response.stats); } catch (error) { if (error instanceof ServiceError) { const info = error.queryInfo; const stats = info.stats; } } ``` Output: ```json { compute_ops: 1, read_ops: 0, write_ops: 0, query_time_ms: 0, contention_retries: 0, storage_bytes_read: 0, storage_bytes_write: 0, rate_limits_hit: [], attempts: 1 } ``` ## [](#typescript-support)TypeScript support The driver supports TypeScript. For example, you can apply a type parameter to your FQL query results: ```typescript import { fql, Client, type QuerySuccess } from "fauna"; const client = new Client({ secret: 'FAUNA_SECRET' }); type Customer = { name: string; email: string; }; const query = fql`{ name: "Alice Appleseed", email: "alice.appleseed@example.com", }`; const response: QuerySuccess = await client.query(query); const customer_doc: Customer = response.data; console.assert(customer_doc.name === "Alice Applesee"); console.assert(customer_doc.email === "alice.appleseed@example.com"); ``` Alternatively, you can apply a type parameter directly to your `fql` statements and `Client` methods will infer your return types. Due to backwards compatibility, if a type parameter is provided to a `Client` method, the provided type will override the inferred type from your query. ```typescript const query = fql`{ name: "Alice", email: "alice@site.example", }`; // Response will be typed as `QuerySuccess`. const response = await client.query(query); // `userDoc` will be automatically inferred as `User`. const userDoc = response.data; console.assert(userDoc.name === "Alice"); console.assert(userDoc.email === "alice@site.example"); client.close(); ``` ## [](#config)Client configuration The `Client` instance comes with reasonable configuration defaults. We recommend using the defaults in most cases. If needed, you can configure the client to override the defaults. This also lets you set default [Query options](#query-opts). ```javascript import { Client, endpoints } from "fauna"; const config = { // Configure the client client_timeout_buffer_ms: 5000, endpoint: endpoints.default, fetch_keepalive: false, http2_max_streams: 100, http2_session_idle_ms: 5000, secret: "FAUNA_SECRET", // Set default query options format: "tagged", linearized: false, long_type: "number", max_attempts: 3, max_backoff: 20, max_contention_retries: 5, query_tags: { tag: "value" }, query_timeout_ms: 60_000, traceparent: "00-750efa5fb6a131eb2cf4db39f28366cb-000000000000000b-00", typecheck: true, }; const client = new Client(config); ``` For supported properties, see [ClientConfiguration](https://fauna.github.io/fauna-js/latest/interfaces/ClientConfiguration.html) in the API reference. ### [](#environment-variables)Environment variables By default, `secret` and `endpoint` default to the respective `FAUNA_SECRET` and `FAUNA_ENDPOINT` environment variables. For example, if you set the following environment variables: ```bash export FAUNA_SECRET=FAUNA_SECRET export FAUNA_ENDPOINT=https://db.fauna.com/ ``` You can initialize the client with a default configuration: ```javascript const client = new Client(); ``` ### [](#retries)Retries By default, the client automatically retries query requests that return a `limit_exceeded` [error code](../../../reference/http/reference/errors/). Retries use an exponential backoff. Use the [Client configuration](#config)'s `max_backoff` property to set the maximum time between retries. Similarly, use `max_attempts` to set the maximum number of retry attempts. ## [](#query-opts)Query options The [Client configuration](#config) sets default query options for the following methods: * `query()` * `paginate()` You can pass a `QueryOptions` object to override these defaults: ```javascript const options = { arguments: { name: "Alice" }, format: "tagged", linearized: false, long_type: "number", max_contention_retries: 5, query_tags: { tag: "value" }, query_timeout_ms: 60_000, traceparent: "00-750efa5fb6a131eb2cf4db39f28366cb-000000000000000b-00", typecheck: true, }; client.query(fql`"Hello, #{name}!"`, options); ``` For supported properties, see [QueryOptions](https://fauna.github.io/fauna-js/latest/interfaces/QueryOptions.html) in the API reference. ## [](#event-feeds)Event feeds The driver supports [event feeds](../../../learn/cdc/#event-feeds). An event feed asynchronously polls an [event source](../../../learn/cdc/#create-an-event-source) for paginated events. To use event feeds, you must have a Pro or Enterprise plan. ### [](#request-an-event-feed)Request an event feed To get an event source, append [`set.eventSource()`](../../../reference/fql-api/set/eventsource/) or [`set.eventsOn()`](../../../reference/fql-api/set/eventson/) to a [supported Set](../../../learn/cdc/#sets). To get paginated events, pass the event source to `feed()`: ```javascript const response = await client.query(fql` let set = Product.all() { initialPage: set.pageSize(10), eventSource: set.eventSource() } `); const { initialPage, eventSource } = response.data; const feed = client.feed(eventSource); ``` If changes occur between the creation of the event source and the event feed request, the feed replays and emits any related events. You can also pass a query that produces an event source directly to `feed()`: ```javascript const query = fql`Product.all().eventsOn(.price, .stock)`; const feed = client.feed(query); ``` In most cases, you’ll get events after a specific [event cursor](#cursor) or [start time](#start-time). #### [](#start-time)Get events after a specific start time When you first poll an event source using an event feed, you usually include a `start_ts` (start timestamp) in the [`FeedClientConfiguration` object](#event-feed-opts) that’s passed to `feed()`. The request returns events that occurred after the specified timestamp (exclusive). `start_ts` is an integer representing a time in microseconds since the Unix epoch: ```typescript // Calculate timestamp for 10 minutes ago const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); // Convert to microseconds const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; const options: FeedClientConfiguration = { start_ts: startTs }; const feed = client.feed(fql`Product.all().eventSource()`, options); ``` #### [](#cursor)Get events after a specific event cursor After the initial request, you usually get subsequent events using the [cursor](../../../learn/cdc/#cursor) for the last page or event. To get events after a cursor (exclusive), include the `cursor` in the [`FeedClientConfiguration` object](#event-feed-opts) that’s passed to `feed()`: ```typescript const options: FeedClientConfiguration = { // Cursor for a previous page cursor: "gsGabc456" }; const feed = client.feed(fql`Product.all().eventSource()`, options); ``` ### [](#loop)Iterate on an event feed `feed()` returns a `FeedClient` instance that acts as an `AsyncIterator`. You can use `for await...of` to iterate through the pages of events: ```javascript const query = fql`Product.all().eventSource()`; // Calculate timestamp for 10 minutes ago const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; const options: FeedClientConfiguration = { start_ts: startTs }; const feed = client.feed(query, options); for await (const page of feed) { console.log("Page stats", page.stats); for (const event of page.events) { switch (event.type) { case "add": // Do something on add console.log("Add event: ", event); break; case "update": // Do something on update console.log("Update event: ", event); break; case "remove": // Do something on remove console.log("Remove event: ", event); break; } } } ``` Alternatively, use `flatten()` to get events as a single, flat array: ```javascript const query = fql`Product.all().eventSource()`; // Calculate timestamp for 10 minutes ago const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; const options = { start_ts: startTs }; const feed = client.feed(query, options); for await (const event of feed.flatten()) { switch (event.type) { case "add": // Do something on add console.log("Add event: ", event); break; case "update": // Do something on update console.log("Update event: ", event); break; case "remove": // Do something on remove console.log("Remove event: ", event); break; } } ``` The event feed iterator will stop when there are no more events to poll. Each page includes a top-level `cursor`. You can include the cursor in a [`FeedClientConfiguration` object](#event-feed-opts) passed to `feed()` to poll for events after the cursor: ```typescript import { Client, fql } from "fauna"; const client = new Client(); async function processFeed(client, query, startTs = null, sleepTime = 300) { let cursor = null; while (true) { // Only include `start_ts `if `cursor` is null. Otherwise, only include `cursor`. const options = cursor === null ? { start_ts: startTs } : { cursor: cursor }; const feed = client.feed(query, options); for await (const page of feed) { for (const event of page.events) { switch (event.type) { case "add": console.log("Add event: ", event); break; case "update": console.log("Upodate event: ", event); break; case "remove": console.log("Remove event: ", event); break; } } // Store the cursor of the last page cursor = page.cursor; } // Clear startTs after the first request startTs = null; console.log(`Sleeping for ${sleepTime} seconds...`); await new Promise(resolve => setTimeout(resolve, sleepTime * 1000)); } } const query = fql`Product.all().eventsOn(.price, .stock)`; // Calculate timestamp for 10 minutes ago const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; processFeed(client, query, startTs); ``` If needed, you can store the cursor as a collection document: ```typescript import { Client, fql } from "fauna"; const client = new Client(); async function processFeed(client, query, startTs = null, sleepTime = 300) { // Create the `Cursor` collection. await client.query( fql` if (Collection.byName("Cursor").exists() == false) { Collection.create({ name: "Cursor", fields: { name: { signature: "String" }, value: { signature: "String?" } }, constraints: [ { unique: [ { field: ".name", mva: false } ] } ], indexes: { byName: { terms: [ { field: ".name", mva: false } ] } }, }) } else { null } ` ); // Create a `ProductInventory` document in the `Cursor` collection. // The document holds the latest cursor. await client.query( fql` if (Collection("Cursor").byName("ProductInventory").first() == null) { Cursor.create({ name: "ProductInventory", value: null }) } else { null } ` ); while (true) { // Get existing cursor from the `Cursor` collection. const cursorResponse = await client.query( fql`Cursor.byName("ProductInventory").first()` ); let cursor = cursorResponse.data?.value || null; // Only include `start_ts `if `cursor` is null. Otherwise, only include `cursor`. const options = cursor === null ? { start_ts: startTs } : { cursor: cursor }; const feed = client.feed(query, options); for await (const page of feed) { for (const event of page.events) { switch (event.type) { case "add": console.log("Add event: ", event); break; case "update": console.log("Update event: ", event); break; case "remove": console.log("Remove event: ", event); break; } } // Store the cursor of the last page cursor = page.cursor; await client.query( fql` Cursor.byName("ProductInventory").first()!.update({ value: ${cursor} }) ` ); console.log(`Cursor updated: ${cursor}`); } // Clear startTs after the first request startTs = null; console.log(`Sleeping for ${sleepTime} seconds...`); await new Promise(resolve => setTimeout(resolve, sleepTime * 1000)); } } const query = fql`Product.all().eventsOn(.price, .stock)`; // Calculate timestamp for 10 minutes ago const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; processFeed(client, query, startTs).catch(console.error); ``` ### [](#error-handling)Error handling Exceptions can be raised at two different places: * While fetching a page * While iterating a page’s events This distinction allows for you to ignore errors originating from event processing. For example: ```javascript const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const startTs = Math.floor(tenMinutesAgo.getTime() / 1000) * 1000000; const options = { start_ts: startTs }; const feed = client.feed(fql` Product.all().map(.name.toUpperCase()).eventSource() `, options); try { for await (const page of feed) { // Pages will stop at the first error encountered. // Therefore, its safe to handle an event failures // and then pull more pages. try { for (const event of page.events) { console.log("Event: ", event); } } catch (error: unknown) { console.log("Feed event error: ", error); } } } catch (error: unknown) { console.log("Non-retryable error: ", error); } ``` Each page’s `cursor` contains the cursor for the page’s last successfully processed event. If you’re using a [loop to poll for changes](#loop), using the cursor will skip any events that caused errors. ### [](#event-feed-opts)Event feed options The client configuration sets the default options for `feed()`. You can pass a `FeedClientConfiguration` object to override these defaults: ```typescript const options: FeedClientConfiguration = { long_type: "number", max_attempts: 5, max_backoff: 1000, query_timeout_ms: 5000, client_timeout_buffer_ms: 5000, secret: "FAUNA_SECRET", cursor: undefined, start_ts: undefined, }; client.feed(fql`Product.all().eventSource()`, options); ``` For supported properties, see [FeedClientConfiguration](https://fauna.github.io/fauna-js/latest/types/FeedClientConfiguration.html) in the API reference. ## [](#event-streaming)Event streams The driver supports [event streams](../../../learn/cdc/). ### [](#start-a-stream)Start a stream To get an event source, append [`set.eventSource()`](../../../reference/fql-api/set/eventsource/) or [`set.eventsOn()`](../../../reference/fql-api/set/eventson/) to a [supported Set](../../../learn/cdc/#sets). To stream the source’s events, pass the event source to `stream()`: ```javascript const response = await client.query(fql` let set = Product.all() { initialPage: set.pageSize(10), eventSource: set.eventSource() } `); const { initialPage, eventSource } = response.data; client.stream(eventSource) ``` You can also pass a query that produces an event source directly to `stream()`: ```javascript const query = fql`Product.all().eventsOn(.price, .stock)` client.stream(query) ``` ### [](#iterate-on-a-stream)Iterate on a stream You can iterate on the stream using an async loop: ```javascript try { for await (const event of stream) { switch (event.type) { case "update": case "add": case "remove": console.log("Stream event:", 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 } ``` Or you can use a callback function: ```javascript stream.start( function onEvent(event) { switch (event.type) { case "update": case "add": case "remove": console.log("Stream event:", event); // ... break; } }, function onFatalError(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 } ); ``` ### [](#close-a-stream)Close a stream Use `close()` to close a stream: ```javascript const stream = await client.stream(fql`Product.all().eventSource()`) let count = 0; for await (const event of stream) { console.log("Stream event:", event); // ... count++; // Close the stream after 2 events if (count === 2) { stream.close() break; } } ``` ### [](#stream-options)Stream options The [Client configuration](#config) sets default options for the `stream()` method. You can pass a `StreamClientConfiguration` object to override these defaults: ```javascript const options = { long_type: "number", max_attempts: 5, max_backoff: 1000, secret: "FAUNA_SECRET", status_events: true, }; client.stream(fql`Product.all().eventSource()`, options) ``` For supported properties, see [StreamClientConfiguration](https://fauna.github.io/fauna-js/latest/types/StreamClientConfiguration.html) in the API reference. ### [](#sample-app-2)Sample app For a practical example that uses the JavaScript driver with event streams, check out the [event streaming sample app](../../sample-apps/streaming/). ## [](#debug-logging)Debug logging To enable or disable debug logging, set the `FAUNA_DEBUG` environment variable to a string-encoded [`LOG_LEVELS`](https://fauna.github.io/fauna-js/latest/variables/LOG_LEVELS.html) integer: ```shell # Enable logging for warnings (3) and above: export FAUNA_DEBUG="3" ``` Logs are output to `console` methods. If `FAUNA_DEBUG` is not set or is invalid, logging is disabled. For advanced logging, you can pass a custom log handler using the [client configuration](#config)'s `logger` property: ```js import { Client, LOG_LEVELS } from "fauna"; import { CustomLogHandler } from "./your-logging-module"; // Create a client with a custom logger. const client = new Client({ logger: new CustomLogHandler(LOG_LEVELS.DEBUG), }); ``` # JavaScript driver source code # Files ## File: src/http-client/fetch-client.ts ````typescript /** following reference needed to include types for experimental fetch API in Node */ /// import { getServiceError, NetworkError } from "../errors"; import { QueryFailure, QueryRequest } from "../wire-protocol"; import { FaunaAPIPaths } from "./paths"; import { HTTPClient, HTTPClientOptions, HTTPRequest, HTTPResponse, HTTPStreamRequest, HTTPStreamClient, StreamAdapter, } from "./http-client"; /** * An implementation for {@link HTTPClient} that uses the native fetch API */ export class FetchClient implements HTTPClient, HTTPStreamClient { #baseUrl: string; #defaultRequestPath = FaunaAPIPaths.QUERY; #defaultStreamPath = FaunaAPIPaths.STREAM; #keepalive: boolean; constructor({ url, fetch_keepalive }: HTTPClientOptions) { this.#baseUrl = url; this.#keepalive = fetch_keepalive; } #resolveURL(path: string): string { return new URL(path, this.#baseUrl).toString(); } /** {@inheritDoc HTTPClient.request} */ async request({ data, headers: requestHeaders, method, client_timeout_ms, path = this.#defaultRequestPath, }: HTTPRequest): Promise { const signal = AbortSignal.timeout === undefined ? (() => { const controller = new AbortController(); const signal = controller.signal; setTimeout(() => controller.abort(), client_timeout_ms); return signal; })() : AbortSignal.timeout(client_timeout_ms); const response = await fetch(this.#resolveURL(path), { method, headers: { ...requestHeaders, "Content-Type": "application/json" }, body: JSON.stringify(data), signal, keepalive: this.#keepalive, }).catch((error) => { throw new NetworkError("The network connection encountered a problem.", { cause: error, }); }); const status = response.status; const responseHeaders: Record = {}; response.headers.forEach((value, key) => (responseHeaders[key] = value)); const body = await response.text(); return { status, body, headers: responseHeaders, }; } /** {@inheritDoc HTTPStreamClient.stream} */ stream({ data, headers: requestHeaders, method, path = this.#defaultStreamPath, }: HTTPStreamRequest): StreamAdapter { const request = new Request(this.#resolveURL(path), { method, headers: { ...requestHeaders, "Content-Type": "application/json" }, body: JSON.stringify(data), keepalive: this.#keepalive, }); const abortController = new AbortController(); const options = { signal: abortController.signal, }; async function* reader() { const response = await fetch(request, options).catch((error) => { throw new NetworkError( "The network connection encountered a problem.", { cause: error, }, ); }); const status = response.status; if (!(status >= 200 && status < 400)) { const failure: QueryFailure = await response.json(); throw getServiceError(failure, status); } const body = response.body; if (!body) { throw new Error("Response body is undefined."); } const reader = body.getReader(); try { for await (const line of readLines(reader)) { yield line; } } catch (error) { throw new NetworkError( "The network connection encountered a problem while streaming events.", { cause: error }, ); } } return { read: reader(), close: () => { abortController.abort("Stream closed by the client."); }, }; } /** {@inheritDoc HTTPClient.close} */ close() { // no actions at this time } } /** * Get individual lines from the stream * * The stream may be broken into arbitrary chunks, but the events are delimited by a newline character. * * @param reader - The stream reader */ async function* readLines(reader: ReadableStreamDefaultReader) { const textDecoder = new TextDecoder(); let partOfLine = ""; for await (const chunk of readChunks(reader)) { const chunkText = textDecoder.decode(chunk); const chunkLines = (partOfLine + chunkText).split("\n"); // Yield all complete lines for (let i = 0; i < chunkLines.length - 1; i++) { yield chunkLines[i].trim(); } // Store the partial line partOfLine = chunkLines[chunkLines.length - 1]; } // Yield the remaining partial line if any if (partOfLine.trim() !== "") { yield partOfLine; } } async function* readChunks(reader: ReadableStreamDefaultReader) { let done = false; do { const readResult = await reader.read(); if (readResult.value !== undefined) { yield readResult.value; } done = readResult.done; } while (!done); } ```` ## File: src/http-client/http-client.ts ````typescript // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { Client } from "../client"; import { QueryRequest, StreamRequest } from "../wire-protocol"; import { SupportedFaunaAPIPaths } from "./paths"; /** * An object representing an http request. * The {@link Client} provides this to the {@link HTTPClient} implementation. */ export type HTTPRequest = { /** * The timeout of each http request, in milliseconds. */ client_timeout_ms: number; /** The encoded Fauna query to send */ data: T; /** Headers in object format */ headers: Record; /** HTTP method to use */ method: string; /** The path of the endpoint to call if not using the default */ path?: SupportedFaunaAPIPaths; }; /** * An object representing an http request. * It is returned to, and handled by, the {@link Client}. */ export type HTTPResponse = { body: string; headers: Record; status: number; }; export type HTTPClientOptions = { url: string; http2_session_idle_ms: number; http2_max_streams: number; fetch_keepalive: boolean; }; /** * An interface to provide implementation-specific, asynchronous http calls. * This driver provides default implementations for common environments. Users * can configure the {@link Client} to use custom implementations if desired. */ export interface HTTPClient { /** * Makes an HTTP request and returns the response * @param req - an {@link HTTPRequest} * @returns A Promise<{@link HTTPResponse}> * @throws {@link NetworkError} on request timeout or other network issue. */ request(req: HTTPRequest): Promise; /** * Flags the calling {@link Client} as no longer * referencing this HTTPClient. Once no {@link Client} instances reference this HTTPClient * the underlying resources will be closed. * It is expected that calls to this method are _only_ made by a {@link Client} * instantiation. The behavior of direct calls is undefined. * @remarks * For some HTTPClients, such as the {@link FetchClient}, this method * is a no-op as there is no shared resource to close. */ close(): void; } /** * An object representing an http request. * The {@link Client} provides this to the {@link HTTPStreamClient} implementation. */ export type HTTPStreamRequest = { /** The encoded Fauna query to send */ // TODO: Allow type to be a QueryRequest once implemented by the db data: StreamRequest; /** Headers in object format */ headers: Record; /** HTTP method to use */ method: "POST"; /** The path of the endpoint to call if not using the default */ path?: string; }; /** * A common interface for a StreamClient to operate a stream from any HTTPStreamClient */ export interface StreamAdapter { read: AsyncGenerator; close: () => void; } /** * An interface to provide implementation-specific, asynchronous http calls. * This driver provides default implementations for common environments. Users * can configure the {@link Client} to use custom implementations if desired. */ export interface HTTPStreamClient { /** * Makes an HTTP request and returns the response * @param req - an {@link HTTPStreamRequest} * @returns A Promise<{@link HTTPResponse}> * @throws {@link NetworkError} on request timeout or other network issue. */ stream(req: HTTPStreamRequest): StreamAdapter; } ```` ## File: src/http-client/index.ts ````typescript import { FetchClient } from "./fetch-client"; import { HTTPClient, HTTPClientOptions, HTTPResponse, HTTPStreamClient, } from "./http-client"; import { NodeHTTP2Client } from "./node-http2-client"; export * from "./paths"; export * from "./fetch-client"; export * from "./http-client"; export * from "./node-http2-client"; export const getDefaultHTTPClient = ( options: HTTPClientOptions, ): HTTPClient & HTTPStreamClient => nodeHttp2IsSupported() ? NodeHTTP2Client.getClient(options) : new FetchClient(options); export const isHTTPResponse = (res: any): res is HTTPResponse => res instanceof Object && "body" in res && "headers" in res && "status" in res; export const isStreamClient = ( client: Partial, ): client is HTTPStreamClient => { return "stream" in client && typeof client.stream === "function"; }; export const nodeHttp2IsSupported = () => { if ( typeof process !== "undefined" && process && process.release?.name === "node" ) { try { require("node:http2"); return true; } catch (_) { return false; } } return false; }; ```` ## File: src/http-client/node-http2-client.ts ````typescript let http2: any; try { http2 = require("node:http2"); } catch (_) { http2 = undefined; } import { HTTPClient, HTTPClientOptions, HTTPRequest, HTTPResponse, HTTPStreamClient, HTTPStreamRequest, StreamAdapter, } from "./http-client"; import { NetworkError, getServiceError } from "../errors"; import { QueryFailure, QueryRequest } from "../wire-protocol"; import { FaunaAPIPaths } from "./paths"; // alias http2 types type ClientHttp2Session = any; type ClientHttp2Stream = any; type IncomingHttpHeaders = any; type IncomingHttpStatusHeader = any; type OutgoingHttpHeaders = any; /** * An implementation for {@link HTTPClient} that uses the node http package */ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { static #clients: Map = new Map(); #http2_session_idle_ms: number; #http2_max_streams: number; #url: string; #numberOfUsers = 0; #session: ClientHttp2Session | null; #defaultRequestPath = FaunaAPIPaths.QUERY; #defaultStreamPath = FaunaAPIPaths.STREAM; private constructor({ http2_session_idle_ms, url, http2_max_streams, }: HTTPClientOptions) { if (http2 === undefined) { throw new Error("Your platform does not support Node's http2 library"); } this.#http2_session_idle_ms = http2_session_idle_ms; this.#http2_max_streams = http2_max_streams; this.#url = url; this.#session = null; } /** * Gets a {@link NodeHTTP2Client} matching the {@link HTTPClientOptions} * @param httpClientOptions - the {@link HTTPClientOptions} * @returns a {@link NodeHTTP2Client} matching the {@link HTTPClientOptions} */ static getClient(httpClientOptions: HTTPClientOptions): NodeHTTP2Client { const clientKey = NodeHTTP2Client.#getClientKey(httpClientOptions); if (!NodeHTTP2Client.#clients.has(clientKey)) { NodeHTTP2Client.#clients.set( clientKey, new NodeHTTP2Client(httpClientOptions), ); } // we know that we have a client here const client = NodeHTTP2Client.#clients.get(clientKey) as NodeHTTP2Client; client.#numberOfUsers++; return client; } static #getClientKey({ http2_session_idle_ms, url }: HTTPClientOptions) { return `${url}|${http2_session_idle_ms}`; } /** {@inheritDoc HTTPClient.request} */ async request(req: HTTPRequest): Promise { let retryCount = 0; let memoizedError: any; do { try { return await this.#doRequest(req); } catch (error: any) { // see https://github.com/nodejs/node/pull/42190/files // and https://github.com/nodejs/help/issues/2105 // // TLDR; In Node, there is a race condition between handling // GOAWAY and submitting requests - that can cause // clients that safely handle go away to submit // requests after a GOAWAY was received anyway. // // technical explanation: node HTTP2 request gets put // on event queue before it is actually executed. In the iterim, // a GOAWAY can come and cause the request to fail // with a GOAWAY. if (error?.code !== "ERR_HTTP2_GOAWAY_SESSION") { throw new NetworkError( "The network connection encountered a problem.", { cause: error, }, ); } memoizedError = error; retryCount++; } } while (retryCount < 3); throw new NetworkError("The network connection encountered a problem.", { cause: memoizedError, }); } /** {@inheritDoc HTTPStreamClient.stream} */ stream(req: HTTPStreamRequest): StreamAdapter { return this.#doStream(req); } /** {@inheritDoc HTTPClient.close} */ close() { // defend against redundant close calls if (this.isClosed()) { return; } this.#numberOfUsers--; if (this.#numberOfUsers === 0 && this.#session && !this.#session.closed) { this.#session.close(); } } /** * @returns true if this client has been closed, false otherwise. */ isClosed(): boolean { return this.#numberOfUsers === 0; } #closeForAll() { this.#numberOfUsers = 0; if (this.#session && !this.#session.closed) { this.#session.close(); } } #connect() { // create the session if it does not exist or is closed if (!this.#session || this.#session.closed || this.#session.destroyed) { const newSession: ClientHttp2Session = http2 .connect(this.#url, { peerMaxConcurrentStreams: this.#http2_max_streams, }) .once("error", () => this.#closeForAll()) .once("goaway", () => this.#closeForAll()); newSession.setTimeout(this.#http2_session_idle_ms, () => { this.#closeForAll(); }); this.#session = newSession; } return this.#session; } #doRequest({ client_timeout_ms, data: requestData, headers: requestHeaders, method, path = this.#defaultRequestPath, }: HTTPRequest): Promise { return new Promise((resolvePromise, rejectPromise) => { let req: ClientHttp2Stream; const onResponse = ( http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader, ) => { const status = Number( http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS], ); let responseData = ""; // append response data to the data string every time we receive new // data chunks in the response req.on("data", (chunk: string) => { responseData += chunk; }); // Once the response is finished, resolve the promise req.on("end", () => { resolvePromise({ status, body: responseData, headers: http2ResponseHeaders, }); }); }; try { const httpRequestHeaders: OutgoingHttpHeaders = { ...requestHeaders, [http2.constants.HTTP2_HEADER_PATH]: path, [http2.constants.HTTP2_HEADER_METHOD]: method, }; const session = this.#connect(); req = session .request(httpRequestHeaders) .setEncoding("utf8") .on("error", (error: any) => { rejectPromise(error); }) .on("response", onResponse); req.write(JSON.stringify(requestData), "utf8"); // req.setTimeout must be called before req.end() req.setTimeout(client_timeout_ms, () => { req.destroy(new Error(`Client timeout`)); }); req.end(); } catch (error) { rejectPromise(error); } }); } /** {@inheritDoc HTTPStreamClient.stream} */ #doStream({ data: requestData, headers: requestHeaders, method, path = this.#defaultStreamPath, }: HTTPStreamRequest): StreamAdapter { let resolveChunk: (chunk: string[]) => void; let rejectChunk: (reason: any) => void; const setChunkPromise = () => new Promise((res, rej) => { resolveChunk = res; rejectChunk = rej; }); let chunkPromise = setChunkPromise(); let req: ClientHttp2Stream; const onResponse = ( http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader, ) => { const status = Number( http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS], ); if (!(status >= 200 && status < 400)) { // Get the error body and then throw an error let responseData = ""; // append response data to the data string every time we receive new // data chunks in the response req.on("data", (chunk: string) => { responseData += chunk; }); // Once the response is finished, resolve the promise req.on("end", () => { try { const failure: QueryFailure = JSON.parse(responseData); rejectChunk(getServiceError(failure, status)); } catch (error) { rejectChunk( new NetworkError("Could not process query failure.", { cause: error, }), ); } }); } else { let partOfLine = ""; // append response data to the data string every time we receive new // data chunks in the response req.on("data", (chunk: string) => { const chunkLines = (partOfLine + chunk).split("\n"); // Yield all complete lines resolveChunk(chunkLines.map((s) => s.trim()).slice(0, -1)); chunkPromise = setChunkPromise(); // Store the partial line partOfLine = chunkLines[chunkLines.length - 1]; }); // Once the response is finished, resolve the promise req.on("end", () => { resolveChunk([partOfLine]); }); } }; // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; async function* reader(): AsyncGenerator { const httpRequestHeaders: OutgoingHttpHeaders = { ...requestHeaders, [http2.constants.HTTP2_HEADER_PATH]: path, [http2.constants.HTTP2_HEADER_METHOD]: method, }; const session = self.#connect(); req = session .request(httpRequestHeaders) .setEncoding("utf8") .on("error", (error: any) => { rejectChunk(error); }) .on("response", onResponse); const body = JSON.stringify(requestData); req.write(body, "utf8"); req.end(); while (true) { const chunks = await chunkPromise; for (const chunk of chunks) { yield chunk; } } } return { read: reader(), close: () => { if (req) { req.close(); } }, }; } } ```` ## File: src/http-client/paths.ts ````typescript /** * Readonly object representing the paths of the Fauna API to be used * with HTTP clients. */ export const FaunaAPIPaths = { QUERY: "/query/1", STREAM: "/stream/1", EVENT_FEED: "/feed/1", } as const; export type SupportedFaunaAPIPaths = (typeof FaunaAPIPaths)[keyof typeof FaunaAPIPaths]; ```` ## File: src/util/environment.ts ````typescript import { packageVersion } from "./package-version"; let os: any; try { os = require("node:os"); } catch (_) { os = undefined; } /** * Function to put all of the environment details together. * @internal */ export const getDriverEnv = (): string => { const driverEnv = { driver: ["javascript", packageVersion].join("-"), env: "unknown", os: "unknown", runtime: "unknown", }; try { /** * Determine if we're executing in a Node environment */ const isNode = typeof window === "undefined" && typeof process !== "undefined" && process.versions != null && process.versions.node != null; /** * Determine if we're executing in a Node environment */ const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; /** * Determine if we're executing in a Service Worker environment */ const isServiceWorker = typeof self === "object" && self.constructor && self.constructor.name === "DedicatedWorkerGlobalScope"; /** * Determine if we're executing in Vercel's Edge Runtime * @see {@link https://vercel.com/docs/concepts/functions/edge-functions/edge-runtime#check-if-you're-running-on-the-edge-runtime} */ // @ts-expect-error Cannot find name 'EdgeRuntime' const isVercelEdgeRuntime = typeof EdgeRuntime !== "string"; if (isNode) { driverEnv.runtime = ["nodejs", process.version].join("-"); driverEnv.env = getNodeRuntimeEnv(); driverEnv.os = [os.platform(), os.release()].join("-"); } else if (isServiceWorker) { driverEnv.runtime = getBrowserDetails(navigator); driverEnv.env = "Service Worker"; driverEnv.os = getBrowserOsDetails(navigator); } else if (isBrowser) { driverEnv.runtime = getBrowserDetails(navigator); driverEnv.env = "browser"; driverEnv.os = getBrowserOsDetails(navigator); } else if (isVercelEdgeRuntime) { driverEnv.runtime = "Vercel Edge Runtime"; driverEnv.env = "edge"; } } catch (e) { // ignore errors trying to report on user environment } return ( Object.entries(driverEnv) // eslint-disable-next-line @typescript-eslint/no-unused-vars .filter(([_, val]) => val !== "unknown") .map((entry: [string, string]) => entry.join("=")) .join("; ") ); }; /** * Get browser environment details */ const getBrowserDetails = (navigator: Navigator | WorkerNavigator): string => { let browser: string = navigator.appName; let browserVersion = "" + parseFloat(navigator.appVersion); let nameOffset, verOffset, ix; // Opera if ((verOffset = navigator.userAgent.indexOf("Opera")) != -1) { browser = "Opera"; browserVersion = navigator.userAgent.substring(verOffset + 6); if ((verOffset = navigator.userAgent.indexOf("Version")) != -1) { browserVersion = navigator.userAgent.substring(verOffset + 8); } } // MSIE else if ((verOffset = navigator.userAgent.indexOf("MSIE")) != -1) { browser = "Microsoft Internet Explorer"; browserVersion = navigator.userAgent.substring(verOffset + 5); } //IE 11 no longer identifies itself as MS IE, so trap it //http://stackoverflow.com/questions/17907445/how-to-detect-ie11 else if ( browser == "Netscape" && navigator.userAgent.indexOf("Trident/") != -1 ) { browser = "Microsoft Internet Explorer"; browserVersion = navigator.userAgent.substring(verOffset + 5); if ((verOffset = navigator.userAgent.indexOf("rv:")) != -1) { browserVersion = navigator.userAgent.substring(verOffset + 3); } } // Chrome else if ((verOffset = navigator.userAgent.indexOf("Chrome")) != -1) { browser = "Chrome"; browserVersion = navigator.userAgent.substring(verOffset + 7); } // Safari else if ((verOffset = navigator.userAgent.indexOf("Safari")) != -1) { browser = "Safari"; browserVersion = navigator.userAgent.substring(verOffset + 7); if ((verOffset = navigator.userAgent.indexOf("Version")) != -1) { browserVersion = navigator.userAgent.substring(verOffset + 8); } // Chrome on iPad identifies itself as Safari. Actual results do not match what Google claims // at: https://developers.google.com/chrome/mobile/docs/user-agent?hl=ja // No mention of chrome in the user agent string. However it does mention CriOS, which presumably // can be keyed on to detect it. if (navigator.userAgent.indexOf("CriOS") != -1) { //Chrome on iPad spoofing Safari...correct it. browser = "Chrome"; //Don't believe there is a way to grab the accurate version number, so leaving that for now. } } // Firefox else if ((verOffset = navigator.userAgent.indexOf("Firefox")) != -1) { browser = "Firefox"; browserVersion = navigator.userAgent.substring(verOffset + 8); } // Other browsers else if ( (nameOffset = navigator.userAgent.lastIndexOf(" ") + 1) < (verOffset = navigator.userAgent.lastIndexOf("/")) ) { browser = navigator.userAgent.substring(nameOffset, verOffset); browserVersion = navigator.userAgent.substring(verOffset + 1); if (browser.toLowerCase() == browser.toUpperCase()) { browser = navigator.appName; } } // trim the browser version string if ((ix = browserVersion.indexOf(";")) != -1) browserVersion = browserVersion.substring(0, ix); if ((ix = browserVersion.indexOf(" ")) != -1) browserVersion = browserVersion.substring(0, ix); if ((ix = browserVersion.indexOf(")")) != -1) browserVersion = browserVersion.substring(0, ix); return [browser, browserVersion].join("-"); }; /** * Get OS details for the browser */ const getBrowserOsDetails = ( navigator: Navigator | WorkerNavigator ): string => { let os = "unknown"; const clientStrings = [ { s: "Windows 10", r: /(Windows 10.0|Windows NT 10.0)/ }, { s: "Windows 8.1", r: /(Windows 8.1|Windows NT 6.3)/ }, { s: "Windows 8", r: /(Windows 8|Windows NT 6.2)/ }, { s: "Windows 7", r: /(Windows 7|Windows NT 6.1)/ }, { s: "Windows Vista", r: /Windows NT 6.0/ }, { s: "Windows Server 2003", r: /Windows NT 5.2/ }, { s: "Windows XP", r: /(Windows NT 5.1|Windows XP)/ }, { s: "Windows 2000", r: /(Windows NT 5.0|Windows 2000)/ }, { s: "Windows ME", r: /(Win 9x 4.90|Windows ME)/ }, { s: "Windows 98", r: /(Windows 98|Win98)/ }, { s: "Windows 95", r: /(Windows 95|Win95|Windows_95)/ }, { s: "Windows NT 4.0", r: /(Windows NT 4.0|WinNT4.0|WinNT|Windows NT)/ }, { s: "Windows CE", r: /Windows CE/ }, { s: "Windows 3.11", r: /Win16/ }, { s: "Android", r: /Android/ }, { s: "Open BSD", r: /OpenBSD/ }, { s: "Sun OS", r: /SunOS/ }, { s: "Chrome OS", r: /CrOS/ }, { s: "Linux", r: /(Linux|X11(?!.*CrOS))/ }, { s: "iOS", r: /(iPhone|iPad|iPod)/ }, { s: "Mac OS X", r: /Mac OS X/ }, { s: "Mac OS", r: /(Mac OS|MacPPC|MacIntel|Mac_PowerPC|Macintosh)/ }, { s: "QNX", r: /QNX/ }, { s: "UNIX", r: /UNIX/ }, { s: "BeOS", r: /BeOS/ }, { s: "OS/2", r: /OS\/2/ }, { s: "Search Bot", r: /(nuhk|Googlebot|Yammybot|Openbot|Slurp|MSNBot|Ask Jeeves\/Teoma|ia_archiver)/, }, ]; for (const id in clientStrings) { const cs = clientStrings[id]; if (cs.r.test(navigator.userAgent)) { os = cs.s; break; } } let osVersion: string | undefined = "unknown"; if (/Windows/.test(os)) { osVersion; const matches = /Windows (.*)/.exec(os); if (matches) { osVersion = matches[1]; } os = "Windows"; } switch (os) { case "Mac OS": case "Mac OS X": case "Android": { const matches = /(?:Android|Mac OS|Mac OS X|MacPPC|MacIntel|Mac_PowerPC|Macintosh) ([._\d]+)/.exec( navigator.userAgent ); if (matches) { osVersion = matches[1]; } break; } case "iOS": { const matches = /OS (\d+)_(\d+)_?(\d+)?/.exec(navigator.appVersion); if (matches) { osVersion = matches[1] + "." + matches[2] + "." + (matches[3] ?? 0); } break; } } return [os, osVersion].join("-"); }; const crossGlobal = typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : typeof global !== "undefined" ? global : self; /** * Get node environment details */ const getNodeRuntimeEnv = (): string => { // return early if process variables are not available if ( !( typeof process !== "undefined" && process && process.env && typeof process.env === "object" ) ) { return "unknown"; } const runtimeEnvs = [ { name: "Netlify", check: function (): boolean { return !!process.env["NETLIFY_IMAGES_CDN_DOMAIN"]; }, }, { name: "Vercel", check: function (): boolean { return !!process.env["VERCEL"]; }, }, { name: "Heroku", check: function (): boolean { return ( !!process.env["PATH"] && process.env.PATH.indexOf(".heroku") !== -1 ); }, }, { name: "AWS Lambda", check: function (): boolean { return !!process.env["AWS_LAMBDA_FUNCTION_VERSION"]; }, }, { name: "GCP Cloud Functions", check: function (): boolean { return !!process.env["_"] && process.env._.indexOf("google") !== -1; }, }, { name: "GCP Compute Instances", check: function (): boolean { return !!process.env["GOOGLE_CLOUD_PROJECT"]; }, }, { name: "Azure Cloud Functions", check: function (): boolean { return !!process.env["WEBSITE_FUNCTIONS_AZUREMONITOR_CATEGORIES"]; }, }, { name: "Azure Compute", check: function (): boolean { return ( !!process.env["ORYX_ENV_TYPE"] && !!process.env["WEBSITE_INSTANCE_ID"] && process.env.ORYX_ENV_TYPE === "AppService" ); }, }, { name: "Mongo Stitch", check: function (): boolean { // @ts-expect-error Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.ts(7017) return typeof crossGlobal?.StitchError === "function"; }, }, { name: "Render", check: function (): boolean { return !!process.env["RENDER_SERVICE_ID"]; }, }, { name: "Begin", check: function (): boolean { return !!process.env["BEGIN_DATA_SCOPE_ID"]; }, }, ]; const detectedEnv = runtimeEnvs.find((env) => env.check()); return detectedEnv ? detectedEnv.name : "unknown"; }; ```` ## File: src/util/logging.ts ````typescript export const LOG_LEVELS = { TRACE: "0", DEBUG: "1", INFO: "2", WARN: "3", ERROR: "4", FATAL: "5", OFF: "6", } as const; export type LogLevel = (typeof LOG_LEVELS)[keyof typeof LOG_LEVELS]; /** * Converts the FAUNA_DEBUG environment variable (string) into a LogLevel. * The intended use is to set FAUNA_DEBUG=0|1|2|3|4. * * This function will convert null, undefined, empty, or or any non matching * string to a LogLevel of OFF. * * @param debug_level - The String value of FAUNA_DEBUG. */ export function parseDebugLevel(debug_level: string | undefined): LogLevel { switch (debug_level) { case "0": case "1": case "2": case "3": case "4": case "5": case "6": return debug_level; default: return LOG_LEVELS.OFF; } } export interface LogHandler { trace(msg: string, ...args: any[]): void; debug(msg: string, ...args: any[]): void; info(msg: string, ...args: any[]): void; warn(msg: string, ...args: any[]): void; error(msg: string, ...args: any[]): void; fatal(msg: string, ...args: any[]): void; } export class ConsoleLogHandler implements LogHandler { readonly #level: LogLevel; constructor(level: LogLevel) { this.#level = level; } trace(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.TRACE) { console.trace(msg, ...args); } } debug(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.DEBUG) { console.debug(msg, ...args); } } info(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.INFO) { console.info(msg, ...args); } } warn(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.WARN) { console.warn(msg, ...args); } } error(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.ERROR) { console.error(msg, ...args); } } fatal(msg: string, ...args: any[]): void { if (this.#level <= LOG_LEVELS.FATAL) { console.error(msg, ...args); } } } export function defaultLogHandler(): LogHandler { return new ConsoleLogHandler(LOG_LEVELS.FATAL); } ```` ## File: src/util/package-version.ts ````typescript //THIS FILE IS AUTOGENERATED. DO NOT EDIT. SEE .husky/pre-commit /** The current package version. */ export const packageVersion = "2.4.1"; ```` ## File: src/util/retryable.ts ````typescript export type RetryOptions = { maxAttempts: number; maxBackoff: number; shouldRetry?: (error: any) => boolean; attempt?: number; sleepFn?: (callback: (args: void) => void, ms?: number) => void; }; export const withRetries = async ( fn: () => Promise, { maxAttempts, maxBackoff, shouldRetry = () => true, attempt = 0, sleepFn = setTimeout, }: RetryOptions, ): Promise => { const backoffMs = attempt > 0 ? Math.min(Math.random() * 2 ** attempt, maxBackoff) * 1_000 : 0; attempt += 1; try { return await fn(); } catch (error: any) { if (attempt >= maxAttempts || shouldRetry(error) !== true) { throw error; } await new Promise((resolve) => sleepFn(resolve, backoffMs)); return withRetries(fn, { maxAttempts, maxBackoff, shouldRetry, attempt, sleepFn, }); } }; ```` ## File: src/values/date-time.ts ````typescript import { ClientError } from "../errors"; import * as PARSE from "../regex"; /** * A wrapper around the Fauna `Time` type. It, represents a fixed point in time * without regard to calendar or location, e.g. July 20, 1969, at 20:17 UTC. * Convert to and from Javascript Date's with the {@link TimeStub.fromDate} and * {@link TimeStub.toDate} methods. * See remarks for possible precision loss when doing this. If precision loss is * a concern consider using a 3rd party datetime library such as luxon. * * @remarks The Javascript `Date` type most closely resembles a Fauna `Time`, * not a Fauna `Date`. However, Fauna stores `Time` values with nanosecond * precision, while Javascript `Date` values only have millisecond precision. * This TimeStub class preserves precision by storing the original string value * and should be used whenever possible to pass `Time` values back to Fauna. * Converting to a Javascript date before sending to Fauna could result in loss * of precision. * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#time} */ export class TimeStub { readonly isoString: string; /** * @remarks constructor is private to enforce using factory functions */ private constructor(isoString: string) { this.isoString = isoString; } /** * Creates a new {@link TimeStub} from an ISO date string * @param isoString - An ISO date string. * @returns A new {@link TimeStub} * @throws TypeError if a string is not provided, or RangeError if item * is not a valid date */ static from(isoString: string): TimeStub { if (typeof isoString !== "string") { throw new TypeError( `Expected string but received ${typeof isoString}: ${isoString}` ); } const matches = PARSE.datetime.exec(isoString); if (matches === null) { throw new RangeError( `(regex) Expected an ISO date string but received '${isoString}'` ); } // There are some dates that match the regex but are invalid, such as Feb 31. // Javascript does not parse all years that are valid in fauna, so let // Fauna be the final check. return new TimeStub(isoString); } /** * Creates a new {@link TimeStub} from a Javascript `Date` * @param date - A Javascript `Date` * @returns A new {@link TimeStub} */ static fromDate(date: Date): TimeStub { return new TimeStub(date.toISOString()); } /** * Get a copy of the `TimeStub` converted to a Javascript `Date`. Does not * mutate the existing `TimeStub` value. * @returns A `Date` */ toDate(): Date { const date = new Date(this.isoString); if (date.toString() === "Invalid Date") { throw new RangeError( "Fauna Date could not be converted to Javascript Date" ); } return date; } /** * Override default string conversion * @returns the string representation of a `TimeStub` */ toString(): string { return `TimeStub("${this.isoString}")`; } } /** * A wrapper aroud the Fauna `Date` type. It represents a calendar date that is * not associated with a particular time or time zone, e.g. August 24th, 2006. * Convert to and from Javascript Date's with the {@link DateStub.fromDate} and * {@link DateStub.toDate} methods. Javascript Dates are rendered in UTC time * before the date part is used. * See remarks for possible precision loss when doing this. If precision loss is * a concern consider using a 3rd party datetime library such as luxon. * * @remarks The Javascript `Date` type always has a time associated with it, but * Fauna's `Date` type does not. When converting from a Fauna `Date` to a * Javascript `Date`, we set time to 00:00:00 UTC. When converting a Javascript * `Date` or time string to Fauna `Date`, we convert to UTC first. Care should * be taken to specify the desired date, since Javascript `Date`s use local * timezone info by default. * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#date} */ export class DateStub { readonly dateString: string; /** * @remarks constructor is private to enforce using factory functions */ private constructor(dateString: string) { this.dateString = dateString; } /** * Creates a new {@link DateStub} from a date string * @param dateString - A plain date string. The time is converted to UTC * before saving the date. * @returns A new {@link DateStub} * @throws TypeError if a string is not provided, or RangeError if dateString * is not a valid date */ static from(dateString: string): DateStub { if (typeof dateString !== "string") { throw new TypeError( `Expected string but received ${typeof dateString}: ${dateString}` ); } const matches = PARSE.plaindate.exec(dateString); if (matches === null) { throw new RangeError( `Expected a plain date string but received '${dateString}'` ); } // There are some dates that match the regex but are invalid, such as Feb 31. // Javascript does not parse all years that are valid in fauna, so let // Fauna be the final check. return new DateStub(matches[0]); } /** * Creates a new {@link DateStub} from a Javascript `Date` * @param date - A Javascript `Date`. The time is converted to UTC before * saving the date. * @returns A new {@link DateStub} */ static fromDate(date: Date): DateStub { const dateString = date.toISOString(); const matches = PARSE.startsWithPlaindate.exec(dateString); if (matches === null) { // Our regex should match any possible date that comes out of // `Date.toISOString()`, so we will only get here if the regex is // incorrect. This is a ClientError since it is our fault. throw new ClientError(`Failed to parse date '${date}'`); } return new DateStub(matches[0]); } /** * Get a copy of the `TimeStub` converted to a Javascript `Date`. Does not * mutate the existing `TimeStub` value. * @returns A `Date` */ toDate(): Date { const date = new Date(this.dateString + "T00:00:00Z"); if (date.toString() === "Invalid Date") { throw new RangeError( "Fauna Date could not be converted to Javascript Date" ); } return date; } /** * Override default string conversion * @returns the string representation of a `DateStub` */ toString(): string { return `DateStub("${this.dateString}")`; } } ```` ## File: src/values/doc.ts ````typescript import { QueryValueObject } from "../wire-protocol"; import { TimeStub } from "./date-time"; /** * A reference to a Document with an ID. The Document may or may not exist. * References to Keys, Tokens, and Documents in user-defined Collections are * modeled with a {@link DocumentReference}. * * The example below retrieves a document reference from a * hypothetical "Users" collection. * * @example * ```javascript * const response = await client.query(fql` * Users.byId("101") * `); * const userDocumentReference = response.data; * * const id = userDocumentReference.id; * id === "101"; // returns true * ``` * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#special} */ export class DocumentReference { readonly coll: Module; readonly id: string; constructor({ coll, id }: { coll: Module | string; id: string }) { this.id = id; if (typeof coll === "string") { this.coll = new Module(coll); } else { this.coll = coll; } } } /** * A materialized Document with an ID. Keys, Tokens and Documents in * user-defined Collections are modeled with a {@link Document}. All top level * Document fields are added to a {@link Document} instance, but types cannot be * provided. Cast the instance to a {@link DocumentT} to have typesafe access to * all top level fields. * * The example below retrieves a document from a * hypothetical "Users" collection. * * @example * ```javascript * const response = await client.query(fql` * Users.byId("101") * `); * const userDocument = response.data; * * const color = userDocument.color; * ``` * * @remarks The {@link Document} class cannot be generic because classes cannot * extend generic type arguments. * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#special} */ export class Document extends DocumentReference { readonly ts: TimeStub; readonly ttl?: TimeStub; constructor(obj: { coll: Module | string; id: string; ts: TimeStub; [key: string]: any; }) { const { coll, id, ts, ...rest } = obj; super({ coll, id }); this.ts = ts; Object.assign(this, rest); } toObject(): { coll: Module; id: string; ts: TimeStub; ttl?: TimeStub } { return { ...this }; } } /** * A reference to a Document with a name. The Document may or may not exist. * References to specific AccessProviders, Collections, Databases, Functions, etc. are * modeled with a {@link NamedDocumentReference}. * * The example below retrieves a NamedDocumentReference for a hypothetical * "Users" collection. * * @example * ```javascript * const response = await client.query(fql` * Users.definition * `); * const namedDocumentReference = response.data; * * const collectionName = namedDocumentReference.name; * collectionName === "Users"; // returns true * ``` * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#special} */ export class NamedDocumentReference { readonly coll: Module; readonly name: string; constructor({ coll, name }: { coll: Module | string; name: string }) { this.name = name; if (typeof coll === "string") { this.coll = new Module(coll); } else { this.coll = coll; } } } /** * A materialized Document with a name. Specific AccessProviders, Collections, Databases, * Functions, etc. that include user defined data are modeled with a {@link NamedDocument}. * * The example below retrieves a NamedDocument for a hypothetical * "Users" collection. * * @example * ```javascript * const response = await client.query(fql` * Users.definition * `); * const userCollectionNamedDocument = response.data; * * const indexes = userCollectionNamedDocument.indexes; * ``` * * @example * All of the named Documents can have optional, user-defined data. The generic * class lets you define the shape of that data in a typesafe way * ```typescript * type CollectionMetadata = { * metadata: string * } * * const response = await client.query>(fql` * Users.definition * `); * const userCollection = response.data; * * const metadata = userCollection.data.metadata; * ``` * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#special} */ export class NamedDocument< T extends QueryValueObject = Record, > extends NamedDocumentReference { readonly ts: TimeStub; readonly data: T; constructor(obj: { coll: Module | string; name: string; ts: TimeStub; data?: T; }) { const { coll, name, ts, data, ...rest } = obj; super({ coll, name }); this.ts = ts; this.data = data || ({} as T); Object.assign(this, rest); } toObject(): { coll: Module; name: string; ts: TimeStub; data: T } { return { ...this } as { coll: Module; name: string; ts: TimeStub; data: T }; } } /** * A Fauna module, such as a Collection, Database, Function, Role, etc. * Every module is usable directly in your FQL code. * * The example below shows FQL code that gets all documents for a hypothetical * 'Users' collection by creating a Module for user and then calling .all(). * * You can also create modules for databases, functions, roles and other * entities in your database. * * @example * ```javascript * const response = await client.query(fql` * ${new Module("Users")}.all() * `); * const allUserDocuments = response.data; * ``` * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#module} */ export class Module { readonly name: string; constructor(name: string) { this.name = name; } } /** * A reference to a Document or Named Document that could not be read. The * Document may or may not exist in future queries. The cause field specifies * the reason the document could not be read, typically because the Document * does not exist or due to insufficient privileges. * * Some read operations, such as the `.byId` method may return * either a Document or a NullDocument. This example shows how to handle such a * result with the driver * * @example * ```typescript * const response = await client.query(fql` * Users.byId("101") * `); * const maybeUserDocument = response.data; * * if (maybeUserDocument instanceof NullDocument) { * // handle NullDocument case * const cause = maybeUserDocument.cause * } else { * // handle Document case * const color = maybeUserDocument.color; * } * ``` * * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#nulldoc} */ export class NullDocument { readonly ref: DocumentReference | NamedDocumentReference; readonly cause: string; constructor(ref: DocumentReference | NamedDocumentReference, cause: string) { this.ref = ref; this.cause = cause; } } /** * A Document typed with a user-defined data type. Typescript users can cast * instances of {@link Document} to {@link DocumentT} to access user-defined fields with type safety. * * The example below creates a local type "User" that is applied to queries for documents in a * hypothetical "Users" collection. * * @example * ```typescript * type User = { * color: string * } * * const response = await client.query>(fql` * Users.byId("101") * `); * const user = response.data; * * const color = user.color; * ``` * * @remarks The {@link Document} class cannot be generic because classes cannot * extend generic type arguments. */ export type DocumentT = Document & T; ```` ## File: src/values/index.ts ````typescript export * from "./date-time"; export * from "./doc"; export * from "./set"; export * from "./stream"; ```` ## File: src/values/set.ts ````typescript import { Client } from "../client"; import { Query, fql } from "../query-builder"; import { QueryOptions, QueryValue } from "../wire-protocol"; /** * A materialized view of a Set. * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#set} */ export class Page { /** A materialized page of data */ readonly data: T[]; /** * A pagination cursor, used to obtain additional information in the Set. * If `after` is not provided, then `data` must be present and represents the * last Page in the Set. */ readonly after?: string; constructor({ data, after }: { data: T[]; after?: string }) { this.data = data; this.after = after; } } /** * A un-materialized Set. Typically received when a materialized Set contains * another set, the EmbeddedSet does not contain any data to avoid potential * issues such as self-reference and infinite recursion * @see {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#set} */ export class EmbeddedSet { /** * A pagination cursor, used to obtain additional information in the Set. */ readonly after: string; constructor(after: string) { this.after = after; } } /** * A class to provide an iterable API for fetching multiple pages of data, given * a Fauna Set */ export class SetIterator implements AsyncGenerator { readonly #generator: AsyncGenerator; /** * Constructs a new {@link SetIterator}. * * @remarks Though you can use {@link SetIterator} class directly, it is * most common to create an instance through the {@link Client.paginate} `paginate` * method. * * @typeParam T - The expected type of the items returned from Fauna on each * iteration * @param client - The {@link Client} that will be used to fetch new data on * each iteration * @param initial - An existing fauna Set ({@link Page} or * {@link EmbeddedSet}) or function which returns a promise. If the Promise * resolves to a {@link Page} or {@link EmbeddedSet} then the iterator will * use the client to fetch additional pages of data. * @param options - a {@link QueryOptions} to apply to the queries. Optional. */ constructor( client: Client, initial: Page | EmbeddedSet | (() => Promise | EmbeddedSet>), options?: QueryOptions, ) { options = options ?? {}; if (initial instanceof Function) { this.#generator = generateFromThunk(client, initial, options); } else if (initial instanceof Page || initial instanceof EmbeddedSet) { this.#generator = generatePages(client, initial, options); } else { throw new TypeError( `Expected 'Page | EmbeddedSet | (() => Promise | EmbeddedSet>)', but received ${JSON.stringify( initial, )}`, ); } } /** * Constructs a new {@link SetIterator} from an {@link Query} * * @internal Though you can use {@link SetIterator.fromQuery} directly, it is * intended as a convenience for use in the {@link Client.paginate} method */ static fromQuery( client: Client, query: Query, options?: QueryOptions, ): SetIterator { return new SetIterator( client, async () => { const response = await client.query | EmbeddedSet>( query, options, ); return response.data; }, options, ); } /** * Constructs a new {@link SetIterator} from an {@link Page} or * {@link EmbeddedSet} * * @internal Though you can use {@link SetIterator.fromPageable} directly, it * is intended as a convenience for use in the {@link Client.paginate} method */ static fromPageable( client: Client, pageable: Page | EmbeddedSet, options?: QueryOptions, ): SetIterator { return new SetIterator(client, pageable, options); } /** * Constructs a new {@link FlattenedSetIterator} from the current instance * * @returns A new {@link FlattenedSetIterator} from the current instance */ flatten(): FlattenedSetIterator { return new FlattenedSetIterator(this); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/next| AsyncGenerator.next} * */ async next(): Promise> { return this.#generator.next(); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/return| AsyncGenerator.return} * */ async return(): Promise> { return this.#generator.return(); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/throw| AsyncGenerator.throw} * */ async throw(e: any): Promise> { return this.#generator.throw(e); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator| AsyncGenerator} * */ [Symbol.asyncIterator]() { return this; } } /** * A class to provide an iterable API for fetching multiple pages of data, given * a Fauna Set. This class takes a {@link SetIterator} and flattens the results * to yield the items directly. */ export class FlattenedSetIterator implements AsyncGenerator { readonly #generator: AsyncGenerator; /** * Constructs a new {@link FlattenedSetIterator}. * * @remarks Though you can use {@link FlattenedSetIterator} class directly, it * is most common to create an instance through the * {@link SetIterator.flatten} method. * * @typeParam T - The expected type of the items returned from Fauna on each * iteration * @param setIterator - The {@link SetIterator} */ constructor(setIterator: SetIterator) { this.#generator = generateItems(setIterator); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/next| AsyncGenerator.next} * */ async next(): Promise> { return this.#generator.next(); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/return| AsyncGenerator.return} * */ async return(): Promise> { return this.#generator.return(); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator/throw| AsyncGenerator.throw} * */ async throw(e: any): Promise> { return this.#generator.throw(e); } /** Implement * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator| AsyncGenerator} * */ [Symbol.asyncIterator]() { return this; } } /** * Internal async generator function to use with {@link Page} and * {@link EmbeddedSet} values */ async function* generatePages( client: Client, initial: Page | EmbeddedSet, options: QueryOptions, ): AsyncGenerator { let currentPage = initial; if (currentPage instanceof Page) { yield currentPage.data; } while (currentPage.after) { // cursor means there is more data to fetch const query = fql`Set.paginate(${currentPage.after})`; const response = await client.query>(query, options); const nextPage = response.data; currentPage = nextPage; yield currentPage.data; } } /** * Internal async generator function to use with a function that returns a * promise of data. If the promise resolves to a {@link Page} or * {@link EmbeddedSet} then continue iterating. */ async function* generateFromThunk( client: Client, thunk: () => Promise | EmbeddedSet>, options: QueryOptions, ): AsyncGenerator { const result = await thunk(); if (result instanceof Page || result instanceof EmbeddedSet) { for await (const page of generatePages( client, result as Page | EmbeddedSet, options, )) { yield page; } return; } yield [result]; } /** * Internal async generator function that flattens a {@link SetIterator} */ async function* generateItems( setIterator: SetIterator, ) { for await (const page of setIterator) { for (const item of page) { yield item; } } } ```` ## File: src/values/stream.ts ````typescript import { FeedSuccess, QueryValue, StreamEventData, QueryStats, } from "../wire-protocol"; import { getServiceError } from "../errors"; /** * A token used to initiate a Fauna event source at a particular snapshot in time. * * The example below shows how to request an event token from Fauna and use it * to establish an event steam. * * @example * ```javascript * const response = await client.query(fql` * Messages.byRecipient(User.byId("1234")) * `); * const eventSource = response.data; * * const stream = client.stream(eventSource) * .on("add", (event) => console.log("New message", event)) * * stream.start(); * ``` */ export interface EventSource { readonly token: string; } export function isEventSource(value: any): value is EventSource { if (typeof value.token === "string") { return true; } return false; } export class StreamToken implements EventSource { readonly token: string; constructor(token: string) { this.token = token; } } /** * A class to represent a page of events from a Fauna stream. */ export class FeedPage { readonly events: IterableIterator>; readonly cursor: string; readonly hasNext: boolean; readonly stats?: QueryStats; constructor({ events, cursor, has_next, stats }: FeedSuccess) { this.events = this.#toEventIterator(events); this.cursor = cursor; this.hasNext = has_next; this.stats = stats; } *#toEventIterator( events: FeedSuccess["events"], ): IterableIterator> { // A page of events may contain an error event. These won't be reported // at a response level, so we need to check for them here. They are // considered fatal. Pages end at the first error event. for (const event of events) { if (event.type === "error") { throw getServiceError(event); } yield event; } } } ```` ## File: src/client-configuration.ts ````typescript import { HTTPClient, HTTPStreamClient } from "./http-client"; import type { ValueFormat } from "./wire-protocol"; import { LogHandler } from "./util/logging"; /** * Configuration for a client. The options provided are used as the * default options for each query. */ export interface ClientConfiguration { /** * Time in milliseconds beyond {@link ClientConfiguration.query_timeout_ms} at * which the client will abort a request if it has not received a response. * The default is 5000 ms, which should account for network latency for most * clients. The value must be greater than zero. The closer to zero the value * is, the more likely the client is to abort the request before the server * can report a legitimate response or error. */ client_timeout_buffer_ms?: number; /** * The {@link https://developer.mozilla.org/en-US/docs/Web/API/URL|URL} of Fauna to call. See {@link endpoints} for some default options. */ endpoint?: URL; /** * Determines the encoded format expected for the query `arguments` field, and * the `data` field of a successful response. * @remarks **Note, it is very unlikely you need to change this value from its * default.** * The default format is "tagged", which specifies that the driver transmits * type information over the wire. Type information allows the driver and * Fauna to distinguish between types such as int" and "long" which do not * have a standard way of distinguishing in JSON. * Rare use cases can also deal with standard JSON by setting the value to * "simple". Note that the types enocodable in standard JSON are a subset of * the types encodable in the default "tagged" format. * It is not recommended that users use the "simple" format as you will lose * the typing of your data. e.g. a "Date" will no longer be recognized by the * Fauna as a "Date", but will instead be treated as a string. */ format?: ValueFormat; /** * Time in milliseconds the client will keep an HTTP2 session open after all * requests are completed. The default is 5000 ms. */ http2_session_idle_ms?: number; /** * The maximum number of HTTP2 streams to execute in parallel * to Fauna per HTTP2 session. * Only relevant to certain HTTP2 clients. * @remarks * Relevant to clients using the {@link NodeHTTP2Client} provided, * or any custom HTTP2Clients you implement that support this feature. */ http2_max_streams?: number; /** * When true will keep executing a request even if the page * that fired the request is no longer executing. Only relevant * to underlying clients using the {@link https://fetch.spec.whatwg.org/ | Fetch standard}. * By default set to false. * @remarks * Relevant to clients using the {@link FetchClient} provided, * or any custom HTTP Clients you implement using the Fetch standard. */ fetch_keepalive?: boolean; /** * A log handler instance. */ logger?: LogHandler; /** * A secret for your Fauna DB, used to authorize your queries. * @see https://docs.fauna.com/fauna/current/security/keys */ secret?: string; // Query options /** * The timeout of each query, in milliseconds. This controls the maximum amount of * time Fauna will execute your query before marking it failed. The default is 5000 ms. */ query_timeout_ms?: number; /** * If true, unconditionally run the query as strictly serialized. * This affects read-only transactions. Transactions which write * will always be strictly serialized. */ linearized?: boolean; /** * Controls what Javascript type to deserialize {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#long | Fauna longs} to. * Use 'number' to deserialize longs to number. Use 'bigint' to deserialize to bigint. Defaults to 'number'. * Note, for extremely large maginitude numbers Javascript's number will lose precision; as Javascript's * 'number' can only support +/- 2^53-1 whereas Fauna's long is 64 bit. If this is detected, a warning will * be logged to the console and precision loss will occur. * If your application uses extremely large magnitude numbers use 'bigint'. */ long_type?: "number" | "bigint"; /** * The max number of times to retry the query if contention is encountered. */ max_contention_retries?: number; /** * Tags provided back via logging and telemetry. */ query_tags?: { [key: string]: string }; /** * A traceparent provided back via logging and telemetry. * Must match format: https://www.w3.org/TR/trace-context/#traceparent-header */ traceparent?: string; /** * Enable or disable typechecking of the query before evaluation. If no value * is provided, the value of `typechecked` in the database configuration will * be used. */ typecheck?: boolean; /** * Enable or disable performance hints. Defaults to disabled. * The QueryInfo object includes performance hints in the `summary` field, which is a * top-level field in the response object. */ performance_hints?: boolean; /** * Max attempts for retryable exceptions. Default is 3. */ max_attempts?: number; /** * Max backoff between retries. Default is 20 seconds. */ max_backoff?: number; } /** * An extensible interface for a set of Fauna endpoints. * @remarks Leverage the `[key: string]: URL;` field to extend to other endpoints. */ export interface Endpoints { /** Fauna's default endpoint. */ default: URL; /** * An endpoint for interacting with local instance of Fauna (e.g. one running in a local docker container). */ local: URL; /** * An alias for local. */ localhost: URL; /** * Any other endpoint you want your client to support. For example, if you run all requests through a proxy * configure it here. Most clients will not need to leverage this ability. */ [key: string]: URL; } /** * Configuration for a streaming client. This typically comes from the `Client` * instance configuration. */ export type StreamClientConfiguration = { /** * The underlying {@link HTTPStreamClient} that will execute the actual HTTP calls */ httpStreamClient: HTTPStreamClient; /** * Controls what Javascript type to deserialize {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#long | Fauna longs} to. * * @see {@link ClientConfiguration.long_type} */ long_type: "number" | "bigint"; /** * Max attempts for retryable exceptions. */ max_attempts: number; /** * Max backoff between retries. */ max_backoff: number; /** * A secret for your Fauna DB, used to authorize your queries. * @see https://docs.fauna.com/fauna/current/security/keys */ secret: string; /** * A log handler instance. */ logger: LogHandler; /** * Indicates if stream should include "status" events, periodic events that * update the client with the latest valid timestamp (in the event of a * dropped connection) as well as metrics about the cost of maintaining * the stream other than the cost of the received events. */ status_events?: boolean; /** * The last seen event cursor to resume the stream from. When provided, the * stream will start from the given cursor position (exclusively). */ cursor?: string; }; /** * Configuration for an event feed client. */ export type FeedClientConfiguration = Required< Pick< ClientConfiguration, | "long_type" | "max_attempts" | "max_backoff" | "client_timeout_buffer_ms" | "query_timeout_ms" | "secret" | "logger" > > & { /** * The underlying {@link HTTPClient} that will execute the actual HTTP calls. */ httpClient: HTTPClient; /** * The starting timestamp of the event feed, exclusive. If set, Fauna will return events starting after the timestamp. */ start_ts?: number; /** * The starting event cursor, exclusive. If set, Fauna will return events starting after the cursor. */ cursor?: string; /** * Maximum number of events returned per page. * Must be in the range 1 to 16000 (inclusive). * Defaults to 16. */ page_size?: number; }; /** * A extensible set of endpoints for calling Fauna. * @remarks Most clients will will not need to extend this set. * @example * ## To Extend * ```typescript * // add to the endpoints constant * endpoints.myProxyEndpoint = new URL("https://my.proxy.url"); * ``` */ export const endpoints: Endpoints = { default: new URL("https://db.fauna.com"), local: new URL("http://localhost:8443"), localhost: new URL("http://localhost:8443"), }; ```` ## File: src/client.ts ````typescript 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; /** 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( iterable: Page | EmbeddedSet | Query>, options?: QueryOptions, ): SetIterator { if (iterable instanceof Query) { return SetIterator.fromQuery(this, iterable, options); } return SetIterator.fromPageable(this, iterable, options) as SetIterator; } /** * 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<{@link QuerySuccess}>. * * @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( query: Query, options?: QueryOptions, ): Promise> { 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( tokenOrQuery: EventSource | Query, options?: Partial, ): StreamClient { 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(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( tokenOrQuery: EventSource | Query, options?: Partial, ): FeedClient { 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(tokenOrQuery).then((res) => res.data) : tokenOrQuery; return new FeedClient(tokenOrGetToken, clientConfiguration); } async #queryWithRetries( queryRequest: QueryRequest, queryOptions?: QueryOptions, attempt = 0, ): Promise> { 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(queryRequest, queryOptions, attempt); } catch (error) { if (error instanceof ThrottlingError && attempt < maxAttempts) { await wait(backoffMs); return this.#queryWithRetries(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( queryRequest: QueryRequest, queryOptions?: QueryOptions, attempt = 0, ): Promise> { 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; if (res.stats) { res.stats.attempts = attempt; } return res; } catch (e: any) { throw this.#getError(e); } } #setHeaders( fromObject: QueryOptions, headerObject: Record, ): void { const setHeader = ( 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 { /** 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; /** 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), 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 | 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 | 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 '.eventSource' or '.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 | 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 = 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 { /** 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; /** 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), 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 { 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 = { 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> { 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> { if (this.#isDone) { throw new ClientError("The event feed has no more pages to fetch."); } const { httpClient } = this.#clientConfiguration; const request: HTTPRequest = 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 | 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(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> { for await (const page of this) { for (const event of page.events) { yield event; } } } async #resolveEventSource( fn: () => Promise, ): Promise { 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 '.eventSource' or '.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)); } ```` ## File: src/errors.ts ````typescript import type { ConstraintFailure, QueryFailure, QueryInfo, QueryStats, QueryValue, } from "./wire-protocol"; /** * A common error base class for all other errors. */ export abstract class FaunaError extends Error { constructor(...args: any[]) { super(...args); } } /** * An error representing a query failure returned by Fauna. */ export class ServiceError extends FaunaError { /** * The HTTP Status Code of the error. */ readonly httpStatus?: number; /** * A code for the error. Codes indicate the cause of the error. * It is safe to write programmatic logic against the code. They are * part of the API contract. */ readonly code: string; /** * Details about the query sent along with the response */ readonly queryInfo?: QueryInfo; /** * A machine readable description of any constraint failures encountered by the query. * Present only if this query encountered constraint failures. */ readonly constraint_failures?: Array; constructor(failure: QueryFailure, httpStatus?: number) { super(failure.error.message); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ServiceError); } this.name = "ServiceError"; this.code = failure.error.code; this.httpStatus = httpStatus; const info: QueryInfo = { txn_ts: failure.txn_ts, summary: failure.summary, query_tags: failure.query_tags, stats: failure.stats, }; this.queryInfo = info; this.constraint_failures = failure.error.constraint_failures; } } /** * An error response that is the result of the query failing during execution. * QueryRuntimeError's occur when a bug in your query causes an invalid execution * to be requested. * The 'code' field will vary based on the specific error cause. */ export class QueryRuntimeError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryRuntimeError); } this.name = "QueryRuntimeError"; // TODO trace, txn_ts, and stats not yet returned for QueryRuntimeError // flip to check for those rather than a specific code. } } /** * An error due to a "compile-time" check of the query * failing. */ export class QueryCheckError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryCheckError); } this.name = "QueryCheckError"; } } /** * An error due to an invalid request to Fauna. Either the request body was not * valid JSON or did not conform to the API specification */ export class InvalidRequestError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); } this.name = "InvalidRequestError"; } } /** * A runtime error due to failing schema constraints. */ export class ConstraintFailureError extends ServiceError { /** * The list of constraints that failed. */ readonly constraint_failures: Array; constructor( failure: QueryFailure & { error: { constraint_failures: Array }; }, httpStatus?: number, ) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryCheckError); } this.name = "ConstraintFailureError"; this.constraint_failures = failure.error.constraint_failures; } } /** * An error due to calling the FQL `abort` function. */ export class AbortError extends ServiceError { /** * The user provided value passed to the originating `abort()` call. * Present only when the query encountered an `abort()` call, which is denoted * by the error code `"abort"` */ readonly abort: QueryValue; constructor( failure: QueryFailure & { error: { abort: QueryValue } }, httpStatus?: number, ) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryCheckError); } this.name = "AbortError"; this.abort = failure.error.abort; } } /** * AuthenticationError indicates invalid credentials were * used. */ export class AuthenticationError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthenticationError); } this.name = "AuthenticationError"; } } /** * AuthorizationError indicates the credentials used do not have * permission to perform the requested action. */ export class AuthorizationError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthorizationError); } this.name = "AuthorizationError"; } } /** * An error due to a contended transaction. */ export class ContendedTransactionError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); } this.name = "ContendedTransactionError"; } } /** * ThrottlingError indicates some capacity limit was exceeded * and thus the request could not be served. */ export class ThrottlingError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ThrottlingError); } this.name = "ThrottlingError"; } } /** * A failure due to the query timeout being exceeded. * * This error can have one of two sources: * 1. Fauna is behaving expectedly, but the query timeout provided was too * aggressive and lower than the query's expected processing time. * 2. Fauna was not available to service the request before the timeout was * reached. * * In either case, consider increasing the `query_timeout_ms` configuration for * your client. */ export class QueryTimeoutError extends ServiceError { /** * Statistics regarding the query. * * TODO: Deprecate this `stats` field. All `ServiceError`s already provide * access to stats through `queryInfo.stats` */ readonly stats?: QueryStats; constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryTimeoutError); } this.name = "QueryTimeoutError"; this.stats = failure.stats; } } /** * ServiceInternalError indicates Fauna failed unexpectedly. */ export class ServiceInternalError extends ServiceError { constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServiceInternalError); } this.name = "ServiceInternalError"; } } /** * An error representing a failure internal to the client, itself. * This indicates Fauna was never called - the client failed internally * prior to sending the request. */ export class ClientError extends FaunaError { constructor(message: string, options?: { cause: any }) { super(message, options); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ClientError); } this.name = "ClientError"; } } /** * An error thrown if you try to call the client after it has been closed. */ export class ClientClosedError extends FaunaError { constructor(message: string, options?: { cause: any }) { super(message, options); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ClientClosedError); } this.name = "ClientClosedError"; } } /** * An error representing a failure due to the network. * This indicates Fauna was never reached. */ export class NetworkError extends FaunaError { constructor(message: string, options: { cause: any }) { super(message, options); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, NetworkError); } this.name = "NetworkError"; } } /** * An error representing a HTTP failure - but one not directly * emitted by Fauna. */ export class ProtocolError extends FaunaError { /** * The HTTP Status Code of the error. */ readonly httpStatus: number; constructor(error: { message: string; httpStatus: number }) { super(error.message); // Maintains proper stack trace for where our error was thrown (only available on V8) if (Error.captureStackTrace) { Error.captureStackTrace(this, ProtocolError); } this.name = "ProtocolError"; this.httpStatus = error.httpStatus; } } export const getServiceError = ( failure: QueryFailure, httpStatus?: number, ): ServiceError => { const failureCode = failure.error.code; switch (failureCode) { case "invalid_query": return new QueryCheckError(failure, httpStatus); case "invalid_request": return new InvalidRequestError(failure, httpStatus); case "abort": if (failure.error.abort !== undefined) { return new AbortError( failure as QueryFailure & { error: { abort: QueryValue } }, httpStatus, ); } break; case "constraint_failure": if (failure.error.constraint_failures !== undefined) { return new ConstraintFailureError( failure as QueryFailure & { error: { constraint_failures: Array }; }, httpStatus, ); } break; case "unauthorized": return new AuthenticationError(failure, httpStatus); case "forbidden": return new AuthorizationError(failure, httpStatus); case "contended_transaction": return new ContendedTransactionError(failure, httpStatus); case "limit_exceeded": return new ThrottlingError(failure, httpStatus); case "time_out": return new QueryTimeoutError(failure, httpStatus); case "internal_error": return new ServiceInternalError(failure, httpStatus); } return new QueryRuntimeError(failure, httpStatus); }; ```` ## File: src/index.ts ````typescript export { Client, StreamClient, FeedClient } from "./client"; export { endpoints, type ClientConfiguration, type Endpoints, type StreamClientConfiguration, type FeedClientConfiguration, } from "./client-configuration"; export { AbortError, AuthenticationError, AuthorizationError, ClientError, ClientClosedError, ConstraintFailureError, ContendedTransactionError, FaunaError, InvalidRequestError, NetworkError, ProtocolError, QueryCheckError, QueryRuntimeError, QueryTimeoutError, ServiceError, ServiceInternalError, ThrottlingError, } from "./errors"; export { fql, type Query, type QueryArgument, type QueryArgumentObject, } from "./query-builder"; export { DecodeOptions, LONG_MAX, LONG_MIN, TaggedTypeFormat, } from "./tagged-type"; export { type ArrayFragment, type ConstraintFailure, type EncodedObject, type FeedError, type FeedRequest, type FeedSuccess, type FQLFragment, type ObjectFragment, type QueryFailure, type QueryInfo, type QueryInterpolation, type QueryOptions, type QueryRequest, type QueryStats, type QuerySuccess, type QueryValue, type QueryValueObject, type Span, type StreamEventData, type StreamEventError, type StreamEventStatus, type StreamRequest, type TaggedBytes, type TaggedDate, type TaggedDouble, type TaggedInt, type TaggedLong, type TaggedMod, type TaggedObject, type TaggedRef, type TaggedTime, type TaggedType, type ValueFormat, type ValueFragment, } from "./wire-protocol"; export { DateStub, Document, DocumentReference, EmbeddedSet, FlattenedSetIterator, Module, NamedDocument, NamedDocumentReference, NullDocument, Page, SetIterator, StreamToken, TimeStub, FeedPage, type EventSource, type DocumentT, } from "./values"; export { FaunaAPIPaths, FetchClient, getDefaultHTTPClient, HTTPClientOptions, HTTPStreamRequest, isHTTPResponse, isStreamClient, NodeHTTP2Client, SupportedFaunaAPIPaths, type HTTPClient, type HTTPRequest, type HTTPResponse, type HTTPStreamClient, type StreamAdapter, } from "./http-client"; export { LogLevel, LOG_LEVELS, ConsoleLogHandler, LogHandler, parseDebugLevel, } from "./util/logging"; ```` ## File: src/query-builder.ts ````typescript import { TaggedTypeFormat } from "./tagged-type"; import type { FQLFragment, QueryValue, QueryInterpolation, } from "./wire-protocol"; /** * A QueryArgumentObject is a plain javascript object where each property is a * valid QueryArgument. */ export type QueryArgumentObject = { [key: string]: QueryArgument; }; /** * A QueryArgument represents all possible values that can be encoded and passed * to Fauna as a query argument. * * The {@link fql} tagged template function requires all arguments to be of type * QueryArgument. */ export type QueryArgument = | QueryValue | Query | Date | ArrayBuffer | Uint8Array | Array | QueryArgumentObject; /** * Creates a new Query. Accepts template literal inputs. * @typeParam T - The expected type of the response from Fauna when evaluated. * @param queryFragments - An array that constitutes * the strings that are the basis of the query. * @param queryArgs - an Array\ that * constitute the arguments to inject between the queryFragments. * @throws Error - if you call this method directly (not using template * literals) and pass invalid construction parameters * @example * ```typescript * const str = "baz"; * const num = 17; * const innerQuery = fql`${num} + 3)`; * const queryRequestBuilder = fql`${str}.length == ${innerQuery}`; * ``` */ export function fql( queryFragments: ReadonlyArray, ...queryArgs: QueryArgument[] ): Query { return new Query(queryFragments, ...queryArgs); } /** * Internal class. * A builder for composing queries using the {@link fql} tagged template * function * @typeParam T - The expected type of the response from Fauna when evaluated. * T can be used to infer the type of the response type from {@link Client} * methods. */ export class Query { readonly #queryFragments: ReadonlyArray; readonly #interpolatedArgs: QueryArgument[]; /** * A phantom field to enforce the type of the Query. * @internal * * We need to provide an actual property of type `T` for Typescript to * actually enforce it. * * "Because TypeScript is a structural type system, type parameters only * affect the resulting type when consumed as part of the type of a member." * * @see {@link https://www.typescriptlang.org/docs/handbook/type-compatibility.html#generics} */ readonly #__phantom: T; constructor( queryFragments: ReadonlyArray, ...queryArgs: QueryArgument[] ) { if ( queryFragments.length === 0 || queryFragments.length !== queryArgs.length + 1 ) { throw new Error("invalid query constructed"); } this.#queryFragments = queryFragments; this.#interpolatedArgs = queryArgs; // HACK: We have to construct the phantom field, but we don't have any value for it. this.#__phantom = undefined as unknown as T; } /** * Converts this Query to an {@link FQLFragment} you can send * to Fauna. * @returns a {@link FQLFragment}. * @example * ```typescript * const num = 8; * const queryBuilder = fql`'foo'.length == ${num}`; * const queryRequest = queryBuilder.toQuery(); * // produces: * { fql: ["'foo'.length == ", { value: { "@int": "8" } }, ""] } * ``` */ encode(): FQLFragment { if (this.#queryFragments.length === 1) { return { fql: [this.#queryFragments[0]] }; } let renderedFragments: (string | QueryInterpolation)[] = this.#queryFragments.flatMap((fragment, i) => { // There will always be one more fragment than there are arguments if (i === this.#queryFragments.length - 1) { return fragment === "" ? [] : [fragment]; } // arguments in the template format must always be encoded, regardless // of the "x-format" request header // TODO: catch and rethrow Errors, indicating bad user input const arg = this.#interpolatedArgs[i]; const encoded = TaggedTypeFormat.encodeInterpolation(arg); return [fragment, encoded]; }); // We don't need to send empty-string fragments over the wire renderedFragments = renderedFragments.filter((x) => x !== ""); return { fql: renderedFragments }; } } ```` ## File: src/regex.ts ````typescript // Date and Time expressions const yearpart = /(?:\d{4}|[\u2212-]\d{4,}|\+\d{5,})/; const monthpart = /(?:0[1-9]|1[0-2])/; const daypart = /(?:0[1-9]|[12]\d|3[01])/; const hourpart = /(?:[01][0-9]|2[0-3])/; const minsecpart = /(?:[0-5][0-9])/; const decimalpart = /(?:\.\d+)/; const datesplit = new RegExp( `(${yearpart.source}-(${monthpart.source})-(${daypart.source}))` ); const timesplit = new RegExp( `(${hourpart.source}:${minsecpart.source}:${minsecpart.source}${decimalpart.source}?)` ); const zonesplit = new RegExp( `([zZ]|[+\u2212-]${hourpart.source}(?::?${minsecpart.source}|:${minsecpart.source}:${minsecpart.source}))` ); /** * Matches the subset of ISO8601 dates that Fauna can accept. Cannot include any * time part */ export const plaindate = new RegExp(`^${datesplit.source}$`); /** * Matches a valid ISO8601 date and can have anything trailing after. */ export const startsWithPlaindate = new RegExp(`^${datesplit.source}`); /** * Matches the subset of ISO8601 times that Fauna can accept. */ export const datetime = new RegExp( `^${datesplit.source}T${timesplit.source}${zonesplit.source}$` ); ```` ## File: src/tagged-type.ts ````typescript import base64 from "base64-js"; import { ClientError } from "./errors"; import { DateStub, Document, DocumentReference, Module, NamedDocument, NamedDocumentReference, TimeStub, Page, NullDocument, EmbeddedSet, StreamToken, } from "./values"; import { QueryValue, QueryInterpolation, ObjectFragment, ArrayFragment, FQLFragment, ValueFragment, TaggedType, TaggedLong, TaggedInt, TaggedDouble, TaggedObject, EncodedObject, TaggedTime, TaggedDate, TaggedMod, TaggedRef, TaggedBytes, } from "./wire-protocol"; import { Query, QueryArgument, QueryArgumentObject } from "./query-builder"; export interface DecodeOptions { long_type: "number" | "bigint"; } /** * TaggedType provides the encoding/decoding of the Fauna Tagged Type formatting */ export class TaggedTypeFormat { /** * Encode the value to the Tagged Type format for Fauna * * @param input - value that will be encoded * @returns Map of result */ static encode(input: QueryArgument): TaggedType { return encode(input); } /** * Encode the value to a QueryInterpolation to send to Fauna * * @param input - value that will be encoded * @returns Map of result */ static encodeInterpolation(input: QueryArgument): QueryInterpolation { return encodeInterpolation(input); } /** * Decode the JSON string result from Fauna to remove Tagged Type formatting. * * @param input - JSON string result from Fauna * @returns object of result of FQL query */ static decode(input: string, decodeOptions: DecodeOptions): any { return JSON.parse(input, (_, value: any) => { if (value == null) return null; if (value["@mod"]) { return new Module(value["@mod"]); } else if (value["@doc"]) { // WIP: The string-based ref is being removed from the API if (typeof value["@doc"] === "string") { const [modName, id] = value["@doc"].split(":"); return new DocumentReference({ coll: modName, id: id }); } // if not a docref string, then it is an object. const obj = value["@doc"]; if (obj.id) { return new Document(obj); } else { return new NamedDocument(obj); } } else if (value["@ref"]) { const obj = value["@ref"]; let ref: DocumentReference | NamedDocumentReference; if (obj.id) { ref = new DocumentReference(obj); } else { ref = new NamedDocumentReference(obj); } if ("exists" in obj && obj.exists === false) { return new NullDocument(ref, obj.cause); } return ref; } else if (value["@set"]) { if (typeof value["@set"] === "string") { return new EmbeddedSet(value["@set"]); } return new Page(value["@set"]); } else if (value["@int"]) { return Number(value["@int"]); } else if (value["@long"]) { const bigInt = BigInt(value["@long"]); if (decodeOptions.long_type === "number") { if ( bigInt > Number.MAX_SAFE_INTEGER || bigInt < Number.MIN_SAFE_INTEGER ) { console.warn(`Value is too large to be represented as a number. \ Returning as Number with loss of precision. Use long_type 'bigint' instead.`); } return Number(bigInt); } return bigInt; } else if (value["@double"]) { return Number(value["@double"]); } else if (value["@date"]) { return DateStub.from(value["@date"]); } else if (value["@time"]) { return TimeStub.from(value["@time"]); } else if (value["@object"]) { return value["@object"]; } else if (value["@stream"]) { return new StreamToken(value["@stream"]); } else if (value["@bytes"]) { return base64toBuffer(value["@bytes"]); } return value; }); } } export const LONG_MIN = BigInt("-9223372036854775808"); export const LONG_MAX = BigInt("9223372036854775807"); export const INT_MIN = -(2 ** 31); export const INT_MAX = 2 ** 31 - 1; const encodeMap = { bigint: (value: bigint): TaggedLong | TaggedInt => { if (value < LONG_MIN || value > LONG_MAX) { throw new RangeError( "BigInt value exceeds max magnitude for a 64-bit Fauna long. Use a 'number' to represent doubles beyond that limit.", ); } if (value >= INT_MIN && value <= INT_MAX) { return { "@int": value.toString() }; } return { "@long": value.toString(), }; }, number: (value: number): TaggedDouble | TaggedInt | TaggedLong => { if ( value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY ) { throw new RangeError(`Cannot convert ${value} to a Fauna type.`); } if (!Number.isInteger(value)) { return { "@double": value.toString() }; } else { if (value >= INT_MIN && value <= INT_MAX) { return { "@int": value.toString() }; } else if (Number.isSafeInteger(value)) { return { "@long": value.toString(), }; } return { "@double": value.toString() }; } }, string: (value: string): string => { return value; }, object: (input: QueryArgumentObject): TaggedObject | EncodedObject => { let wrapped = false; const _out: EncodedObject = {}; for (const k in input) { if (k.startsWith("@")) { wrapped = true; } if (input[k] !== undefined) { _out[k] = encode(input[k]); } } return wrapped ? { "@object": _out } : _out; }, array: (input: QueryArgument[]): TaggedType[] => input.map(encode), date: (dateValue: Date): TaggedTime => ({ "@time": dateValue.toISOString(), }), faunadate: (value: DateStub): TaggedDate => ({ "@date": value.dateString }), faunatime: (value: TimeStub): TaggedTime => ({ "@time": value.isoString }), module: (value: Module): TaggedMod => ({ "@mod": value.name }), documentReference: (value: DocumentReference): TaggedRef => ({ "@ref": { id: value.id, coll: { "@mod": value.coll.name } }, }), document: (value: Document): TaggedRef => ({ "@ref": { id: value.id, coll: { "@mod": value.coll.name } }, }), namedDocumentReference: (value: NamedDocumentReference): TaggedRef => ({ "@ref": { name: value.name, coll: { "@mod": value.coll.name } }, }), namedDocument: (value: NamedDocument): TaggedRef => ({ "@ref": { name: value.name, coll: { "@mod": value.coll.name } }, }), // eslint-disable-next-line @typescript-eslint/no-unused-vars set: (value: Page | EmbeddedSet) => { throw new ClientError( "Page could not be encoded. Fauna does not accept encoded Set values, yet. Use Page.data and Page.after as arguments, instead.", ); // TODO: uncomment to encode Pages once core starts accepting `@set` tagged values // if (value.data === undefined) { // // if a Page has no data, then it must still have an 'after' cursor // return { "@set": value.after }; // } // return { // "@set": { data: encodeMap["array"](value.data), after: value.after }, // }; }, // TODO: encode as a tagged value if provided as a query arg? // streamToken: (value: StreamToken): TaggedStreamToken => ({ "@stream": value.token }), streamToken: (value: StreamToken): string => value.token, bytes: (value: ArrayBuffer | Uint8Array): TaggedBytes => ({ "@bytes": bufferToBase64(value), }), }; const encode = (input: QueryArgument): TaggedType => { switch (typeof input) { case "bigint": return encodeMap["bigint"](input); case "string": return encodeMap["string"](input); case "number": return encodeMap["number"](input); case "boolean": return input; case "object": if (input == null) { return null; } else if (Array.isArray(input)) { return encodeMap["array"](input); } else if (input instanceof Date) { return encodeMap["date"](input); } else if (input instanceof DateStub) { return encodeMap["faunadate"](input); } else if (input instanceof TimeStub) { return encodeMap["faunatime"](input); } else if (input instanceof Module) { return encodeMap["module"](input); } else if (input instanceof Document) { // Document extends DocumentReference, so order is important here return encodeMap["document"](input); } else if (input instanceof DocumentReference) { return encodeMap["documentReference"](input); } else if (input instanceof NamedDocument) { // NamedDocument extends NamedDocumentReference, so order is important here return encodeMap["namedDocument"](input); } else if (input instanceof NamedDocumentReference) { return encodeMap["namedDocumentReference"](input); } else if (input instanceof NullDocument) { return encode(input.ref); } else if (input instanceof Page) { return encodeMap["set"](input); } else if (input instanceof EmbeddedSet) { return encodeMap["set"](input); } else if (input instanceof StreamToken) { return encodeMap["streamToken"](input); } else if (input instanceof Uint8Array || input instanceof ArrayBuffer) { return encodeMap["bytes"](input); } else if (ArrayBuffer.isView(input)) { throw new ClientError( "Error encoding TypedArray to Fauna Bytes. Convert your TypedArray to Uint8Array or ArrayBuffer before passing it to Fauna. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray", ); } else if (input instanceof Query) { throw new TypeError( "Cannot encode instance of type 'Query'. Try using TaggedTypeFormat.encodeInterpolation instead.", ); } else { return encodeMap["object"](input); } default: // catch "undefined", "symbol", and "function" throw new TypeError( `Passing ${typeof input} as a QueryArgument is not supported`, ); } // anything here would be unreachable code }; const encodeInterpolation = (input: QueryArgument): QueryInterpolation => { switch (typeof input) { case "bigint": case "string": case "number": case "boolean": return encodeValueInterpolation(encode(input)); case "object": if ( input == null || input instanceof Date || input instanceof DateStub || input instanceof TimeStub || input instanceof Module || input instanceof DocumentReference || input instanceof NamedDocumentReference || input instanceof Page || input instanceof EmbeddedSet || input instanceof StreamToken || input instanceof Uint8Array || input instanceof ArrayBuffer || ArrayBuffer.isView(input) ) { return encodeValueInterpolation(encode(input)); } else if (input instanceof NullDocument) { return encodeInterpolation(input.ref); } else if (input instanceof Query) { return encodeQueryInterpolation(input); } else if (Array.isArray(input)) { return encodeArrayInterpolation(input); } else { return encodeObjectInterpolation(input); } default: // catch "undefined", "symbol", and "function" throw new TypeError( `Passing ${typeof input} as a QueryArgument is not supported`, ); } }; const encodeObjectInterpolation = ( input: QueryArgumentObject, ): ObjectFragment => { const _out: EncodedObject = {}; for (const k in input) { if (input[k] !== undefined) { _out[k] = encodeInterpolation(input[k]); } } return { object: _out }; }; const encodeArrayInterpolation = ( input: Array, ): ArrayFragment => { const encodedItems = input.map(encodeInterpolation); return { array: encodedItems }; }; const encodeQueryInterpolation = (value: Query): FQLFragment => value.encode(); const encodeValueInterpolation = (value: TaggedType): ValueFragment => ({ value, }); function base64toBuffer(value: string): Uint8Array { return base64.toByteArray(value); } function bufferToBase64(value: ArrayBuffer | Uint8Array): string { const arr: Uint8Array = value instanceof Uint8Array ? value : new Uint8Array(value); return base64.fromByteArray(arr); } ```` ## File: src/wire-protocol.ts ````typescript import { QueryArgumentObject } from "./query-builder"; import { DateStub, Document, DocumentReference, EmbeddedSet, Module, NamedDocument, NamedDocumentReference, NullDocument, Page, StreamToken, TimeStub, } from "./values"; /** * A request to make to Fauna. */ export interface QueryRequest< T extends string | QueryInterpolation = string | QueryInterpolation, > { /** The query */ query: T; /** Optional arguments. Variables in the query will be initialized to the * value associated with an argument key. */ arguments?: EncodedObject; } /** * Options for queries. Each query can be made with different options. Settings here * take precedence over those in {@link ClientConfiguration}. */ export interface QueryOptions { /** Optional arguments. Variables in the query will be initialized to the * value associated with an argument key. */ arguments?: QueryArgumentObject; /** * Determines the encoded format expected for the query `arguments` field, and * the `data` field of a successful response. * Overrides the optional setting on the {@link ClientConfiguration}. */ format?: ValueFormat; /** * If true, unconditionally run the query as strictly serialized. * This affects read-only transactions. Transactions which write * will always be strictly serialized. * Overrides the optional setting on the {@link ClientConfiguration}. */ linearized?: boolean; /** * Controls what Javascript type to deserialize {@link https://docs.fauna.com/fauna/current/reference/fql_reference/types#long | Fauna longs} to. * Use 'number' to deserialize longs to number. Use 'bigint' to deserialize to bigint. Defaults to 'number'. * Note, for extremely large maginitude numbers Javascript's number will lose precision; as Javascript's * 'number' can only support +/- 2^53-1 whereas Fauna's long is 64 bit. If this is detected, a warning will * be logged to the console and precision loss will occur. * If your application uses extremely large magnitude numbers use 'bigint'. */ long_type?: "number" | "bigint"; /** * The max number of times to retry the query if contention is encountered. *Overrides the optional setting on the {@link ClientConfiguration}. */ max_contention_retries?: number; /** * Tags provided back via logging and telemetry. * Overrides the optional setting on the {@link ClientConfiguration}. */ query_tags?: Record; /** * The timeout to use in this query in milliseconds. * Overrides the optional setting on the {@link ClientConfiguration}. */ query_timeout_ms?: number; /** * A traceparent provided back via logging and telemetry. * Must match format: https://www.w3.org/TR/trace-context/#traceparent-header * Overrides the optional setting on the {@link ClientConfiguration}. */ traceparent?: string; /** * Enable or disable typechecking of the query before evaluation. If no value * is provided, the value of `typechecked` in the database configuration will * be used. * Overrides the optional setting on the {@link ClientConfiguration}. */ typecheck?: boolean; /** * Enable or disable performance hints. Defaults to disabled. * The QueryInfo object includes performance hints in the `summary` field, which is a * top-level field in the response object. * Overrides the optional setting on the {@link ClientConfiguration}. */ performance_hints?: boolean; /** * Secret to use instead of the client's secret. */ secret?: string; } /** * tagged declares that type information is transmitted and received by the driver. * "simple" indicates it is not - pure JSON is used. * "decorated" will cause the service output to be shown in FQL syntax that could * hypothetically be used to query Fauna. This is intended to support CLI and * REPL like tools. * @example * ```typescript * // example of decorated output * { time: Time("2012-01-01T00:00:00Z") } * ``` */ export declare type ValueFormat = "simple" | "tagged" | "decorated"; export type QueryStats = { /** The amount of Transactional Compute Ops consumed by the query. */ compute_ops: number; /** The amount of Transactional Read Ops consumed by the query. */ read_ops: number; /** The amount of Transactional Write Ops consumed by the query. */ write_ops: number; /** The query run time in milliseconds. */ query_time_ms: number; /** The amount of data read from storage, in bytes. */ storage_bytes_read: number; /** The amount of data written to storage, in bytes. */ storage_bytes_write: number; /** The number of times the transaction was retried due to write contention. */ contention_retries: number; /** The number query attempts made due to retryable errors. */ attempts: number; /** * A list of rate limits hit. * Included with QueryFailure responses when the query is rate limited. */ rate_limits_hit?: ("read" | "write" | "compute")[]; }; export type QueryInfo = { /** The last transaction timestamp of the query. A Unix epoch in microseconds. */ txn_ts?: number; /** The schema version that was used for the query execution. */ schema_version?: number; /** A readable summary of any warnings or logs emitted by the query. */ summary?: string; /** The value of the x-query-tags header, if it was provided. */ query_tags?: Record; /** Stats on query performance and cost */ stats?: QueryStats; }; /** * A decoded response from a successful query to Fauna */ export type QuerySuccess = QueryInfo & { /** * The result of the query. The data is any valid JSON value. * @remarks * data is type parameterized so that you can treat it as a * certain type if you are using typescript. */ data: T; /** The query's inferred static result type. */ static_type?: string; }; /** * A decoded response from a failed query to Fauna. Integrations which only want to report a human * readable version of the failure can simply print out the "summary" field. */ export type QueryFailure = QueryInfo & { /** * The result of the query resulting in */ error: { /** A predefined code which indicates the type of error. See XXX for a list of error codes. */ code: string; /** A short, human readable description of the error */ message: string; /** * A machine readable description of any constraint failures encountered by the query. * Present only if this query encountered constraint failures. */ constraint_failures?: Array; /** * The user provided value passed to the originating `abort()` call. * Present only when the query encountered an `abort()` call, which is * denoted by the error code `"abort"` */ abort?: QueryValue; }; }; /** * A constraint failure triggered by a query. */ export type ConstraintFailure = { /** Description of the constraint failure */ message: string; /** Name of the failed constraint */ name?: string; /** Path into the write input data to which the failure applies */ paths?: Array>; }; export type QueryResponse = | QuerySuccess | QueryFailure; export const isQuerySuccess = (res: any): res is QuerySuccess => res instanceof Object && "data" in res; export const isQueryFailure = (res: any): res is QueryFailure => res instanceof Object && "error" in res && res.error instanceof Object && "code" in res.error && "message" in res.error; export const isQueryResponse = (res: any): res is QueryResponse => isQueryResponse(res) || isQueryFailure(res); /** * A piece of an interpolated query. Interpolated queries can be safely composed * together without concern of query string injection. * @see {@link ValueFragment} and {@link FQLFragment} for additional * information */ export type QueryInterpolation = | FQLFragment | ValueFragment | ObjectFragment | ArrayFragment; /** * A piece of an interpolated query that represents an actual value. Arguments * are passed to fauna using ValueFragments so that query string injection is * not possible. * @remarks A ValueFragment is created by this driver when a literal value or * object is provided as an argument to the {@link fql} tagged template * function. * * ValueFragments must always be encoded with tags, regardless of the "x-format" * request header sent. * @example * ```typescript * const num = 17; * const query = fql`${num} + 3)`; * // produces * { "fql": [{ "value": { "@int": "17" } }, " + 3"] } * ``` */ export type ValueFragment = { value: TaggedType }; /** * A piece of an interpolated query that represents an object. Arguments * are passed to fauna using ObjectFragments so that query arguments can be * nested within javascript objects. * * ObjectFragments must always be encoded with tags, regardless of the * "x-format" request header sent. * @example * ```typescript * const arg = { startDate: DateStub.from("2023-09-01") }; * const query = fql`${arg})`; * // produces * { * "fql": [ * { * "object": { * "startDate": { * "value": { "@date": "2023-09-01" } // Object field values have type QueryInterpolation * } * } * } * ] * } * ``` */ export type ObjectFragment = { object: EncodedObject }; /** * A piece of an interpolated query that represents an array. Arguments * are passed to fauna using ArrayFragments so that query arguments can be * nested within javascript arrays. * * ArrayFragments must always be encoded with tags, regardless of the "x-format" * request header sent. * @example * ```typescript * const arg = [1, 2]; * const query = fql`${arg})`; * // produces * { * "fql": [ * { * "array": [ * { "value": { "@int": "1" } }, // Array items have type QueryInterpolation * { "value": { "@int": "2" } } * ] * } * ] * } * ``` */ export type ArrayFragment = { array: TaggedType[] }; /** * A piece of an interpolated query. Interpolated Queries can be safely composed * together without concern of query string injection. * @remarks A FQLFragment is created when calling the {@link fql} tagged * template function and can be passed as an argument to other Querys. * @example * ```typescript * const num = 17; * const query1 = fql`${num} + 3)`; * const query2 = fql`5 + ${query1})`; * // produces * { "fql": ["5 + ", { "fql": [{ "value": { "@int": "17" } }, " + 3"] }] } * ``` */ export type FQLFragment = { fql: (string | QueryInterpolation)[] }; /** * A source span indicating a segment of FQL. */ export interface Span { /** * A string identifier of the FQL source. For example, if performing * a raw query against the API this would be *query*. */ src: string; /** * The span's starting index within the src, inclusive. */ start: number; /** * The span's ending index within the src, inclusive. */ end: number; /** * The name of the enclosing function, if applicable. */ function: string; } /** * A QueryValueObject is a plain javascript object where each value is a valid * QueryValue. * These objects can be returned in {@link QuerySuccess}. */ export interface QueryValueObject { [key: string]: QueryValue; } /** * A QueryValue represents the possible return values in a {@link QuerySuccess}. */ export type QueryValue = // plain javascript values | null | string | number | bigint | boolean | QueryValueObject | Array | Uint8Array // client-provided classes | DateStub | TimeStub | Module | Document | DocumentReference | NamedDocument | NamedDocumentReference | NullDocument | Page | EmbeddedSet | StreamToken; export type StreamRequest = { token: string; start_ts?: number; cursor?: string; }; export type StreamEventType = "status" | "add" | "remove" | "update" | "error"; export type StreamEventStatus = { type: "status"; txn_ts: number; cursor: string; stats: QueryStats; }; export type StreamEventData = { type: "add" | "remove" | "update"; txn_ts: number; cursor: string; stats: QueryStats; data: T; }; export type StreamEventError = { type: "error" } & QueryFailure; export type StreamEvent = | StreamEventStatus | StreamEventData | StreamEventError; export type FeedRequest = StreamRequest & { page_size?: number; }; export type FeedSuccess = { events: (StreamEventData | StreamEventError)[]; cursor: string; has_next: boolean; stats?: QueryStats; }; export type FeedError = QueryFailure; export type TaggedBytes = { "@bytes": string }; export type TaggedDate = { "@date": string }; export type TaggedDouble = { "@double": string }; export type TaggedInt = { "@int": string }; export type TaggedLong = { "@long": string }; export type TaggedMod = { "@mod": string }; export type TaggedObject = { "@object": QueryValueObject }; export type TaggedRef = { "@ref": { id: string; coll: TaggedMod } | { name: string; coll: TaggedMod }; }; // WIP: core does not accept `@set` tagged values // type TaggedSet = { "@set": { data: QueryValue[]; after?: string } }; export type TaggedTime = { "@time": string }; export type EncodedObject = { [key: string]: TaggedType }; export type TaggedType = | string | boolean | null | EncodedObject | TaggedBytes | TaggedDate | TaggedDouble | TaggedInt | TaggedLong | TaggedMod | TaggedObject | TaggedRef | TaggedTime | TaggedType[]; ````