GraphQLResolver
Batch lookups through a GraphQL client method with RequestResolver.
ordered matches results by position; findById matches by
key; aliased repeats an operation under aliases in one request.
request turns any of them into (key) => Effect.
Client middleware still run. When a method needs services, such as a
middleware's requires, batchKey keeps lookups with different services
in separate batches. Compose RequestResolver.setDelay and
RequestResolver.batchN with .pipe to control batching.
Resolvers
Batches calls to a generated query or mutation by repeating the operation under aliases in one request.
When to use
Use when the API has no list-key field, such as GitHub's pull requests by repository and number. Otherwise, ordered or findById send a smaller query.
Details
Use request to get (variables) => Effect. Variables that encode
to the same JSON share a slice; each slice uses the operation's result Schema.
Throws when the resolver is built if the operation has no document
template, if an operation directive references a variable, or if a root
selection is a fragment spread or inline fragment, since none of these can
be repeated under aliases.
Batches use POST even with queryMethod: "GET". The default batch size
is 10; change it with RequestResolver.batchN. Middleware run once per
batch, and batchKey works as in ordered. Mock and custom methods
are called once per slice instead.
Errors with a matching path[0] fail only that slice, with the alias prefix
removed from the error path. Errors without a matching path, transport errors
and middleware failures fail the batch.
Gotchas
An error under a non-null root field can null the entire response, failing
every slice. GitHub's repository and pullRequest are nullable.
Middleware that hash queries must use request.query, not
operation.document. Trusted-document allowlists don't support batching;
query hashing does, since the same operation and slice count produce the
same document.
Mutations are allowed, but the caller cannot control root-field order.
Signature
declare function aliased<Op extends Any>(method: OperationMethod<Op>, ...__namedParameters: {} extends BatchKey<Op> ? [options?: BatchKey<Op>] : [options: BatchKey<Op>]): RequestResolver<AliasedRequest<Op>>Example Looking up pull requests by number
import { Effect, Layer, RequestResolver, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
// Generated from:// query PullRequest($owner: String!, $name: String!, $number: Int!) {// repository(owner: $owner, name: $name) { pullRequest(number: $number) { title } }// }const PullRequest = GraphQL.query("PullRequest", { document: { variables: [ { name: "owner", body: "String!" }, { name: "name", body: "String!" }, { name: "number", body: "Int!" } ], selections: [{ name: "repository", body: ["(owner:$", "owner", ",name:$", "name", "){pullRequest(number:$", "number", "){title}}"] }], fragments: [] }, variables: { owner: Schema.String, name: Schema.String, number: Schema.Int }, result: Schema.Struct({ repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ title: Schema.String })) })) })})
// A stand-in server that answers every aliased sliceconst queries: Array<string> = []const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { queries.push(request.query) const data: Record<string, unknown> = {} for (const [key, number] of Object.entries(request.variables as Record<string, unknown>)) { const prefix = /^(_\d+_)number$/.exec(key)?.[1] if (prefix !== undefined) data[`${prefix}repository`] = { pullRequest: { title: `PR ${number}` } } } return Effect.succeed({ body: { data } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const github = yield* GraphQLClient.make(GraphQLGroup.make(PullRequest)) const PullRequests = GraphQLResolver.aliased(github.PullRequest).pipe(RequestResolver.batchN(20)) const getPullRequest = GraphQLResolver.request(PullRequests) const results = yield* Effect.forEach( [1, 2, 3], (number) => getPullRequest({ owner: "Effect-TS", name: "effect", number }), { concurrency: "unbounded" } ) return [results.map((result) => result.repository?.pullRequest?.title), queries.length]})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [["PR 1", "PR 2", "PR 3"], 1]Batches lookups by key for searches and collections that return results in any order.
Details
Write key before variables to infer the key type from its return type.
variables maps batch keys to operation variables. Keys are compared with
Equal and Hash. path and typename work as in ordered;
key receives the narrowed item type.
Missing keys, null parents, pagination and batchKey behave as in
ordered.
Any GraphQL error fails the batch with the same ResponseError as a
non-partial call, since error paths cannot be matched to keys.
Signature
declare function findById<Op extends Any, K, P extends readonly Array<string>, T extends string = never>(method: OperationMethod<Op>, options: { readonly key: (item: Narrow<Item<Result<Op>, P>, T>) => K; readonly path: Path<Result<Op>, P>; readonly typename?: T; readonly variables: (keys: readonly Array<K>) => Variables<Op>;} & BatchKey<Op>): RequestResolver<KeyRequest<K, Narrow<Item<Result<Op>, P>, T>, NoSuchElementError | Error<Op>, Services<Op>>>Example Finding repositories through search
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const Repository = Schema.Struct({ __typename: Schema.Literal("Repository"), nameWithOwner: Schema.String })const User = Schema.Struct({ __typename: Schema.Literal("User"), login: Schema.String })
const SearchRepos = GraphQL.query("SearchRepos", { document: "query SearchRepos($query:String!){search(query:$query,type:REPOSITORY,first:100){pageInfo{hasNextPage} nodes{__typename ...on Repository{nameWithOwner} ...on User{login}}}}", variables: { query: Schema.String }, result: Schema.Struct({ search: Schema.Struct({ pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Union([Repository, User])))) }) })})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { const { query } = request.variables as { query: string } const nodes = query.split(" ").map((term) => ({ __typename: "Repository", nameWithOwner: term.slice(5) })) return Effect.succeed({ body: { data: { search: { pageInfo: { hasNextPage: false }, nodes: nodes.reverse() } } } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(SearchRepos)) const RepoByName = GraphQLResolver.findById(client.SearchRepos, { key: (repo) => repo.nameWithOwner, variables: (names) => ({ query: names.map((name) => `repo:${name}`).join(" ") }), path: ["search", "nodes"], typename: "Repository" }) const repos = yield* Effect.forEach( ["Effect-TS/effect", "Effect-TS/website"], GraphQLResolver.request(RepoByName), { concurrency: "unbounded" } ) return repos.map((repo) => repo.nameWithOwner)})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["Effect-TS/effect", "Effect-TS/website"]Batches lookups for fields such as nodes(ids:) that return one entry per
key in input order.
Details
variables maps batch keys to operation variables; annotate its parameter
to set the key type. path selects a list through object fields, with
autocomplete at each level. For __typename unions, typename narrows
the success type and treats other types as missing.
Null entries, typename mismatches and null parents fail with
Cause.NoSuchElementError. GraphQL errors whose paths start with
[...path, i] fail only entry i; its ResponseError contains those errors
and the response status. Other GraphQL errors fail the batch with the same
ResponseError as a non-partial call. Transport, encode and decode errors
also fail the batch. A list length mismatch fails with DecodeError.
pageInfo.hasNextPage: true on the list's parent fails the batch with
DecodeError to avoid reporting unreturned entries as missing. Set the
operation's page size at least as large as RequestResolver.batchN.
A batch uses one lookup's services. Methods requiring services also require
batchKey; only lookups with keys equal under Equal share a batch. Return
the value that distinguishes those services, for example
(context) => Context.get(context, CurrentUser).tenantId. Methods without
services use one batch and do not accept batchKey.
Gotchas
Don't return the whole context from batchKey. It also holds the caller's
span, so most lookups would get a batch of their own.
A middleware that reads a Context.Reference with a default value doesn't
add it to the method's services. batchKey isn't required for it, and
lookups with different values can share a batch.
Signature
declare function ordered<Op extends Any, K, P extends readonly Array<string>, T extends string = never>(method: OperationMethod<Op>, options: { readonly path: Path<Result<Op>, P>; readonly typename?: T; readonly variables: (keys: readonly Array<K>) => Variables<Op>;} & BatchKey<Op>): RequestResolver<KeyRequest<K, Narrow<Item<Result<Op>, P>, T>, NoSuchElementError | Error<Op>, Services<Op>>>Example
import { Cause, Effect, Layer, RequestResolver, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const Repository = Schema.Struct({ __typename: Schema.Literal("Repository"), nameWithOwner: Schema.String })const User = Schema.Struct({ __typename: Schema.Literal("User"), login: Schema.String })
const RepoNodes = GraphQL.query("RepoNodes", { document: "query RepoNodes($ids:[ID!]!){nodes(ids:$ids){__typename ...on Repository{nameWithOwner} ...on User{login}}}", variables: { ids: Schema.Array(Schema.String) }, result: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(Schema.Union([Repository, User]))) })})
const store: Record<string, unknown> = { R_1: { __typename: "Repository", nameWithOwner: "Effect-TS/effect" }, U_1: { __typename: "User", login: "tim" }}const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { const { ids } = request.variables as { ids: Array<string> } return Effect.succeed({ body: { data: { nodes: ids.map((id) => store[id] ?? null) } } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoNodes)) const RepoById = GraphQLResolver.ordered(client.RepoNodes, { variables: (ids: ReadonlyArray<string>) => ({ ids }), path: ["nodes"], typename: "Repository" }).pipe(RequestResolver.batchN(100)) const getRepo = GraphQLResolver.request(RepoById) return yield* Effect.forEach( ["R_1", "U_1", "R_2"], (id) => getRepo(id).pipe( Effect.map((repo) => repo.nameWithOwner), Effect.catchIf(Cause.isNoSuchElementError, () => Effect.succeed("missing")) ), { concurrency: "unbounded" } )})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["Effect-TS/effect", "missing", "missing"]Running
Looks up a key with an ordered, findById or aliased
resolver, using request(resolver)(key) or request(key, resolver).
Details
Concurrent lookups are batched. Keys equal under Equal and Hash
are sent once per batch and share the result.
Signature
declare const request: { <K, A, E, R>(resolver: RequestResolver<KeyRequest<K, A, E, R>>): (key: K) => Effect<A, E, R>; <K, A, E, R>(key: K, resolver: RequestResolver<KeyRequest<K, A, E, R>>): Effect<A, E, R>;}Example Looking up repositories by id
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const RepoNodes = GraphQL.query("RepoNodes", { document: "query RepoNodes($ids:[ID!]!){nodes(ids:$ids){... on Repository{name}}}", variables: { ids: Schema.Array(Schema.String) }, result: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(Schema.Struct({ name: Schema.String }))) })})
let calls = 0const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: (request) => { calls++ const { ids } = request.variables as { ids: Array<string> } return Effect.succeed({ body: { data: { nodes: ids.map((id) => ({ name: `repo-${id}` })) } } }) }, subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoNodes)) const getRepo = GraphQLResolver.request(GraphQLResolver.ordered(client.RepoNodes, { variables: (ids: ReadonlyArray<string>) => ({ ids }), path: ["nodes"] })) const repos = yield* Effect.forEach(["1", "2", "1"], getRepo, { concurrency: "unbounded" }) return [repos.map((repo) => repo.name), calls]})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [["repo-1", "repo-2", "repo-1"], 1]