/**
* Batch lookups through a GraphQL client method with `RequestResolver`.
* {@link ordered} matches results by position; {@link findById} matches by
* key; {@link aliased} repeats an operation under aliases in one request.
* {@link 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.
*
* Lookups with different protocol URLs are batched separately, and
* `RequestResolver.batchN` limits each URL's batches. See
* `GraphQLProtocol.layerHttp` for middleware and caching constraints.
*
* @stability experimental
* @since 4.0.0
*/
import * as Arr from "../Array.ts"
import * as Cause from "../Cause.ts"
import * as Context from "../Context.ts"
import * as Effect from "../Effect.ts"
import * as Exit from "../Exit.ts"
import { dual } from "../Function.ts"
import * as Headers from "../http/Headers.ts"
import * as UrlParams from "../http/UrlParams.ts"
import * as internalGraphQL from "../internal/graphql.ts"
import * as MutableHashMap from "../MutableHashMap.ts"
import * as Request from "../Request.ts"
import * as RequestResolver from "../RequestResolver.ts"
import * as Tracer from "../Tracer.ts"
import type * as Types from "../Types.ts"
import type * as GraphQL from "./GraphQL.ts"
import type { OperationMethod, PartialResult } from "./GraphQLClient.ts"
import { DecodeError, type GraphQLClientError, type GraphQLError } from "./GraphQLClientError.ts"
import type { ExecutionResult, GraphQLRequest } from "./GraphQLProtocol.ts"
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
type NonNull = Exclude
type Step = K extends keyof NonNull ? NonNull[K] : never
// Check one segment at a time for autocomplete without enumerating all paths.
// The path must end at a list and cannot cross one.
type CheckPath = NonNull extends ReadonlyArray ? readonly []
: P extends readonly [infer Head, ...infer Tail]
? readonly [Head extends keyof NonNull ? Head : keyof NonNull, ...CheckPath, Tail>]
: readonly [keyof NonNull]
type Path = P extends CheckPath ? P : CheckPath
type AtPath = P extends readonly [infer Head, ...infer Tail] ? AtPath, Tail> : A
type Item = NonNull> extends ReadonlyArray ? NonNull : never
type Typenames = Types.IsUnion extends true ?
I extends { readonly __typename: infer T extends string } ? T : never
: never
type Narrow = [T] extends [never] ? I : Extract
type BatchKey = [GraphQL.Services] extends [never] ? { readonly batchKey?: never }
: { readonly batchKey: (context: Context.Context>) => unknown }
interface KeyRequest extends Request.Request {
readonly key: K
/** The protocol URL {@link request} resolved for this lookup, if it could. */
readonly url: string | undefined
}
// Requests are structural, so two requests with equal keys and URLs are one request.
const makeRequest = (key: K, url: string | undefined): KeyRequest =>
Request.of>()({ key, url })
// -----------------------------------------------------------------------------
// Running
// -----------------------------------------------------------------------------
/**
* Looks up a key with an {@link ordered}, {@link findById} or {@link 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.
*
* **Example** (Looking up repositories by id)
*
* ```ts import.meta.vitest
* 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 = 0
* const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
* execute: (request) => {
* calls++
* const { ids } = request.variables as { ids: Array }
* 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) => ({ 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]
* ```
*
* @stability experimental
* @category running
* @since 4.0.0
*/
export const request: {
// -----------------------------------------------------------------------------
// Running
// -----------------------------------------------------------------------------
/**
* Looks up a key with an {@link ordered}, {@link findById} or {@link 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.
*
* **Example** (Looking up repositories by id)
*
* ```ts import.meta.vitest
* 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 = 0
* const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
* execute: (request) => {
* calls++
* const { ids } = request.variables as { ids: Array }
* 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) => ({ 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]
* ```
*
* @stability experimental
* @category running
* @since 4.0.0
*/
(resolver: RequestResolver.RequestResolver>): (key: K) => Effect.Effect
// -----------------------------------------------------------------------------
// Running
// -----------------------------------------------------------------------------
/**
* Looks up a key with an {@link ordered}, {@link findById} or {@link 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.
*
* **Example** (Looking up repositories by id)
*
* ```ts import.meta.vitest
* 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 = 0
* const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
* execute: (request) => {
* calls++
* const { ids } = request.variables as { ids: Array }
* 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) => ({ 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]
* ```
*
* @stability experimental
* @category running
* @since 4.0.0
*/
(key: K, resolver: RequestResolver.RequestResolver>): Effect.Effect
} = dual(
2,
(key: K, resolver: RequestResolver.RequestResolver>): Effect.Effect => {
// Resolved in the caller's fiber, so the URL is part of the pending batch's key.
const resolveUrl = urlResolvers.get(resolver.batchKey)
return resolveUrl === undefined
? queue(makeRequest(key, undefined), resolver)
: Effect.flatMap(resolveUrl, (url) => queue(makeRequest(key, url), resolver))
}
)
// -----------------------------------------------------------------------------
// Resolvers
// -----------------------------------------------------------------------------
/**
* 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.
*
* **Example** (Looking up repositories in a `Node` union)
*
* ```ts import.meta.vitest
* 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 = {
* 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 }
* 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) => ({ 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"]
* ```
*
* @stability experimental
* @category resolvers
* @since 4.0.0
*/
export const ordered = <
Op extends GraphQL.Any,
K,
const P extends ReadonlyArray,
T extends Typenames- , P>> = never
>(
method: OperationMethod,
options: {
readonly variables: (keys: ReadonlyArray) => GraphQL.Variables
readonly path: Path, P>
readonly typename?: T | undefined
} & BatchKey
): RequestResolver.RequestResolver<
KeyRequest<
K,
Narrow
- , P>, T>,
GraphQL.Error | Cause.NoSuchElementError,
GraphQL.Services
>
> => {
const path = options.path as ReadonlyArray
const codec = internalGraphQL.makeCodec(method.operation)
return makeResolver(
method,
options.batchKey,
(entries) =>
Effect.flatMap(callBatch(method, entries, options.variables), ({ keys, targets, result }) => {
const byIndex = new Map>()
for (const error of result.errors) {
const index = entryIndex(error, path, keys.length)
if (index === undefined) return batchError(codec, result)
const errors = byIndex.get(index)
if (errors === undefined) byIndex.set(index, [error])
else errors.push(error)
}
const list = findList(result.data, path)
// An error in a non-null item nulls the whole list, so every entry lost its data to it.
if (list === undefined) return result.errors.length > 0 ? batchError(codec, result) : completeAll(targets)
if (list.truncated) return Effect.fail(codec.fail(truncated(path)))
if (list.items.length !== keys.length) {
return Effect.fail(codec.fail(
new DecodeError({
description: `Expected ${keys.length} entries at ${path.join(".")}, received ${list.items.length}`
})
))
}
const response = internalGraphQL.partialResponses.get(result)
for (let i = 0; i < targets.length; i++) {
const errors = byIndex.get(i)
const item = list.items[i]
targets[i].completeUnsafe(
errors !== undefined
? Exit.fail(codec.fail(
internalGraphQL.responseError({ status: response?.status, headers: response?.headers }, errors)
))
: matchesTypename(item, options.typename)
? Exit.succeed(item)
: constNoSuchElement
)
}
return Effect.void
})
)
}
/**
* 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 {@link ordered};
* `key` receives the narrowed item type.
*
* Missing keys, null parents, pagination and `batchKey` behave as in
* {@link ordered}.
* Any GraphQL error fails the batch with the same `ResponseError` as a
* non-partial call, since error paths cannot be matched to keys.
*
* **Example** (Finding repositories through search)
*
* ```ts import.meta.vitest
* 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"]
* ```
*
* @stability experimental
* @category resolvers
* @since 4.0.0
*/
export const findById = <
Op extends GraphQL.Any,
K,
const P extends ReadonlyArray,
T extends Typenames
- , P>> = never
>(
method: OperationMethod,
options: {
readonly variables: (keys: ReadonlyArray) => GraphQL.Variables
readonly path: Path, P>
readonly key: (item: Narrow
- , P>, T>) => K
readonly typename?: T | undefined
} & BatchKey
): RequestResolver.RequestResolver<
KeyRequest<
K,
Narrow
- , P>, T>,
GraphQL.Error | Cause.NoSuchElementError,
GraphQL.Services
>
> => {
const path = options.path as ReadonlyArray
const codec = internalGraphQL.makeCodec(method.operation)
return makeResolver(
method,
options.batchKey,
(entries) =>
Effect.flatMap(callBatch(method, entries, options.variables), ({ keys, targets, result }) => {
if (result.errors.length > 0) return batchError(codec, result)
const list = findList(result.data, path)
if (list === undefined) return completeAll(targets)
if (list.truncated) return Effect.fail(codec.fail(truncated(path)))
const pending = MutableHashMap.empty>()
for (let i = 0; i < keys.length; i++) MutableHashMap.set(pending, keys[i], targets[i])
for (const item of list.items) {
if (!matchesTypename(item, options.typename)) continue
const key = options.key(item as Narrow
- , P>, T>)
const target = MutableHashMap.get(pending, key)
if (target._tag === "None") continue
MutableHashMap.remove(pending, key)
target.value.completeUnsafe(Exit.succeed(item))
}
MutableHashMap.forEach(pending, (target) => target.completeUnsafe(constNoSuchElement))
return Effect.void
})
)
}
/**
* 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, {@link ordered} or {@link findById} send
* a smaller query.
*
* **Details**
*
* Use {@link 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 {@link 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.
*
* **Example** (Looking up pull requests by number)
*
* ```ts import.meta.vitest
* 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 slice
* const queries: Array = []
* const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
* execute: (request) => {
* queries.push(request.query)
* const data: Record = {}
* for (const [key, number] of Object.entries(request.variables as Record)) {
* 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]
* ```
*
* @stability experimental
* @category resolvers
* @since 4.0.0
*/
export const aliased = (
method: OperationMethod,
...[options]: {} extends BatchKey ? [options?: BatchKey] : [options: BatchKey]
): RequestResolver.RequestResolver> => {
const operation = method.operation
const template = operation.template
if (template === undefined) {
throw new Error(`GraphQLResolver.aliased: operation ${operation.name} has no document template`)
}
const unsliceable = internalGraphQL.templateUnsliceableReason(template)
if (unsliceable !== undefined) {
throw new Error(`GraphQLResolver.aliased: cannot batch operation ${operation.name}: ${unsliceable}`)
}
const codec = internalGraphQL.makeCodec(operation)
// The registered hook uses this operation's middleware errors and services.
const execute = internalGraphQL.rawExecutors.get(method)?.execute as
| ((request: GraphQLRequest) => Effect.Effect, GraphQL.Services>)
| undefined
// Generic conditional parameters prevent calling the strict overload directly.
const call = method as unknown as (
variables: GraphQL.Variables
) => Effect.Effect, GraphQL.Error, GraphQL.Services>
return makeResolver>(method, options?.batchKey, (entries) =>
Effect.flatMap(
Effect.forEach(entries, (entry) => Effect.exit(codec.encodeVariables(entry.request.key))),
(encoded) => {
const slices: Array> = []
const byJson = new Map>()
for (let i = 0; i < entries.length; i++) {
const exit = encoded[i]
if (exit._tag === "Failure") {
entries[i].completeUnsafe(exit)
continue
}
const json = internalGraphQL.canonicalJson(exit.value)
const slice = byJson.get(json)
if (slice !== undefined) {
slice.entries.push(entries[i])
continue
}
const created = { variables: entries[i].request.key, encoded: exit.value, entries: [entries[i]] }
byJson.set(json, created)
slices.push(created)
}
const complete = (slice: Slice, effect: Effect.Effect, GraphQL.Error, R>) =>
Effect.map(Effect.exit(effect), (exit) => {
for (const entry of slice.entries) entry.completeUnsafe(exit)
})
if (execute === undefined) {
return Effect.forEach(slices, (slice) => complete(slice, call(slice.variables)), { discard: true })
}
if (!Arr.isReadonlyArrayNonEmpty(slices)) return Effect.void
const prefixes = Arr.map(slices, (_, i) => `_${i}_`)
return execute({
kind: operation.kind,
query: internalGraphQL.renderSlices(operation.kind, operation.name, template, prefixes),
operationName: operation.name,
variables: Object.fromEntries(
slices.flatMap((slice, i) =>
Object.entries(slice.encoded ?? {}).map(([name, value]) => [prefixes[i] + name, value])
)
),
headers: Headers.empty,
urlParams: UrlParams.empty
}).pipe(
Effect.flatMap((result) => {
const results = internalGraphQL.splitSlices(result, template, prefixes)
return results === undefined
? codec.failResponse(result)
: Effect.forEach(slices, (slice, i) => complete(slice, codec.decodeStrict(results[i])), { discard: true })
})
)
}
)).pipe(RequestResolver.batchN(10))
}
type AliasedRequest = KeyRequest<
GraphQL.Variables,
GraphQL.Result,
GraphQL.Error,
GraphQL.Services
>
interface Slice {
readonly variables: GraphQL.Variables
readonly encoded: unknown
readonly entries: Array>>
}
// -----------------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------------
const constNoSuchElement = Exit.fail(new Cause.NoSuchElementError())
/**
* URL resolvers by resolver `batchKey`, which `RequestResolver.batchN` and
* `setDelay` keep, so {@link request} can resolve a lookup's URL before it is
* queued.
*/
const urlResolvers = new WeakMap>()
/**
* Groups pending entries by URL and `batchKey`, so collection limits apply to
* each group. A URL that `request` couldn't resolve, for example after
* `RequestResolver.grouped` replaced the key, is resolved here in the entry's
* context, and the batch is split again. Each batch runs in its first entry's
* context, under any span opened around the batch, carrying the URL it was
* grouped by, so the transport doesn't resolve it again.
*/
const makeResolver = >(
method: OperationMethod,
batchKey: ((context: Context.Context) => unknown) | undefined,
runAll: (entries: Arr.NonEmptyArray>) => Effect.Effect, any>
): RequestResolver.RequestResolver => {
const resolve = internalGraphQL.rawExecutors.get(method)?.url
const resolveUrl = resolve?.({ kind: method.operation.kind, operationName: method.operation.name })
// Arrays are compared structurally, so equal keys share a batch.
const keyOf = (entry: Request.Entry, url: string | undefined) => [url, batchKey?.(entry.context)] as const
const urlOf = (entry: Request.Entry) =>
entry.request.url !== undefined || resolveUrl === undefined
? Effect.succeed(entry.request.url)
: runIn(resolveUrl, entry.context)
const resolver = RequestResolver.makeGrouped({
key: (entry) => keyOf(entry, entry.request.url),
resolver: (entries) =>
Effect.contextWith((current: Context.Context) => {
const span = batchSpan(current)
return Effect.flatMap(Effect.forEach(entries, urlOf, { concurrency: "unbounded" }), (urls) => {
const batches = MutableHashMap.empty<
readonly [string | undefined, unknown],
Arr.NonEmptyArray>
>()
entries.forEach((entry, i) => {
const key = keyOf(entry, urls[i])
const batch = MutableHashMap.get(batches, key)
if (batch._tag === "Some") batch.value.push(entry)
else MutableHashMap.set(batches, key, [entry])
})
return Effect.forEach(batches, ([[url], entries]) => {
let context: Context.Context = entries[0].context
if (url !== undefined) context = Context.add(context, internalGraphQL.ResolvedUrl, { resolve, url })
if (span !== undefined) context = Context.add(context, Tracer.ParentSpan, span)
return runIn(runAll(entries), context)
}, { concurrency: "unbounded", discard: true })
})
})
})
if (resolveUrl !== undefined) urlResolvers.set(resolver.batchKey, resolveUrl)
return resolver
}
// Replaces, rather than merges into, the batch fiber's context, which may
// belong to a lookup with other services or `Context.Reference` values.
const runIn = (effect: Effect.Effect, context: Context.Context): Effect.Effect =>
Effect.updateContext(effect, (_: Context.Context) => context)
// The caller's span when it queued a lookup. A batch fiber inherits it from the
// caller that started or filled the batch, even if that caller was interrupted.
const QueuedSpan = Context.Reference("effect/graphql/GraphQLResolver/QueuedSpan", {
defaultValue: () => undefined
})
const queue = (
request: KeyRequest,
resolver: RequestResolver.RequestResolver>
): Effect.Effect =>
Effect.contextWith((context: Context.Context) =>
Effect.provideService(
Effect.request(request, resolver),
QueuedSpan,
Context.getOrUndefined(context, Tracer.ParentSpan)
)
)
// A span opened around the batch, such as `RequestResolver.withSpan`'s, rather
// than the one the batch fiber inherited from a caller.
const batchSpan = (context: Context.Context) => {
const span = Context.getOrUndefined(context, Tracer.ParentSpan)
return span === Context.get(context, QueuedSpan) ? undefined : span
}
/**
* Calls the method with deduplicated keys. Each target completes all entries
* sharing its key.
*/
const callBatch = (
method: OperationMethod,
entries: Arr.NonEmptyArray>>,
variables: (keys: ReadonlyArray) => GraphQL.Variables
) => {
const keys: Array = []
const targets: Array>> = []
const indices = MutableHashMap.empty()
for (const entry of entries) {
const index = MutableHashMap.get(indices, entry.request.key)
if (index._tag === "None") {
MutableHashMap.set(indices, entry.request.key, keys.length)
keys.push(entry.request.key)
targets.push(entry)
continue
}
const previous = targets[index.value]
targets[index.value] = {
...previous,
completeUnsafe(exit) {
previous.completeUnsafe(exit)
entry.completeUnsafe(exit)
}
}
}
// Generic conditional parameters prevent calling the partial overload directly.
const call = method as unknown as (
variables: GraphQL.Variables,
options: { readonly partial: true }
) => Effect.Effect>, GraphQL.Error, GraphQL.Services>
return Effect.map(call(variables(keys), { partial: true }), (result) => ({ keys, targets, result }))
}
// Fails the batch with the response's errors and transport metadata.
const batchError = (
codec: { readonly failResponse: (result: ExecutionResult) => Effect.Effect },
result: PartialResult
) => codec.failResponse(internalGraphQL.partialResponses.get(result) ?? { errors: result.errors })
const truncated = (path: ReadonlyArray) =>
new DecodeError({
description: `pageInfo.hasNextPage is true next to ${
path.join(".")
}, so results beyond the page would be reported missing. Make the page size at least the RequestResolver.batchN size`
})
const completeAll = (targets: ReadonlyArray>) => {
for (const target of targets) target.completeUnsafe(constNoSuchElement)
return Effect.void
}
const entryIndex = (error: GraphQLError, path: ReadonlyArray, length: number): number | undefined => {
const errorPath = error.path
if (errorPath === undefined || errorPath.length <= path.length) return undefined
for (let i = 0; i < path.length; i++) {
if (errorPath[i] !== path[i]) return undefined
}
const index = errorPath[path.length]
return typeof index === "number" && Number.isInteger(index) && index >= 0 && index < length ? index : undefined
}
const findList = (
data: unknown,
path: ReadonlyArray
): { readonly items: ReadonlyArray; readonly truncated: boolean } | undefined => {
// Path guarantees object fields leading to a list.
let parent = data as Record | null | undefined
for (let i = 0; i < path.length - 1; i++) parent = parent?.[path[i]]
const items = parent?.[path[path.length - 1]] as ReadonlyArray | null | undefined
if (items === null || items === undefined) return undefined
return { items, truncated: parent!.pageInfo?.hasNextPage === true }
}
const matchesTypename = (item: unknown, typename: string | undefined): boolean =>
item !== null && item !== undefined &&
(typename === undefined || (item as { readonly __typename?: unknown }).__typename === typename)