diff --git a/README.md b/README.md index 4f75c471..5e6fb096 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ See the [Fauna Documentation](https://docs.fauna.com/fauna/current/) for additio - ## Supported runtimes **Server-side** @@ -67,10 +66,9 @@ Stable versions of: - Safari 12.1+ - Edge 79+ - ## Install -The driver is available on [npm](https://www.npmjs.com/package/fauna). You +The driver is available on [npm](https://www.npmjs.com/package/fauna). You can install it using your preferred package manager. For example: ```shell @@ -85,7 +83,6 @@ Browsers can import the driver using a CDN link: ``` - ## Usage By default, the driver's `Client` instance authenticates with Fauna using an @@ -134,7 +131,6 @@ try { } ``` - ### Write FQL queries The `fql` function is your gateway to building safe, reuseable Fauna queries. @@ -175,8 +171,7 @@ This has several advantages: - You can use `fql` to build a library of subqueries applicable to your domain - and combinable in whatever way you need - Injection attacks are not possible if you pass input variables into the interpolated (`` `${interpoloated_argument}` ``) parts of the query. -- The driver speaks "pure" FQL - you can try out some FQL queries on the dashboard's terminal and paste it directly into your app like `` fql`copied from terminal...` `` and the query will work as is. - +- The driver speaks "pure" FQL - you can try out some FQL queries on the dashboard's terminal and paste it directly into your app like ``fql`copied from terminal...` `` and the query will work as is. ### Typescript support @@ -198,14 +193,37 @@ const query = fql`{ }`; const response: QuerySuccess = await client.query(query); -const user_doc: User = response.data; +const userDoc: User = response.data; -console.assert(user_doc.name === "Alice"); -console.assert(user_doc.email === "alice@site.example"); +console.assert(userDoc.name === "Alice"); +console.assert(userDoc.email === "alice@site.example"); client.close(); ``` +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 +`Client` methods, it 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(); +``` ### Query options @@ -230,12 +248,11 @@ const options: QueryOptions = { }; const response = await client.query(fql`"Hello, #{name}!"`, options); -console.log(response.data) +console.log(response.data); client.close(); ``` - ### Query statistics Query statistics are returned with successful query responses and errors of @@ -255,7 +272,7 @@ const client = new Client(); try { const response: QuerySuccess = await client.query( - fql`"Hello world"` + fql`"Hello world"`, ); const stats: QueryStats | undefined = response.stats; console.log(stats); @@ -307,7 +324,7 @@ const pages: SetIterator = client.paginate(query, options); for await (const products of pages) { for (const product of products) { - console.log(product) + console.log(product); } } @@ -320,7 +337,7 @@ Use `flatten()` to get paginated results as a single, flat array: const pages: SetIterator = client.paginate(query, options); for await (const product of pages.flatten()) { - console.log(product) + console.log(product); } ``` @@ -360,7 +377,6 @@ const config: ClientConfiguration = { const client = new Client(config); ``` - ### Environment variables The driver will default to configuring your client with the values of the `FAUNA_SECRET` and `FAUNA_ENDPOINT` environment variable. @@ -378,27 +394,22 @@ You can initalize the client with a default configuration: const client = new Client(); ``` - ### Retry - #### Max attempts The maximum number of times a query will be attempted if a retryable exception is thrown (ThrottlingError). Default 3, inclusive of the initial call. The retry strategy implemented is a simple exponential backoff. To disable retries, pass max_attempts less than or equal to 1. - #### Max backoff The maximum backoff in seconds to be observed between each retry. Default 20 seconds. - ### Timeouts There are a few different timeout settings that can be configured; each comes with a default setting. We recommend that most applications simply stick to the defaults. - #### Query timeout The query timeout is the time, in milliseconds, that Fauna will spend executing your query before aborting with a 503 Timeout error. If a query timeout occurs, the driver will throw an instance of `QueryTimeoutError`. @@ -417,7 +428,6 @@ when performing this query. const response = await client.query(myQuery, { query_timeout_ms: 20_000 }); ``` - #### Client timeout The client timeout is the time, in milliseconds, that the client will wait for a network response before canceling the request. If a client timeout occurs, the driver will throw an instance of `NetworkError`. @@ -428,7 +438,6 @@ The client timeout is always the query timeout plus an additional buffer. This e const client = new Client({ client_timeout_buffer_ms: 6000 }); ``` - #### HTTP/2 session idle timeout The HTTP/2 session idle timeout is the time, in milliseconds, that an HTTP/2 session will remain open after there is no more pending communication. Once the session idle time has elapsed the session is considered idle and the session is closed. Subsequent requests will create a new session; the session idle timeout does not result in an error. @@ -473,15 +482,15 @@ const response = await client.query(fql` `); const { initialPage, streamToken } = response.data; -client.stream(streamToken) +client.stream(streamToken); ``` You can also pass a query that produces a stream token directly to `stream()`: ```javascript -const query = fql`Product.all().changesOn(.price, .quantity)` +const query = fql`Product.all().changesOn(.price, .quantity)`; -client.stream(query) +client.stream(query); ``` ### Iterate on a stream @@ -504,7 +513,6 @@ try { // 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 } ``` @@ -527,9 +535,8 @@ stream.start( // 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 - } + }, ); ``` @@ -538,7 +545,7 @@ stream.start( Use `close()` to close a stream: ```javascript -const stream = await client.stream(fql`Product.all().toStream()`) +const stream = await client.stream(fql`Product.all().toStream()`); let count = 0; for await (const event of stream) { @@ -548,7 +555,7 @@ for await (const event of stream) { // Close the stream after 2 events if (count === 2) { - stream.close() + stream.close(); break; } } @@ -570,14 +577,13 @@ const options = { status_events: true, }; -client.stream(fql`Product.all().toStream()`, options) +client.stream(fql`Product.all().toStream()`, options); ``` For supported properties, see [Stream options](https://docs.fauna.com/fauna/current/drivers/js-client#stream-options) in the Fauna docs. - ## Contributing Any contributions are from the community are greatly appreciated! @@ -586,26 +592,22 @@ If you have a suggestion that would make this better, please fork the repo and c Don't forget to give the project a star! Thanks again! - ### Set up the repo 1. Clone the repository; e.g. `gh repo clone fauna/fauna-js` if you use the GitHub CLI 2. Install dependencies via `yarn install` - ### Run tests 1. Start a docker desktop or other docker platform. 2. Run `yarn test`. This will start local fauna containers, verify they're up and run all tests. - ### Lint your code Linting runs automatically on each commit. If you wish to run on-demand run `yarn lint`. - ## License Distributed under the MPL 2.0 License. See [LICENSE](./LICENSE) for more information. diff --git a/__tests__/integration/query-typings.test.ts b/__tests__/integration/query-typings.test.ts index c49c421d..b847b6ff 100644 --- a/__tests__/integration/query-typings.test.ts +++ b/__tests__/integration/query-typings.test.ts @@ -1,6 +1,9 @@ -import { fql, QueryCheckError } from "../../src"; +import { fql, Page, QueryCheckError } from "../../src"; import { getClient } from "../client"; +// added in a junk property that is not part of QueryValue +type MyType = { x: number; t: QueryCheckError }; + const client = getClient(); afterAll(() => { @@ -12,19 +15,67 @@ describe.each` ${"query"} ${"paginate"} `("$method typings", ({ method }: { method: string }) => { - it("allows customers to use their own types", async () => { - expect.assertions(1); - // added in a junk property that is not part of QueryValue - type MyType = { x: number; t: QueryCheckError }; + it("allows customers to use their own types in queries", async () => { + const query = fql`{ "x": 123 }`; + const paginatedQuery = fql`[{ "x": 123}].toSet()`; + if ("query" === method) { - const result = (await client.query(fql`{ "x": 123}`)).data; + const result = (await client.query(query)).data; expect(result).toEqual({ x: 123 }); } else { - for await (const page of client.paginate(fql`{ "x": 123}`)) { + expect.assertions(2); + for await (const page of client.paginate(paginatedQuery)) { + for (const result of page) { + expect(result).toEqual({ x: 123 }); + } + } + + // It is also allowed to provide a query that does not return a page. + // When this happenes, the driver treats the result as if a page with + // exactly one item is returned. + for await (const page of client.paginate(query)) { for (const result of page) { expect(result).toEqual({ x: 123 }); } } } }); + + it("allows customers to infer their own types in queries from fql statements", async () => { + const query = fql`{ "x": 123 }`; + const paginatedQuery = fql>`[{ "x": 123}].toSet()`; + + if ("query" === method) { + const result = (await client.query(query)).data; + expect(result).toEqual({ x: 123 }); + } else { + expect.assertions(2); + for await (const page of client.paginate(paginatedQuery)) { + for (const result of page) { + expect(result).toEqual({ x: 123 }); + } + } + + // It is also allowed to provide a query that does not return a page. + // When this happenes, the driver treats the result as if a page with + // exactly one item is returned. + for await (const page of client.paginate(query)) { + for (const result of page) { + expect(result).toEqual({ x: 123 }); + } + } + } + }); + + it("allows customers to use subtyped queries", async () => { + const query = fql`"hello"`; + + const result = (await client.query(query)).data; + expect(result).toEqual("hello"); + + // And make sure that the opposite is not possible + const query2 = fql`"hello"`; + // @ts-expect-error Argument of type 'Query' is not assignable to parameter of type 'Query'. + await client.query(query2); + }); }); diff --git a/src/client.ts b/src/client.ts index 8d8ae344..ad4e3b15 100644 --- a/src/client.ts +++ b/src/client.ts @@ -175,7 +175,7 @@ export class Client { * max_attempts, inclusive of the initial call. * * @typeParam T - The expected type of the items returned from Fauna on each - * iteration + * 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. @@ -211,7 +211,7 @@ export class Client { * ``` */ paginate( - iterable: Page | EmbeddedSet | Query, + iterable: Page | EmbeddedSet | Query>, options?: QueryOptions, ): SetIterator { if (iterable instanceof Query) { @@ -224,10 +224,11 @@ export class Client { * 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 + * @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. + * 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}>. @@ -245,7 +246,7 @@ export class Client { * due to an internal error. */ async query( - query: Query, + query: Query, options?: QueryOptions, ): Promise> { if (this.#isClosed) { @@ -269,9 +270,11 @@ export class Client { /** * Initialize a streaming request to Fauna + * @typeParam T - The expected type of the response from Fauna. T can be inferred + * if theprovided query used a type parameter. * @param query - A string-encoded streaming token, or a {@link Query} * @returns A {@link StreamClient} that which can be used to listen to a stream - * of events + * of events * * @example * ```javascript @@ -323,7 +326,7 @@ export class Client { * ``` */ stream( - tokenOrQuery: StreamToken | Query, + tokenOrQuery: StreamToken | Query, options?: Partial, ): StreamClient { if (this.#isClosed) { diff --git a/src/query-builder.ts b/src/query-builder.ts index 381c12e9..8ae0e553 100644 --- a/src/query-builder.ts +++ b/src/query-builder.ts @@ -22,7 +22,7 @@ export type QueryArgumentObject = { */ export type QueryArgument = | QueryValue - | Query + | Query | Date | ArrayBuffer | Uint8Array @@ -31,6 +31,7 @@ export type QueryArgument = /** * Creates a new Query. Accepts template literal inputs. + * @typeParam T - The expected type of the response from Fauna when evaluated. * @param queryFragments - a {@link TemplateStringsArray} that constitute * the strings that are the basis of the query. * @param queryArgs - an Array\ that @@ -45,21 +46,37 @@ export type QueryArgument = * const queryRequestBuilder = fql`${str}.length == ${innerQuery}`; * ``` */ -export function fql( +export function fql( queryFragments: ReadonlyArray, ...queryArgs: QueryArgument[] -): Query { - return new Query(queryFragments, ...queryArgs); +): 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 { +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, @@ -73,6 +90,9 @@ export class Query { } 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; } /**