From f003f5b0485ea0c6db000992cf169d8255f874ce Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Thu, 11 Apr 2024 12:04:48 -0400 Subject: [PATCH 1/3] Remove streaming feature flags from beta (#251) --- docker/feature-flags.json | 27 --------------------------- package.json | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 docker/feature-flags.json diff --git a/docker/feature-flags.json b/docker/feature-flags.json deleted file mode 100644 index 2fc48a0a..00000000 --- a/docker/feature-flags.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": 1, - "properties": [ - { - "property_name": "cluster_name", - "property_value": "fauna", - "flags": { - "fql2_schema": true, - "fqlx_typecheck_default": true, - "persisted_fields": true, - "changes_by_collection_index": true, - "fql2_streams": true - } - }, - { - "property_name": "account_id", - "property_value": 0, - "flags": { - "fql2_schema": true, - "fqlx_typecheck_default": true, - "persisted_fields": true, - "changes_by_collection_index": true, - "fql2_streams": true - } - } - ] -} diff --git a/package.json b/package.json index a332cff4..28fb64d2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "build:node": "esbuild src/index.ts --bundle --sourcemap --platform=node --outfile=dist/node/index.js", "build:types": "tsc -emitDeclarationOnly --declaration true", "lint": "eslint -f unix \"src/**/*.{ts,tsx}\"", - "fauna-local": "docker start faunadb-local || docker run --rm -d --name faunadb-local -p 8443:8443 -p 8084:8084 --mount type=bind,source=\"$(pwd)\"/docker/feature-flags.json,target=/etc/feature-flag-periodic.d/feature-flags.json fauna/faunadb", + "fauna-local": "docker start faunadb-local || docker run --rm -d --name faunadb-local -p 8443:8443 -p 8084:8084 fauna/faunadb", "fauna-local-alt-port": "docker start faunadb-local-alt-port || docker run --rm -d --name faunadb-local-alt-port -p 7443:8443 -p 7084:8084 fauna/faunadb", "prepare": "husky install", "test": "yarn fauna-local; yarn fauna-local-alt-port; ./prepare-test-env.sh; jest", From cf6929c76f209bc9281a86922bfedec387efe6c5 Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Mon, 6 May 2024 15:02:16 -0400 Subject: [PATCH 2/3] Sort service errors into the proper subclass (#252) --- __tests__/integration/query.test.ts | 77 ++++++++------- __tests__/integration/stream.test.ts | 134 ++++++++++++++++++++------- __tests__/unit/error.test.ts | 96 +++++++++++++++++++ __tests__/unit/query.test.ts | 65 +++---------- src/client.ts | 122 ++++++------------------ src/errors.ts | 134 ++++++++++++++++++++------- src/http-client/fetch-client.ts | 20 ++-- src/http-client/node-http2-client.ts | 42 ++++++--- src/index.ts | 2 +- 9 files changed, 423 insertions(+), 269 deletions(-) create mode 100644 __tests__/unit/error.test.ts diff --git a/__tests__/integration/query.test.ts b/__tests__/integration/query.test.ts index 57ffe28e..3ceac266 100644 --- a/__tests__/integration/query.test.ts +++ b/__tests__/integration/query.test.ts @@ -127,13 +127,13 @@ describe("query", () => { expect(req.headers[expectedHeader.key]).toBe(expectedHeader.value); }); expect(req.headers["x-driver-env"]).toEqual( - expect.stringContaining("driver=") + expect.stringContaining("driver="), ); expect(req.headers["x-driver-env"]).toEqual( - expect.stringContaining("os=") + expect.stringContaining("os="), ); expect(req.headers["x-driver-env"]).toEqual( - expect.stringContaining("runtime=") + expect.stringContaining("runtime="), ); return dummyResponse; }, @@ -150,33 +150,31 @@ describe("query", () => { const headers = { [fieldName]: fieldValue }; await myClient.query(fql`"taco".length`, headers); myClient.close(); - } + }, ); - it( - "respects typechecked: undefined", async () => { - const httpClient: HTTPClient = { - async request(req) { - const contains = new Set(Object.keys(req.headers)).has("x-typecheck"); - expect(contains).toBe(false); - return dummyResponse; - }, - close() {}, - }; - - let clientConfiguration: Partial = { - typecheck: true, - }; - let myClient = getClient(clientConfiguration, httpClient); - await myClient.query(fql`"taco".length`, { typecheck: undefined }); - myClient.close(); + it("respects typechecked: undefined", async () => { + const httpClient: HTTPClient = { + async request(req) { + const contains = new Set(Object.keys(req.headers)).has("x-typecheck"); + expect(contains).toBe(false); + return dummyResponse; + }, + close() {}, + }; - clientConfiguration = { typecheck: undefined }; - myClient = getClient(clientConfiguration, httpClient); - await myClient.query(fql`"taco".length`); - myClient.close(); - } - ); + let clientConfiguration: Partial = { + typecheck: true, + }; + let myClient = getClient(clientConfiguration, httpClient); + await myClient.query(fql`"taco".length`, { typecheck: undefined }); + myClient.close(); + + clientConfiguration = { typecheck: undefined }; + myClient = getClient(clientConfiguration, httpClient); + await myClient.query(fql`"taco".length`); + myClient.close(); + }); it("can send arguments directly", async () => { const foo = { @@ -234,10 +232,10 @@ describe("query", () => { expect.assertions(6); try { await client.query( - fql`Function.create({"name": "my_double", "body": "x => x * 2"})` + fql`Function.create({"name": "my_double", "body": "x => x * 2"})`, ); await client.query( - fql`Function.create({"name": "my_double", "body": "x => x * 2"})` + fql`Function.create({"name": "my_double", "body": "x => x * 2"})`, ); } catch (e) { if (e instanceof ServiceError) { @@ -266,7 +264,7 @@ describe("query", () => { data: "{}" as unknown as QueryRequest, }; return getDefaultHTTPClient(getDefaultHTTPClientOptions()).request( - bad_req + bad_req, ); }, close() {}, @@ -307,7 +305,7 @@ describe("query", () => { } catch (e) { if (e instanceof QueryTimeoutError) { expect(e.message).toEqual( - expect.stringContaining("aggressive deadline") + expect.stringContaining("aggressive deadline"), ); expect(e.httpStatus).toBe(440); expect(e.code).toBe("time_out"); @@ -366,7 +364,7 @@ describe("query", () => { }); it("throws a NetworkError on client timeout", async () => { - expect.assertions(2); + expect.assertions(3); const httpClient = getDefaultHTTPClient(getDefaultHTTPClientOptions()); const badHTTPClient = { @@ -384,6 +382,7 @@ describe("query", () => { try { await badClient.query(fql``); } catch (e: any) { + expect(e).toBeInstanceOf(NetworkError); if (e instanceof NetworkError) { expect(e.message).toBe("The network connection encountered a problem."); expect(e.cause).toBeDefined(); @@ -403,7 +402,7 @@ describe("query", () => { { query_timeout_ms: 60, }, - httpClient + httpClient, ); try { await badClient.query(fql`foo`); @@ -411,7 +410,7 @@ describe("query", () => { if (e instanceof ClientError) { expect(e.cause).toBeDefined(); expect(e.message).toBe( - "A client level error occurred. Fauna was not called." + "A client level error occurred. Fauna was not called.", ); } } finally { @@ -440,13 +439,13 @@ describe("query", () => { it("session is closed regardless of number of clients", async () => { const httpClient1 = NodeHTTP2Client.getClient( - getDefaultHTTPClientOptions() + getDefaultHTTPClientOptions(), ); const httpClient2 = NodeHTTP2Client.getClient( - getDefaultHTTPClientOptions() + getDefaultHTTPClientOptions(), ); const httpClient3 = NodeHTTP2Client.getClient( - getDefaultHTTPClientOptions() + getDefaultHTTPClientOptions(), ); const client1 = getClient({}, httpClient1); const client2 = getClient({}, httpClient2); @@ -500,7 +499,7 @@ describe("query can encode / decode QueryValue correctly", () => { }; // Do not use a dynamic Collection name by using `${new Module(collectionName)}`. See ENG-5003 const docCreated = await client.query( - fql`UndefinedTest.create(${toughInput})` + fql`UndefinedTest.create(${toughInput})`, ); expect(docCreated.data.should_exist).toBeUndefined(); expect(docCreated.data.nested_object.i_dont_exist).toBeUndefined(); @@ -519,7 +518,7 @@ describe("query can encode / decode QueryValue correctly", () => { if (e instanceof TypeError) { expect(e.name).toBe("TypeError"); expect(e.message).toBe( - "Passing undefined as a QueryValue is not supported" + "Passing undefined as a QueryValue is not supported", ); } } diff --git a/__tests__/integration/stream.test.ts b/__tests__/integration/stream.test.ts index 6c504137..11b6a5b2 100644 --- a/__tests__/integration/stream.test.ts +++ b/__tests__/integration/stream.test.ts @@ -1,15 +1,17 @@ import { - fql, - getDefaultHTTPClient, - StreamClient, - StreamClientConfiguration, - StreamToken, + AbortError, Client, DocumentT, - ServiceError, - TimeStub, DateStub, Document, + InvalidRequestError, + StreamClient, + StreamClientConfiguration, + StreamToken, + TimeStub, + fql, + getDefaultHTTPClient, + QueryRuntimeError, } from "../../src"; import { getClient, @@ -19,7 +21,6 @@ import { const defaultHttpClient = getDefaultHTTPClient(getDefaultHTTPClientOptions()); const { secret } = getDefaultSecretAndEndpoint(); -const dummyStreamToken = new StreamToken("dummy"); let client: Client; const STREAM_DB_NAME = "StreamTestDB"; @@ -71,7 +72,7 @@ describe("Client", () => { let stream: StreamClient | null = null; try { const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); const token = response.data; @@ -112,7 +113,7 @@ describe("StreamClient", () => { let stream: StreamClient | null = null; try { const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); const token = response.data; @@ -137,7 +138,7 @@ describe("StreamClient", () => { try { const getToken = async () => { const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); return response.data; }; @@ -162,7 +163,7 @@ describe("StreamClient", () => { let stream: StreamClient> | null = null; try { const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); const token = response.data; @@ -193,13 +194,13 @@ describe("StreamClient", () => { expect.assertions(2); const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); const token = response.data; const stream = new StreamClient>( token, - defaultStreamConfig + defaultStreamConfig, ); // create some events that will be played back @@ -228,26 +229,25 @@ describe("StreamClient", () => { await promise; }); - it("catches non 200 responses when establishing a stream", async () => { + it("catches InvalidRequestError when establishing a stream", async () => { expect.assertions(1); try { // create a stream with a bad token const stream = new StreamClient( new StreamToken("2"), - defaultStreamConfig + defaultStreamConfig, ); for await (const _ of stream) { /* do nothing */ } } catch (e) { - // TODO: be more specific about the error and split into multiple tests - expect(e).toBeInstanceOf(ServiceError); + expect(e).toBeInstanceOf(InvalidRequestError); } }); - it("handles non 200 responses via callback when establishing a stream", async () => { + it("handles InvalidRequestError via callback when establishing a stream", async () => { expect.assertions(1); // create a stream with a bad token @@ -261,22 +261,21 @@ describe("StreamClient", () => { stream.start( function onEvent(_) {}, function onError(e) { - // TODO: be more specific about the error and split into multiple tests - expect(e).toBeInstanceOf(ServiceError); + expect(e).toBeInstanceOf(InvalidRequestError); resolve(); - } + }, ); await promise; }); - it("catches a ServiceError if an error event is received", async () => { - expect.assertions(1); + it("catches an AbortError if abort is called when processing an event", async () => { + expect.assertions(3); let stream: StreamClient> | null = null; try { const response = await client.query( - fql`StreamTest.all().map((doc) => abort("oops")).toStream()` + fql`StreamTest.all().map((doc) => abort("oops")).toStream()`, ); const token = response.data; @@ -288,25 +287,87 @@ describe("StreamClient", () => { for await (const _ of stream) { /* do nothing */ } - } catch (e) { - // TODO: be more specific about the error and split into multiple tests - expect(e).toBeInstanceOf(ServiceError); + } catch (e: any) { + expect(e).toBeInstanceOf(AbortError); + expect(e.httpStatus).toBeUndefined(); + expect(e.abort).toBe("oops"); + } finally { + stream?.close(); + } + }); + + it("catches a QueryRuntimeError when processing an event", async () => { + expect.assertions(2); + + let stream: StreamClient> | null = null; + try { + const response = await client.query( + fql`StreamTest.all().map((doc) => notARealFn(doc)).toStream()`, + ); + const token = response.data; + + stream = new StreamClient(token, defaultStreamConfig); + + // create some events that will be played back + await client.query(fql`StreamTest.create({ value: 0 })`); + + for await (const _ of stream) { + /* do nothing */ + } + } catch (e: any) { + expect(e).toBeInstanceOf(QueryRuntimeError); + expect(e.httpStatus).toBeUndefined(); } finally { stream?.close(); } }); - it("handles a ServiceError via callback if an error event is received", async () => { + it("handles an AbortError via callback if abort is called when processing an event", async () => { + expect.assertions(2); + + const response = await client.query( + fql`StreamTest.all().map((doc) => abort("oops")).toStream()`, + ); + const token = response.data; + + const stream = new StreamClient>( + token, + defaultStreamConfig, + ); + + // create some events that will be played back + await client.query(fql`StreamTest.create({ value: 0 })`); + + let resolve: () => void; + const promise = new Promise((res) => { + resolve = () => res(null); + }); + + stream.start( + function onEvent(_) {}, + function onError(e) { + if (e instanceof AbortError) { + expect(e.httpStatus).toBeUndefined(); + expect(e.abort).toBe("oops"); + } + resolve(); + }, + ); + + await promise; + }); + + it("handles a QueryRuntimeError via callback when processing an event", async () => { expect.assertions(1); const response = await client.query( - fql`StreamTest.all().map((doc) => abort("oops")).toStream()` + fql`StreamTest.all().map((doc) => notARealFn(doc)).toStream()`, ); const token = response.data; const stream = new StreamClient>( token, - defaultStreamConfig + defaultStreamConfig, ); // create some events that will be played back @@ -320,10 +381,11 @@ describe("StreamClient", () => { stream.start( function onEvent(_) {}, function onError(e) { - // TODO: be more specific about the error and split into multiple tests - expect(e).toBeInstanceOf(ServiceError); + if (e instanceof QueryRuntimeError) { + expect(e.httpStatus).toBeUndefined(); + } resolve(); - } + }, ); await promise; @@ -341,7 +403,7 @@ describe("StreamClient", () => { date: Date.today(), doc: doc, bigInt: 922337036854775808, - }).toStream()` + }).toStream()`, ); const token = response.data; @@ -392,7 +454,7 @@ describe("StreamClient", () => { await client.query(fql`StreamTest.all().forEach(.delete())`); const response = await client.query( - fql`StreamTest.all().toStream()` + fql`StreamTest.all().toStream()`, ); const token = response.data; diff --git a/__tests__/unit/error.test.ts b/__tests__/unit/error.test.ts new file mode 100644 index 00000000..370b565b --- /dev/null +++ b/__tests__/unit/error.test.ts @@ -0,0 +1,96 @@ +import { + AbortError, + AuthenticationError, + AuthorizationError, + ConstraintFailureError, + ContendedTransactionError, + InvalidRequestError, + QueryCheckError, + QueryFailure, + QueryRuntimeError, + QueryTimeoutError, + ServiceInternalError, + ThrottlingError, +} from "../../src"; +import { getServiceError } from "../../src/errors"; + +describe("query", () => { + it.each` + httpStatus | code | errorClass + ${400} | ${"invalid_query"} | ${QueryCheckError} + ${400} | ${"unbound_variable"} | ${QueryRuntimeError} + ${400} | ${"index_out_of_bounds"} | ${QueryRuntimeError} + ${400} | ${"type_mismatch"} | ${QueryRuntimeError} + ${400} | ${"invalid_argument"} | ${QueryRuntimeError} + ${400} | ${"invalid_bounds"} | ${QueryRuntimeError} + ${400} | ${"invalid_regex"} | ${QueryRuntimeError} + ${400} | ${"invalid_schema"} | ${QueryRuntimeError} + ${400} | ${"invalid_document_id"} | ${QueryRuntimeError} + ${400} | ${"document_id_exists"} | ${QueryRuntimeError} + ${400} | ${"document_not_found"} | ${QueryRuntimeError} + ${400} | ${"document_deleted"} | ${QueryRuntimeError} + ${400} | ${"invalid_function_invocation"} | ${QueryRuntimeError} + ${400} | ${"invalid_index_invocation"} | ${QueryRuntimeError} + ${400} | ${"null_value"} | ${QueryRuntimeError} + ${400} | ${"invalid_null_access"} | ${QueryRuntimeError} + ${400} | ${"invalid_cursor"} | ${QueryRuntimeError} + ${400} | ${"permission_denied"} | ${QueryRuntimeError} + ${400} | ${"invalid_effect"} | ${QueryRuntimeError} + ${400} | ${"invalid_write"} | ${QueryRuntimeError} + ${400} | ${"internal_failure"} | ${QueryRuntimeError} + ${400} | ${"divide_by_zero"} | ${QueryRuntimeError} + ${400} | ${"invalid_id"} | ${QueryRuntimeError} + ${400} | ${"invalid_secret"} | ${QueryRuntimeError} + ${400} | ${"invalid_time"} | ${QueryRuntimeError} + ${400} | ${"invalid_unit"} | ${QueryRuntimeError} + ${400} | ${"invalid_date"} | ${QueryRuntimeError} + ${400} | ${"limit_exceeded"} | ${QueryRuntimeError} + ${400} | ${"stack_overflow"} | ${QueryRuntimeError} + ${400} | ${"invalid_computed_field_access"} | ${QueryRuntimeError} + ${400} | ${"disabled_feature"} | ${QueryRuntimeError} + ${400} | ${"invalid_receiver"} | ${QueryRuntimeError} + ${400} | ${"invalid_timestamp_field_access"} | ${QueryRuntimeError} + ${400} | ${"invalid_request"} | ${InvalidRequestError} + ${400} | ${"abort"} | ${AbortError} + ${400} | ${"constraint_failure"} | ${ConstraintFailureError} + ${401} | ${"unauthorized"} | ${AuthenticationError} + ${403} | ${"forbidden"} | ${AuthorizationError} + ${409} | ${"contended_transaction"} | ${ContendedTransactionError} + ${429} | ${"throttle"} | ${ThrottlingError} + ${440} | ${"time_out"} | ${QueryTimeoutError} + ${503} | ${"time_out"} | ${QueryTimeoutError} + ${500} | ${"internal_error"} | ${ServiceInternalError} + ${400} | ${"some unhandled code"} | ${QueryRuntimeError} + ${401} | ${"some unhandled code"} | ${QueryRuntimeError} + ${403} | ${"some unhandled code"} | ${QueryRuntimeError} + ${409} | ${"some unhandled code"} | ${QueryRuntimeError} + ${429} | ${"some unhandled code"} | ${QueryRuntimeError} + ${440} | ${"some unhandled code"} | ${QueryRuntimeError} + ${500} | ${"some unhandled code"} | ${QueryRuntimeError} + ${503} | ${"some unhandled code"} | ${QueryRuntimeError} + ${999} | ${"some unhandled code"} | ${QueryRuntimeError} + ${undefined} | ${"some unhandled code"} | ${QueryRuntimeError} + `( + "QueryFailures with status '$httpStatus' and code '$code' are correctly mapped to $errorClass", + ({ httpStatus, code, errorClass }) => { + const failure: QueryFailure = { + error: { + message: "error message", + code, + abort: "oops", + constraint_failures: [{ message: "oops" }], + }, + }; + + const error = getServiceError(failure, httpStatus); + expect(error).toBeInstanceOf(errorClass); + expect(error.httpStatus).toEqual(httpStatus); + expect(error.code).toEqual(code); + + const error_no_status = getServiceError(failure); + expect(error_no_status).toBeInstanceOf(errorClass); + expect(error_no_status.httpStatus).toBeUndefined(); + expect(error_no_status.code).toEqual(code); + }, + ); +}); diff --git a/__tests__/unit/query.test.ts b/__tests__/unit/query.test.ts index 5bd728e9..7d80bb74 100644 --- a/__tests__/unit/query.test.ts +++ b/__tests__/unit/query.test.ts @@ -8,7 +8,6 @@ import { QueryTimeoutError, ServiceError, ServiceInternalError, - ServiceTimeoutError, ThrottlingError, } from "../../src"; import { getClient, getDefaultHTTPClientOptions } from "../client"; @@ -22,7 +21,7 @@ const client = getClient( query_timeout_ms: 60, }, // use the FetchClient implementation, so we can mock requests - new FetchClient(getDefaultHTTPClientOptions()) + new FetchClient(getDefaultHTTPClientOptions()), ); describe("query", () => { @@ -30,15 +29,15 @@ describe("query", () => { fetchMock.resetMocks(); }); - // do not treat these codes as canonical. Refer to documentation. These are simply for logical testing. + // Error handling uses the code field to determine the error type. These codes must match the actual code expected from the API. it.each` httpStatus | expectedErrorType | expectedErrorFields - ${403} | ${AuthorizationError} | ${{ code: "no_permission", message: "nope" }} - ${440} | ${QueryTimeoutError} | ${{ code: "query_timeout", message: "too slow - increase your timeout" }} + ${403} | ${AuthorizationError} | ${{ code: "forbidden", message: "nope" }} + ${440} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow - increase your timeout" }} ${999} | ${ServiceError} | ${{ code: "error_not_yet_subclassed_in_client", message: "who knows!!!" }} ${429} | ${ThrottlingError} | ${{ code: "throttle", message: "too much" }} ${500} | ${ServiceInternalError} | ${{ code: "internal_error", message: "unexpected error" }} - ${503} | ${ServiceTimeoutError} | ${{ code: "service_timeout", message: "too slow on our side" }} + ${503} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} `( "throws an $expectedErrorType on a $httpStatus", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { @@ -56,56 +55,18 @@ describe("query", () => { expect(e.code).toEqual(expectedErrorFields.code); } } - } + }, ); - // do not treat these codes as canonical. Refer to documentation. These are simply for logical testing. + // Error handling uses the code field to determine the error type. These codes must match the actual code expected from the API. it.each` httpStatus | expectedErrorType | expectedErrorFields - ${403} | ${AuthorizationError} | ${{ code: "no_permission", message: "nope", summary: "the summary" }} - ${440} | ${QueryTimeoutError} | ${{ code: "query_timeout", message: "too slow - increase your timeout", summary: "the summary" }} - ${999} | ${ServiceError} | ${{ code: "error_not_yet_subclassed_in_client", message: "who knows!!!", summary: "the summary" }} - ${429} | ${ThrottlingError} | ${{ code: "throttle", message: "too much", summary: "the summary" }} - ${500} | ${ServiceInternalError} | ${{ code: "internal_error", message: "unexpected error", summary: "the summary" }} - ${503} | ${ServiceTimeoutError} | ${{ code: "service_timeout", message: "too slow on our side", summary: "the summary" }} - `( - "Includes a summary when present in error field", - async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { - expect.assertions(5); - fetchMock.mockResponse( - JSON.stringify({ - error: { - code: expectedErrorFields.code, - message: expectedErrorFields.message, - }, - summary: expectedErrorFields.summary, - }), - { - status: httpStatus, - } - ); - try { - await client.query(fql`'foo'.length`); - } catch (e) { - if (e instanceof ServiceError) { - expect(e).toBeInstanceOf(expectedErrorType); - expect(e.message).toEqual(expectedErrorFields.message); - expect(e.httpStatus).toEqual(httpStatus); - expect(e.code).toEqual(expectedErrorFields.code); - expect(e.queryInfo?.summary).toEqual(expectedErrorFields.summary); - } - } - } - ); - - it.each` - httpStatus | expectedErrorType | expectedErrorFields - ${403} | ${AuthorizationError} | ${{ code: "no_permission", message: "nope" }} - ${440} | ${QueryTimeoutError} | ${{ code: "query_timeout", message: "too slow - increase your timeout" }} + ${403} | ${AuthorizationError} | ${{ code: "forbidden", message: "nope" }} + ${440} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow - increase your timeout" }} ${999} | ${ServiceError} | ${{ code: "error_not_yet_subclassed_in_client", message: "who knows!!!" }} ${429} | ${ThrottlingError} | ${{ code: "throttle", message: "too much" }} ${500} | ${ServiceInternalError} | ${{ code: "internal_error", message: "unexpected error" }} - ${503} | ${ServiceTimeoutError} | ${{ code: "service_timeout", message: "too slow on our side" }} + ${503} | ${QueryTimeoutError} | ${{ code: "time_out", message: "too slow on our side" }} `( "Includes a summary when not present in error field but present at top-level", async ({ httpStatus, expectedErrorType, expectedErrorFields }) => { @@ -117,7 +78,7 @@ describe("query", () => { }), { status: httpStatus, - } + }, ); try { @@ -131,7 +92,7 @@ describe("query", () => { expect(e.queryInfo?.summary).toEqual("the summary"); } } - } + }, ); it("retries throttling errors and then succeeds", async () => { @@ -146,7 +107,7 @@ describe("query", () => { [ JSON.stringify({ data: 3, summary: "the summary", stats: {} }), { status: 200 }, - ] + ], ); const actual = await client.query(fql`'foo'.length`); expect(actual.data).toEqual(3); diff --git a/src/client.ts b/src/client.ts index b60a140e..986e8e49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,23 +4,14 @@ import { endpoints, } from "./client-configuration"; import { - AuthenticationError, - AuthorizationError, ClientClosedError, ClientError, + FaunaError, NetworkError, ProtocolError, - AbortError, - QueryCheckError, - QueryRuntimeError, - QueryTimeoutError, ServiceError, - ServiceInternalError, - ServiceTimeoutError, ThrottlingError, - ContendedTransactionError, - InvalidRequestError, - FaunaError, + getServiceError, } from "./errors"; import { HTTPStreamClient, @@ -41,7 +32,6 @@ import { StreamEvent, StreamEventData, StreamEventStatus, - type QueryFailure, type QueryOptions, type QuerySuccess, type QueryValue, @@ -114,7 +104,7 @@ export class Client { */ constructor( clientConfiguration?: ClientConfiguration, - httpClient?: HTTPClient + httpClient?: HTTPClient, ) { this.#clientConfiguration = { ...DEFAULT_CLIENT_CONFIG, @@ -170,7 +160,7 @@ export class Client { close() { if (this.#isClosed) { throw new ClientClosedError( - "Your client is closed. You cannot close it again." + "Your client is closed. You cannot close it again.", ); } this.#httpClient.close(); @@ -221,7 +211,7 @@ export class Client { */ paginate( iterable: Page | EmbeddedSet | Query, - options?: QueryOptions + options?: QueryOptions, ): SetIterator { if (iterable instanceof Query) { return SetIterator.fromQuery(this, iterable, options); @@ -243,10 +233,7 @@ export class Client { * * @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. ServiceError child types are - * {@link AuthenticaionError}, {@link AuthorizationError}, {@link QueryCheckError} - * {@link QueryRuntimeError}, {@link QueryTimeoutError}, {@link ServiceInternalError} - * {@link ServiceTimeoutError}, {@link ThrottlingError}. + * 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. @@ -258,11 +245,11 @@ export class Client { */ async query( query: Query, - options?: QueryOptions + options?: QueryOptions, ): Promise> { if (this.#isClosed) { throw new ClientClosedError( - "Your client is closed. No further requests can be issued." + "Your client is closed. No further requests can be issued.", ); } @@ -330,14 +317,13 @@ export class Client { * ); * ``` */ - // TODO: implement options stream( tokenOrQuery: StreamToken | Query, - options?: Partial + options?: Partial, ): StreamClient { if (this.#isClosed) { throw new ClientClosedError( - "Your client is closed. No further requests can be issued." + "Your client is closed. No further requests can be issued.", ); } @@ -364,7 +350,7 @@ export class Client { async #queryWithRetries( queryInterpolation: string | QueryInterpolation, options?: QueryOptions, - attempt = 0 + attempt = 0, ): Promise> { const maxBackoff = this.clientConfiguration.max_backoff ?? DEFAULT_CLIENT_CONFIG.max_backoff; @@ -404,7 +390,7 @@ export class Client { if (isQueryFailure(e.body)) { const failure = e.body; const status = e.status; - return this.#getServiceError(failure, status); + return getServiceError(failure, status); } // we got a different error from the protocol layer @@ -419,7 +405,7 @@ export class Client { "A client level error occurred. Fauna was not called.", { cause: e, - } + }, ); } @@ -440,7 +426,7 @@ export class Client { 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." + constructor.", ); } return maybeSecret; @@ -455,7 +441,7 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ partialClientConfig.endpoint === undefined ) { throw new TypeError( - `ClientConfiguration option endpoint must be defined.` + `ClientConfiguration option endpoint must be defined.`, ); } @@ -475,48 +461,10 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ return partialClientConfig?.endpoint ?? env_endpoint ?? endpoints.default; } - #getServiceError(failure: QueryFailure, httpStatus: number): ServiceError { - switch (httpStatus) { - case 400: - if (QUERY_CHECK_FAILURE_CODES.includes(failure.error.code)) { - return new QueryCheckError(failure, httpStatus); - } - if (failure.error.code === "invalid_request") { - return new InvalidRequestError(failure, httpStatus); - } - if ( - failure.error.code === "abort" && - failure.error.abort !== undefined - ) { - return new AbortError( - failure as QueryFailure & { error: { abort: QueryValue } }, - httpStatus - ); - } - return new QueryRuntimeError(failure, httpStatus); - case 401: - return new AuthenticationError(failure, httpStatus); - case 403: - return new AuthorizationError(failure, httpStatus); - case 409: - return new ContendedTransactionError(failure, httpStatus); - case 429: - return new ThrottlingError(failure, httpStatus); - case 440: - return new QueryTimeoutError(failure, httpStatus); - case 500: - return new ServiceInternalError(failure, httpStatus); - case 503: - return new ServiceTimeoutError(failure, httpStatus); - default: - return new ServiceError(failure, httpStatus); - } - } - async #query( queryInterpolation: string | QueryInterpolation, options?: QueryOptions, - attempt = 0 + attempt = 0, ): Promise> { try { const requestConfig = { @@ -603,12 +551,12 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ #setHeaders( fromObject: QueryOptions, - headerObject: Record + headerObject: Record, ): void { const setHeader = ( header: string, value: V | undefined, - transform: (v: V) => string | number = (v) => String(v) + transform: (v: V) => string | number = (v) => String(v), ) => { if (value !== undefined) { headerObject[header] = transform(value); @@ -624,7 +572,7 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ setHeader("x-query-tags", fromObject.query_tags, (tags) => Object.entries(tags) .map((tag) => tag.join("=")) - .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); @@ -648,7 +596,7 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ required_options.forEach((option) => { if (config[option] === undefined) { throw new TypeError( - `ClientConfiguration option '${option}' must be defined.` + `ClientConfiguration option '${option}' must be defined.`, ); } }); @@ -659,7 +607,7 @@ in an environmental variable named FAUNA_SECRET or pass it to the Client\ if (config.client_timeout_buffer_ms <= 0) { throw new RangeError( - `'client_timeout_buffer_ms' must be greater than zero.` + `'client_timeout_buffer_ms' must be greater than zero.`, ); } @@ -707,10 +655,9 @@ export class StreamClient { * const streamClient = client.stream(streamToken); * ``` */ - // TODO: implement stream-specific options constructor( token: StreamToken | (() => Promise), - clientConfiguration: StreamClientConfiguration + clientConfiguration: StreamClientConfiguration, ) { if (token instanceof StreamToken) { this.#query = () => Promise.resolve(token); @@ -732,16 +679,16 @@ export class StreamClient { */ start( onEvent: (event: StreamEventData | StreamEventStatus) => void, - onError?: (error: Error) => 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.` + `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.` + `Expected a function as the 'onError' argument, but received ${typeof onError}. Please provide a valid function.`, ); } const run = async () => { @@ -770,7 +717,7 @@ export class StreamClient { if (!(maybeStreamToken instanceof StreamToken)) { throw new ClientError( `Error requesting a stream token. Expected a StreamToken as the query result, but received ${typeof maybeStreamToken}. Your query must return the result of '.toStream' or '.changesOn')\n` + - `Query result: ${JSON.stringify(maybeStreamToken, null)}` + `Query result: ${JSON.stringify(maybeStreamToken, null)}`, ); } return maybeStreamToken; @@ -782,7 +729,7 @@ export class StreamClient { const backoffMs = Math.min( Math.random() * 2 ** this.#connectionAttempts, - this.#clientConfiguration.max_backoff + this.#clientConfiguration.max_backoff, ) * 1_000; try { @@ -818,7 +765,7 @@ export class StreamClient { } async *#startStream( - start_ts?: number + start_ts?: number, ): AsyncGenerator | StreamEventStatus> { // Safety: This method must only be called after a stream token has been acquired const streamToken = this.#streamToken as StreamToken; @@ -844,8 +791,7 @@ export class StreamClient { if (deserializedEvent.type === "error") { // Errors sent from Fauna are assumed fatal this.close(); - // TODO: replace with appropriate class from existing error heirarchy - throw new ServiceError(deserializedEvent, 400); + throw getServiceError(deserializedEvent); } this.#last_ts = deserializedEvent.txn_ts; @@ -879,7 +825,7 @@ export class StreamClient { required_options.forEach((option) => { if (config[option] === undefined) { throw new TypeError( - `ClientConfiguration option '${option}' must be defined.` + `ClientConfiguration option '${option}' must be defined.`, ); } }); @@ -896,14 +842,6 @@ export class StreamClient { // Private types and constants for internal logic. -const QUERY_CHECK_FAILURE_CODES = [ - "invalid_function_definition", - "invalid_identifier", - "invalid_query", - "invalid_syntax", - "invalid_type", -]; - function wait(ms: number) { return new Promise((r) => setTimeout(r, ms)); } diff --git a/src/errors.ts b/src/errors.ts index c6d72fef..77a7820b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -21,7 +21,7 @@ export class ServiceError extends FaunaError { /** * The HTTP Status Code of the error. */ - readonly httpStatus: number; + 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 @@ -38,7 +38,7 @@ export class ServiceError extends FaunaError { */ readonly constraint_failures?: Array; - constructor(failure: QueryFailure, httpStatus: number) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure.error.message); // Maintains proper stack trace for where our error was thrown (only available on V8) @@ -71,7 +71,7 @@ export class ServiceError extends FaunaError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class QueryRuntimeError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 400) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryRuntimeError); @@ -89,7 +89,7 @@ export class QueryRuntimeError extends ServiceError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class QueryCheckError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 400) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryCheckError); @@ -105,7 +105,7 @@ export class QueryCheckError extends ServiceError { * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} */ export class InvalidRequestError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 400) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); @@ -114,6 +114,32 @@ export class InvalidRequestError extends ServiceError { } } +/** + * A runtime error due to failing schema constraints. + * + * @see {@link https://fqlx-beta--fauna-docs.netlify.app/fqlx/beta/reference/language/errors#runtime-errors} + */ +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. * @@ -129,7 +155,7 @@ export class AbortError extends ServiceError { constructor( failure: QueryFailure & { error: { abort: QueryValue } }, - httpStatus: 400 + httpStatus?: number, ) { super(failure, httpStatus); if (Error.captureStackTrace) { @@ -145,7 +171,7 @@ export class AbortError extends ServiceError { * used. */ export class AuthenticationError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 401) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthenticationError); @@ -159,7 +185,7 @@ export class AuthenticationError extends ServiceError { * permission to perform the requested action. */ export class AuthorizationError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 403) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, AuthorizationError); @@ -172,7 +198,7 @@ export class AuthorizationError extends ServiceError { * An error due to a contended transaction. */ export class ContendedTransactionError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 409) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, InvalidRequestError); @@ -186,7 +212,7 @@ export class ContendedTransactionError extends ServiceError { * and thus the request could not be served. */ export class ThrottlingError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 429) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ThrottlingError); @@ -196,11 +222,16 @@ export class ThrottlingError extends ServiceError { } /** - * A failure due to the timeout being exceeded, but the timeout - * was set lower than the query's expected processing time. - * This response is distinguished from a ServiceTimeoutException - * in that a QueryTimeoutError shows Fauna behaving in an expected - * manner. + * 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 { /** @@ -208,7 +239,7 @@ export class QueryTimeoutError extends ServiceError { */ readonly stats?: { [key: string]: number }; - constructor(failure: QueryFailure, httpStatus: 440) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryTimeoutError); @@ -222,7 +253,7 @@ export class QueryTimeoutError extends ServiceError { * ServiceInternalError indicates Fauna failed unexpectedly. */ export class ServiceInternalError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 500) { + constructor(failure: QueryFailure, httpStatus?: number) { super(failure, httpStatus); if (Error.captureStackTrace) { Error.captureStackTrace(this, ServiceInternalError); @@ -231,20 +262,6 @@ export class ServiceInternalError extends ServiceError { } } -/** - * ServiceTimeoutError indicates Fauna was not available to servce - * the request before the timeout was reached. - */ -export class ServiceTimeoutError extends ServiceError { - constructor(failure: QueryFailure, httpStatus: 503) { - super(failure, httpStatus); - if (Error.captureStackTrace) { - Error.captureStackTrace(this, ServiceTimeoutError); - } - this.name = "ServiceTimeoutError"; - } -} - /** * An error representing a failure internal to the client, itself. * This indicates Fauna was never called - the client failed internally @@ -312,3 +329,58 @@ export class ProtocolError extends FaunaError { 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 "throttle": + 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); +}; diff --git a/src/http-client/fetch-client.ts b/src/http-client/fetch-client.ts index 20db8e09..619807cf 100644 --- a/src/http-client/fetch-client.ts +++ b/src/http-client/fetch-client.ts @@ -1,7 +1,8 @@ /** following reference needed to include types for experimental fetch API in Node */ /// -import { NetworkError, ServiceError } from "../errors"; +import { getServiceError, NetworkError } from "../errors"; +import { QueryFailure } from "../wire-protocol"; import { HTTPClient, HTTPClientOptions, @@ -94,13 +95,13 @@ export class FetchClient implements HTTPClient, HTTPStreamClient { "The network connection encountered a problem.", { cause: error, - } + }, ); }); const status = response.status; if (!(status >= 200 && status < 400)) { - const body = await response.json(); - throw new ServiceError(body, status); + const failure: QueryFailure = await response.json(); + throw getServiceError(failure, status); } const body = response.body; @@ -109,8 +110,15 @@ export class FetchClient implements HTTPClient, HTTPStreamClient { } const reader = body.getReader(); - for await (const line of readLines(reader)) { - yield line; + 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 }, + ); } } diff --git a/src/http-client/node-http2-client.ts b/src/http-client/node-http2-client.ts index b9e38872..9f69bedf 100644 --- a/src/http-client/node-http2-client.ts +++ b/src/http-client/node-http2-client.ts @@ -13,7 +13,8 @@ import { HTTPStreamRequest, StreamAdapter, } from "./http-client"; -import { ServiceError, NetworkError } from "../errors"; +import { NetworkError, getServiceError } from "../errors"; +import { QueryFailure } from "../wire-protocol"; // alias http2 types type ClientHttp2Session = any; @@ -59,7 +60,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { if (!NodeHTTP2Client.#clients.has(clientKey)) { NodeHTTP2Client.#clients.set( clientKey, - new NodeHTTP2Client(httpClientOptions) + new NodeHTTP2Client(httpClientOptions), ); } // we know that we have a client here @@ -98,7 +99,7 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { "The network connection encountered a problem.", { cause: error, - } + }, ); } memoizedError = error; @@ -169,10 +170,10 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { return new Promise((resolvePromise, rejectPromise) => { let req: ClientHttp2Stream; const onResponse = ( - http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader + http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader, ) => { const status = Number( - http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS] + http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS], ); let responseData = ""; @@ -204,7 +205,12 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { .request(httpRequestHeaders) .setEncoding("utf8") .on("error", (error: any) => { - rejectPromise(error); + rejectPromise( + new NetworkError( + "The network connection encountered a problem while streaming events.", + { cause: error }, + ), + ); }) .on("response", onResponse); @@ -217,7 +223,12 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { req.end(); } catch (error) { - rejectPromise(error); + rejectPromise( + new NetworkError( + "The network connection encountered a problem while streaming events.", + { cause: error }, + ), + ); } }); } @@ -241,10 +252,10 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { let req: ClientHttp2Stream; const onResponse = ( - http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader + http2ResponseHeaders: IncomingHttpHeaders & IncomingHttpStatusHeader, ) => { const status = Number( - http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS] + http2ResponseHeaders[http2.constants.HTTP2_HEADER_STATUS], ); if (!(status >= 200 && status < 400)) { // Get the error body and then throw an error @@ -257,10 +268,17 @@ export class NodeHTTP2Client implements HTTPClient, HTTPStreamClient { }); // Once the response is finished, resolve the promise - // TODO: The Client contains the information for how to parse an error - // into the appropriate class, so lift this logic out of the HTTPClient. req.on("end", () => { - rejectChunk(new ServiceError(JSON.parse(responseData), status)); + 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 = ""; diff --git a/src/index.ts b/src/index.ts index 808a0e4e..95303bf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { AuthorizationError, ClientError, ClientClosedError, + ConstraintFailureError, ContendedTransactionError, FaunaError, InvalidRequestError, @@ -21,7 +22,6 @@ export { QueryTimeoutError, ServiceError, ServiceInternalError, - ServiceTimeoutError, ThrottlingError, } from "./errors"; export { type Query, fql } from "./query-builder"; From 90499c5f1ca0f4be14b5fa7b40685786256ebe3a Mon Sep 17 00:00:00 2001 From: Paul Paterson Date: Mon, 6 May 2024 15:07:34 -0400 Subject: [PATCH 3/3] Add ttl as public field for Document (#254) --- __tests__/integration/doc.test.ts | 16 ++++++++++++++-- __tests__/unit/doc.test.ts | 18 ++++++++++++++++++ src/values/doc.ts | 7 ++++--- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/doc.test.ts b/__tests__/integration/doc.test.ts index 9dae4dce..b6df40d1 100644 --- a/__tests__/integration/doc.test.ts +++ b/__tests__/integration/doc.test.ts @@ -138,11 +138,23 @@ describe("querying for doc types", () => { expect(result.data.module).toBeInstanceOf(Module); expect(result.data.document).toBeInstanceOf(Document); expect(result.data.document.documentReference).toBeInstanceOf( - DocumentReference + DocumentReference, ); expect(result.data.document.namedDocumentReference).toBeInstanceOf( - NamedDocumentReference + NamedDocumentReference, ); expect(result.data.namedDocument).toBeInstanceOf(NamedDocument); }); + + it("can set and read ttl", async () => { + const queryBuilder = fql`${testDoc}`; + const result = await client.query(queryBuilder); + + expect(result.data.ttl).toBeUndefined(); + + const queryBuilderUpdate = fql`${testDoc}.update({ ttl: Time.now().add(1, "day") })`; + const resultUpdate = await client.query(queryBuilderUpdate); + + expect(resultUpdate.data.ttl).toBeInstanceOf(TimeStub); + }); }); diff --git a/__tests__/unit/doc.test.ts b/__tests__/unit/doc.test.ts index 0684b9c7..9593b648 100644 --- a/__tests__/unit/doc.test.ts +++ b/__tests__/unit/doc.test.ts @@ -38,6 +38,24 @@ describe("Document", () => { expect(doc.ts.isoString).toBe("2023-10-16T00:00:00Z"); }); + it("can access ttl", () => { + const doc = new Document({ + coll: new Module("User"), + id: "1234", + ts: TimeStub.from("2023-03-09T00:00:00Z"), + }); + + const doc_w_ttl = new Document({ + coll: new Module("User"), + id: "1234", + ts: TimeStub.from("2023-03-09T00:00:00Z"), + ttl: TimeStub.from("2023-03-10T00:00:00Z"), + }); + + expect(doc.ttl).toBeUndefined(); + expect(doc_w_ttl.ttl).toBeInstanceOf(TimeStub); + }); + it("can access user data", () => { const doc = new Document({ coll: new Module("User"), diff --git a/src/values/doc.ts b/src/values/doc.ts index 1fd93221..0301d7ea 100644 --- a/src/values/doc.ts +++ b/src/values/doc.ts @@ -60,6 +60,7 @@ export class DocumentReference { */ export class Document extends DocumentReference { readonly ts: TimeStub; + readonly ttl?: TimeStub; constructor(obj: { coll: Module | string; @@ -73,8 +74,8 @@ export class Document extends DocumentReference { Object.assign(this, rest); } - toObject(): { coll: Module; id: string; ts: TimeStub } { - return { ...this } as { coll: Module; id: string; ts: TimeStub }; + toObject(): { coll: Module; id: string; ts: TimeStub; ttl?: TimeStub } { + return { ...this }; } } @@ -146,7 +147,7 @@ export class NamedDocumentReference { * ``` */ export class NamedDocument< - T extends QueryValueObject = Record + T extends QueryValueObject = Record, > extends NamedDocumentReference { readonly ts: TimeStub; readonly data: T;