From 9a81c8eaff514e58995510c53da087711ed73826 Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Mon, 16 Feb 2026 10:01:32 +0000 Subject: [PATCH 1/4] add cause property to graphql error logs --- src/GraphQL/Client/Query.js | 3 +++ src/GraphQL/Client/Query.purs | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 src/GraphQL/Client/Query.js diff --git a/src/GraphQL/Client/Query.js b/src/GraphQL/Client/Query.js new file mode 100644 index 0000000..f5960be --- /dev/null +++ b/src/GraphQL/Client/Query.js @@ -0,0 +1,3 @@ +export function cause(err) { + return String(err.cause || ""); +} diff --git a/src/GraphQL/Client/Query.purs b/src/GraphQL/Client/Query.purs index a320056..16b24d1 100644 --- a/src/GraphQL/Client/Query.purs +++ b/src/GraphQL/Client/Query.purs @@ -43,6 +43,8 @@ import GraphQL.Client.Types (class GqlQuery, class QueryClient, Client(..), GqlR import GraphQL.Client.Variables (class VarsTypeChecked, getVarsJson, getVarsTypeNames) import Type.Proxy (Proxy(..)) +foreign import cause :: Error -> String + -- | Run a graphQL query with a custom decoder and custom options queryOptsWithDecoder :: forall client directives schema query returns queryOpts mutationOpts sr @@ -261,6 +263,8 @@ addErrorInfo schema queryName q = <> show queryName <> ".\nerror: " <> message err + <> ".\ncause: " + <> cause err <> ".\nquery: " <> queryName <> " " From dcca416c881a3c7ba6a244a22ee625df03b0fd73 Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Tue, 10 Mar 2026 20:54:31 +0800 Subject: [PATCH 2/4] add more info about errors in Query.js --- src/GraphQL/Client/Query.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/GraphQL/Client/Query.js b/src/GraphQL/Client/Query.js index f5960be..91cc075 100644 --- a/src/GraphQL/Client/Query.js +++ b/src/GraphQL/Client/Query.js @@ -1,3 +1,17 @@ -export function cause(err) { - return String(err.cause || ""); -} +const formatError = (err) => { + if (!err) return ""; + const props = ["message", "code", "syscall", "hostname", "address", "port"] + .filter((k) => err[k]) + .map((k) => `${k}: ${err[k]}`); + if (err.cause) props.push(`cause: ${formatError(err.cause)}`); + return props.join(", "); +}; + +export const cause = (err) => { + const c = err.cause; + if (!c) return ""; + if (c instanceof AggregateError) { + return [c.message, ...Array.from(c.errors, (e, i) => `[${i}] ${formatError(e)}`)].join("\n"); + } + return formatError(c); +}; From 9e2bf809ce5854f3e264dd45cc6ea24eb846a74c Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Tue, 10 Mar 2026 20:56:01 +0800 Subject: [PATCH 3/4] Update src/GraphQL/Client/Query.js Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/GraphQL/Client/Query.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/GraphQL/Client/Query.js b/src/GraphQL/Client/Query.js index 91cc075..ae1329d 100644 --- a/src/GraphQL/Client/Query.js +++ b/src/GraphQL/Client/Query.js @@ -11,7 +11,10 @@ export const cause = (err) => { const c = err.cause; if (!c) return ""; if (c instanceof AggregateError) { - return [c.message, ...Array.from(c.errors, (e, i) => `[${i}] ${formatError(e)}`)].join("\n"); + return [ + c.message, + ...Array.from(c.errors, (e, i) => `[${i}] ${formatError(e)}`), + ].join("\n"); } return formatError(c); }; From 446383bf224951238374860e606f84d09998b155 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Thu, 4 Jun 2026 18:11:34 +0800 Subject: [PATCH 4/4] Add optional operationTypeHeader to Apollo client options When set, HTTP requests carry the GraphQL operation type (query/mutation) as the value of the given header, so a load balancer can route queries to a read replica. Opt-in via new createClient'/createSubscriptionClient'; existing constructors are unchanged. --- src/GraphQL/Client/BaseClients/Apollo.js | 44 ++++++++++++++----- src/GraphQL/Client/BaseClients/Apollo.purs | 51 +++++++++++++++++++++- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/GraphQL/Client/BaseClients/Apollo.js b/src/GraphQL/Client/BaseClients/Apollo.js index bde5f80..e1c1a9a 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.js +++ b/src/GraphQL/Client/BaseClients/Apollo.js @@ -2,7 +2,6 @@ import { createClient as createWsClient } from "graphql-ws"; import { gql, split, - HttpLink, createHttpLink, InMemoryCache, ApolloClient, @@ -11,6 +10,37 @@ import { getMainDefinition } from "@apollo/client/utilities/index.js"; import { setContext } from "@apollo/client/link/context/index.js"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions/index.js"; +// Build the HTTP link. When opts.operationTypeHeader is set, requests are +// split by operation type and the type ("query" or "mutation") is sent as +// the value of that header, so eg. a load balancer can route queries to a +// read replica. +const mkHttpLink = function (opts) { + if (!opts.operationTypeHeader) { + return createHttpLink({ + uri: opts.url, + }); + } + + const linkForOperationType = function (operationType) { + return createHttpLink({ + uri: opts.url, + headers: { [opts.operationTypeHeader]: operationType }, + }); + }; + + return split( + function ({ query }) { + const definition = getMainDefinition(query); + return ( + definition.kind === "OperationDefinition" && + definition.operation === "query" + ); + }, + linkForOperationType("query"), + linkForOperationType("mutation"), + ); +}; + const createClientWithoutWebsockets = function (opts) { const authLink = setContext(function (_, { headers }) { // get the authentication token from local storage if it exists @@ -25,9 +55,7 @@ const createClientWithoutWebsockets = function (opts) { }; }); - const httpLink = createHttpLink({ - uri: opts.url, - }); + const httpLink = mkHttpLink(opts); return new ApolloClient({ link: authLink.concat(httpLink), @@ -40,13 +68,7 @@ const createClientWithoutWebsockets = function (opts) { }; const createClientWithWebsockets = function (opts) { - const httpLink = new HttpLink({ - uri: opts.url, - options: { - authToken: opts.authToken, - reconnect: true, - }, - }); + const httpLink = mkHttpLink(opts); const wsLink = new GraphQLWsLink( createWsClient({ diff --git a/src/GraphQL/Client/BaseClients/Apollo.purs b/src/GraphQL/Client/BaseClients/Apollo.purs index a98aa2a..91c8989 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.purs +++ b/src/GraphQL/Client/BaseClients/Apollo.purs @@ -1,13 +1,17 @@ -- | Creates GraphQL Apollo clients module GraphQL.Client.BaseClients.Apollo ( ApolloClientOptions + , ApolloClientOptions' , ApolloSubClientOptions + , ApolloSubClientOptions' , ApolloClient , ApolloSubClient , MutationOpts , QueryOpts , createClient + , createClient' , createSubscriptionClient + , createSubscriptionClient' , class IsApollo , updateCacheJson , updateCache @@ -45,6 +49,17 @@ type ApolloClientOptions = , headers :: Array RequestHeader } +-- | As `ApolloClientOptions`, plus `operationTypeHeader`: when set, HTTP +-- | requests include the GraphQL operation type ("query" or "mutation") as +-- | the value of the header with this name, so eg. a load balancer can route +-- | queries to a read replica. +type ApolloClientOptions' = + { url :: URL + , authToken :: Maybe String + , headers :: Array RequestHeader + , operationTypeHeader :: Maybe String + } + type ApolloSubClientOptions = { url :: URL , websocketUrl :: URL @@ -52,6 +67,19 @@ type ApolloSubClientOptions = , headers :: Array RequestHeader } +-- | As `ApolloSubClientOptions`, plus `operationTypeHeader`: when set, HTTP +-- | requests include the GraphQL operation type ("query" or "mutation") as +-- | the value of the header with this name, so eg. a load balancer can route +-- | queries to a read replica. Subscriptions go over the websocket and are +-- | unaffected (custom headers are not supported in websockets). +type ApolloSubClientOptions' = + { url :: URL + , websocketUrl :: URL + , authToken :: Maybe String + , headers :: Array RequestHeader + , operationTypeHeader :: Maybe String + } + -- | Apollo client to make graphQL queries and mutations. -- | From the @apollo/client npm module foreign import data ApolloClient :: Type @@ -91,34 +119,52 @@ createClient :: forall schema . ApolloClientOptions -> Effect (Client ApolloClient schema) -createClient = clientOptsToForeign >>> createClientImpl >>> map Client +createClient { url, authToken, headers } = + createClient' { url, authToken, headers, operationTypeHeader: Nothing } + +createClient' + :: forall schema + . ApolloClientOptions' + -> Effect (Client ApolloClient schema) +createClient' = clientOptsToForeign >>> createClientImpl >>> map Client createSubscriptionClient :: forall schema . ApolloSubClientOptions -> Effect (Client ApolloSubClient schema) -createSubscriptionClient = clientOptsToForeign >>> createSubscriptionClientImpl >>> map Client +createSubscriptionClient { url, websocketUrl, authToken, headers } = + createSubscriptionClient' { url, websocketUrl, authToken, headers, operationTypeHeader: Nothing } + +createSubscriptionClient' + :: forall schema + . ApolloSubClientOptions' + -> Effect (Client ApolloSubClient schema) +createSubscriptionClient' = clientOptsToForeign >>> createSubscriptionClientImpl >>> map Client clientOptsToForeign :: forall r . { authToken :: Maybe String , headers :: Array RequestHeader + , operationTypeHeader :: Maybe String | r } -> { authToken :: Nullable String , headers :: Object String + , operationTypeHeader :: Nullable String | r } clientOptsToForeign opts = opts { authToken = toNullable opts.authToken , headers = Object.fromFoldable $ opts.headers <#> \h -> Tuple (name h) (value h) + , operationTypeHeader = toNullable opts.operationTypeHeader } type ApolloClientOptionsForeign = { url :: URL , authToken :: Nullable String , headers :: Object String + , operationTypeHeader :: Nullable String } type ApolloSubApolloClientOptionsForeign = @@ -126,6 +172,7 @@ type ApolloSubApolloClientOptionsForeign = , websocketUrl :: URL , authToken :: Nullable String , headers :: Object String + , operationTypeHeader :: Nullable String } instance queryClient ::