@iappx/entity-repo-gql
v1.1.0
Published
A schema agnostic GraphQL dialect for @iappx/entity-repo: compile the query AST into documents, with filters, ordering, paging, selection, variables and responses as swappable strategies.
Downloads
39
Maintainers
Readme
@iappx/entity-repo-gql
The GraphQL dialect that does not know your schema — you tell it.
Compiles the query AST of @iappx/entity-repo-query into GraphQL
documents. Every schema convention — where arguments or field directives, offset or Relay cursors, accounts_by_pk
or accountById — is a strategy you plug in, not a branch inside this package.
npm install @iappx/entity-repo-gqlThe packages
| Package | What it is |
| --- | --- |
| @iappx/entity-repo | The core. Entities, metadata, change tracking, contexts, transport interfaces. This package extends its EntityAttribute with gqlType and gqlName. |
| @iappx/entity-repo-query | The typed builder and the query AST this package compiles, plus the capability validator. |
| @iappx/entity-repo-gql (this one) | The GraphQL dialect: AST → document → entities back. |
| @iappx/entity-repo-rest | The REST dialect. Same entities, same query chain, a url instead of a document. |
| @iappx/gql-builder | The dependency-free document builder this package prints through — Gql, GqlField, GqlOperation in the examples below come from there. |
All three peers are required at runtime — install them alongside this package:
npm install @iappx/entity-repo @iappx/entity-repo-query @iappx/gql-builder @iappx/entity-repo-gqlHow it works
The query AST says what to fetch. The strategies say how this schema spells it. Nothing in between is baked in.
your application the query AST this package the wire
┌───────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ .where(f => …) │ │ TQueryAst │ │ filter · order · │ │ query accounts { │
│ .orderBy('name') │ → │ plain JSON, no │ → │ paging → fragments │ → │ accounts(where:…)│
│ .take(20) │ │ classes, no │ │ argument strategy → │ │ { id email } │
│ .getPage() │ │ network │ │ where they go │ │ } │
└───────────────────┘ └──────────────────┘ └──────────────────────┘ └────────────────────┘
typed by your serialisable, every box above is an one string, one
entity classes cacheable interface you can swap variables objectThe same builder chain compiles to a query, a subscription or a mutation. Swap one strategy and the document changes shape — not a single line of application code moves.
Highlights
| | |
| --- | --- |
| 🧩 Strategies, not switches | Filters, ordering, paging, selection, argument placement, variables, operation names and responses are nine interfaces. The package ships one humble default for each. |
| 🎯 Arguments or directives — your call | accounts(where: {…}), accounts @filter(field: "status", op: EQ) and { status @filters(items: $filter_status) } are the same AST through different strategies. None of them is special. |
| 🚦 Capabilities are composed | The dialect's TQueryCapabilities is assembled from the compilers actually plugged in. A richer filter compiler automatically raises what QueryValidator accepts. |
| 🔤 Variables or literals | Inline values by default; switch on VariableValueStrategy and every value is lifted, typed and named — the document text stays byte-identical across calls. |
| 📡 Subscriptions are not an afterthought | The chain you read with is the chain you watch() — same filter, same selection, subscription instead of query. |
| 🧾 Writes go through the entity | create / update / delete serialise through entity.serialize({ operation, onlyChanged }); responses come back through Entity.build, clean and unchanged. |
| 🔌 Bring your own client | The request carries the printed document and the builder's operation object — hand it to Apollo, urql, a socket, or use the bundled FetchGqlTransport. |
| 🪶 No runtime dependencies | Three peers, no graphql package, no codegen, no schema at build time. |
Requirements
- Node.js >= 18
- TypeScript with
experimentalDecorators @iappx/entity-repo>= 3.2.0,@iappx/entity-repo-query>= 1.1.0,@iappx/gql-builder>= 1.0.0 (peer dependencies)
Quick start
1. Describe an entity
Fields live on the prototype as accessors, so under strict they need a definite assignment assertion (!). gqlType
is this package's addition to the field options — see Field metadata.
import { RepoEntityBase, RepoEntityField } from '@iappx/entity-repo'
import '@iappx/entity-repo-gql'
export class Account extends RepoEntityBase<Account> {
@RepoEntityField({ isPrimaryKey: true, isGenerated: true, gqlType: 'uuid!' })
public id!: string
@RepoEntityField()
public email!: string
@RepoEntityField()
public status!: 'active' | 'banned'
@RepoEntityField({ isReadonly: true })
public createdAt!: string
@RepoEntityField({ nestedType: () => Book, isArray: true })
public books!: Book[]
}2. Name the operations of the entity set
There is no guessing here: accounts, accounts_by_pk, insert_accounts_one is one convention among many, so the
names come from you. An operation you never call needs no name.
import { EntityContextBase, NamingStrategies, RepoEntitySet } from '@iappx/entity-repo'
import { FetchGqlTransport, GqlEntityQuery, StaticOperationNaming } from '@iappx/entity-repo-gql'
export class AppContext extends EntityContextBase<FetchGqlTransport> {
@RepoEntitySet(() => Account, () => GqlEntityQuery, {
naming: NamingStrategies.snakeCase,
operations: new StaticOperationNaming({
list: 'accounts',
one: 'accounts_by_pk',
aggregate: 'accounts_aggregate',
insert: 'insert_accounts_one',
update: 'update_accounts_by_pk',
delete: 'delete_accounts_by_pk',
subscribe: 'accounts',
}),
})
public accounts!: GqlEntityQuery<Account>
}3. Query
const context = EntityRepo.create()
.use(AppContext, new FetchGqlTransport({ url: 'https://example.test/graphql' }))
.getContext(AppContext)
const page = await context.accounts
.select(s => s.only('id', 'email'))
.where(f => f.eq('status', 'active'))
.orderBy('createdAt', 'desc')
.take(20)
.getPage()The document that went out — nothing more, nothing less:
query accounts {
accounts(where: { status: { eq: "active" } }, orderBy: [{ created_at: desc }], limit: 20) {
id
email
}
}4. Write, count, watch
const created = await context.accounts.create(account) // mutation insert_accounts_one(input: …)
const updated = await context.accounts.update(account) // only the changed fields
await context.accounts.delete(account) // or delete('7')
const total = await context.accounts.count() // accounts_aggregate { count }
for await (const account of context.accounts.where(f => f.eq('status', 'active')).watch()) {
console.log(account.email) // subscription accounts(where: …)
}The entity query
GqlEntityQuery<T> extends QueryableEntityQuery, so every builder method of the query package
(where · orderBy · take · select · include · rawFilter · withMeta …) is available and returns a new query.
On top of that:
| Method | Operation |
| --- | --- |
| getAll() | list query → T[] |
| getPage() | list query → TPage<T> (items, total?, cursor?) |
| getOne(pk) | by primary key → T \| undefined |
| count() | aggregate → number |
| create(entity) / update(entity) / delete(entity \| pk) | mutations |
| watch() | subscription → AsyncIterable<T> |
| compile() · compileOne(pk) · compileAggregate() · compileSubscription() · compileCreate(entity) · compileUpdate(entity) · compileDelete(target) | the TGqlRequest, without sending it |
| dialect() | the assembled GqlDialect |
Reads go through Entity.build(data, { naming }), so the entities you get back are clean — isChanged() is false.
Writes go through entity.serialize({ operation, onlyChanged, naming }), so readonly fields never leave the process and
an update carries only what actually changed.
Extension points
Everything below is an option of TGqlQueryOptions — the object you hand to @RepoEntitySet. Pass an instance of the
shipped default, configure it, or pass your own class.
| Option | Contract | Ships with | Decides |
| --- | --- | --- | --- |
| operations | IOperationNaming | StaticOperationNaming | The root field of every operation: list, one, aggregate, insert, update, delete, subscribe. |
| filters | IFilterCompiler (extend GqlFilterCompiler) | ObjectFilterCompiler | TFilterNode → a value, arguments, directives, or all three. |
| orders | IOrderCompiler | ObjectOrderCompiler | TOrderNode[] → the same. |
| paging | IPagingCompiler | OffsetPagingCompiler | TPagingNode → the same. |
| args | IArgumentStrategy | ArgumentPlacement | Where the compiled parts go — arguments on the field, directives on the field, or split. Root fields and relation fields alike. The selection of the field is already built when it runs. |
| selection | ISelectionCompiler | FieldSelectionCompiler | TResolvedSelection[] → the GqlField tree, including per-field meta, the arguments of an included relation and the fragment parts addressed to single fields. Through the optional TGqlSelectionContext it also sees the query AST and the compiled fragments, so it can wrap the selection when — and only when — the query asks for one. |
| aggregate | IAggregateCompiler | PathAggregateCompiler | What count() selects and how it is read back. |
| variables | IVariableStrategy | InlineValueStrategy | Literal or variable, the variable's name, and its GraphQL type (through IGqlTypeResolver). |
| response | IResponseAdapter | ListResponseAdapter | Response → TPage<T>: a plain list, a Relay connection, anything. |
| capabilities | TQueryCapabilities | composed | Override the composed set entirely — rarely what you want. |
| pagingKind | TPagingKind | 'offset' | What a bare .take(20) means for this schema. Set it to 'cursor' and take builds a { first } window instead of { limit }. |
| naming | NamingStrategy | — | Property key → source name, e.g. NamingStrategies.snakeCase. Reaches CompileContext. |
| values | ValueSerializer | the core one | How a JavaScript value becomes a wire value before any strategy sees it — subclass it for custom scalars. |
| maxDepth · pretty · name · dialect · meta | — | 3 · false · 'GraphQLDialect' | Selection depth limit, printing, the dialect's name in errors, a prepared GqlDialect, options-level meta. |
The defaults are deliberately the smallest honest baseline — an object condition { field: { operator: value } }, a list
of { field: direction } orderings, limit / offset, arguments rather than directives, inline values and a plain
list response. They are a starting point, not a schema. Every one of them is configurable before you need a new class:
filters: new ObjectFilterCompiler({ and: '_and', or: '_or', not: '_not', operators: { eq: '_eq', in: '_in' } })
orders: new ObjectOrderCompiler({ ascending: 'ASC', descending: 'DESC', list: false })
paging: new OffsetPagingCompiler({ limit: 'first', offset: 'skip' })
args: new ArgumentPlacement({ where: 'filter', orderBy: 'sort', create: 'object', update: 'set' })Capabilities are composed, not declared
Each compiler declares only the slice it is responsible for. GqlCapabilityComposer starts from
QueryCapabilityPresets.none() — nothing supported — and widens it slice by slice into the TQueryCapabilities that
QueryValidator checks the AST against, before anything is compiled or sent. Nothing is accepted that no plugged-in
compiler claimed.
public capabilities(): TCapabilitySlice {
return { paging: ['cursor'] } // a cursor paging compiler says only this
}Plug that compiler in and cursor paging starts being accepted; nothing else about the dialect changes. Pair it with
pagingKind: 'cursor' and a plain .take(10) in application code becomes { kind: 'cursor', first: 10 }, so the
chain never has to know it is talking to a connection. Plug in a filter
compiler that cannot do or, and a query with an or fails with a readable list of reasons instead of reaching the
server:
QueryValidationError: GraphQLDialect cannot compile the query
- logical "or" is not supported
- relation filters are not supported (path: books)What a compiled fragment carries
A filter, an ordering and a paging window each compile into a TGqlFragment — the smallest description of "here is a part of the
query, place it wherever this schema keeps it":
type TGqlFieldFragment = {
path: string[] // the GraphQL field names, from the root of the selection
args?: GqlArgument[]
directives?: GqlDirective[]
}
type TGqlFragment = {
value?: GqlValue // one value, e.g. the object a `where` argument takes
args?: GqlArgument[] // arguments for the queried field
directives?: GqlDirective[] // directives for the queried field
fields?: TGqlFieldFragment[]// parts addressed to fields *inside* the selection
}The first three go to the argument strategy, which decides where on the queried field they land. The last one goes to the
selection compiler, because it is the class that builds those fields — FieldSelectionCompiler puts each part on the field its
path names, descends into relations, and quietly drops a part nobody selected. That is what a schema needs when its conditions
ride on the leaves:
return {
fields: [{ path: ['name'], directives: [Gql.directive('filters', [Gql.arg('items', items)])] }],
}The order the parts are put together
For every operation the selection is built first and the argument strategy runs after it, on the root field and on every
relation field alike. So applyQuery / applyPrimaryKey / applyPayload always see field.fields fully populated and may look at
what was selected before deciding where to put anything.
Filters and ordering as directives
This is the point of the package, so here is the whole thing. A schema that carries its conditions as directives on the field instead of arguments:
query accounts {
accounts @filter(field: "status", op: EQ, value: "active") @orderBy(field: "createdAt", direction: DESC) {
id
email
}
}Three consumer-side classes, no fork, no change to this package.
1. The filter compiler. Extend GqlFilterCompiler — it is a FilterVisitor, so a node kind can never be silently
forgotten — and return directives instead of a value. The compiler is stateless: the build scope arrives as the visitor
context, so one instance can serve every query of the entity set.
export class DirectiveFilterCompiler extends GqlFilterCompiler {
public capabilities(): TCapabilitySlice {
return {
operators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in'],
logical: { or: false, not: false, nesting: false },
relationFilters: { enabled: false, quantifiers: [] },
raw: false,
}
}
protected visitComparison(node: TComparisonNode, context?: GqlBuildContext): TGqlFragment {
const scope = this.scopeOf(context)
const resolved = scope.resolve(node.path)
return {
directives: [Gql.directive('filter', [
Gql.arg('field', Gql.string(scope.fieldNames(resolved).join('.'))),
Gql.arg('op', Gql.enumValue(node.operator.toUpperCase())),
Gql.arg('value', scope.value({ value: node.value, attribute: resolved.attribute })),
])],
}
}
protected visitLogical(node: TLogicalNode, context?: GqlBuildContext): TGqlFragment {
if (node.operator !== 'and') {
throw new UnsupportedOperationError(`A field directive cannot express "${node.operator}"`)
}
return GqlFragments.merge(this.visitAll(node.nodes, this.scopeOf(context)))
}
protected visitRelation(node: TRelationFilterNode, context?: GqlBuildContext): TGqlFragment {
throw new UnsupportedOperationError(`A field directive cannot filter on "${node.path.join('.')}"`)
}
protected visitRaw(node: TRawFilterNode, context?: GqlBuildContext): TGqlFragment {
throw new UnsupportedOperationError('A field directive cannot carry a raw condition')
}
}2. The order compiler. One directive per ordering:
export class DirectiveOrderCompiler implements IOrderCompiler {
public capabilities(): TCapabilitySlice {
return { ordering: { multiple: true, nulls: false, byPath: false } }
}
public compile(order: TOrderNode[], scope: GqlBuildContext): TGqlFragment {
return {
directives: order.map(p => Gql.directive('orderBy', [
Gql.arg('field', Gql.string(scope.fieldNames(scope.resolve(p.path)).join('.'))),
Gql.arg('direction', Gql.enumValue(p.direction.toUpperCase())),
])),
}
}
}3. The argument strategy — the class that decides where things go. This one puts everything on the field as directives, paging included:
export class DirectivePlacement implements IArgumentStrategy {
public applyQuery(field: GqlField, fragments: TGqlFragments, scope: GqlBuildContext): void {
field.addDirectives(GqlFragments.directives(fragments.filter))
field.addDirectives(GqlFragments.directives(fragments.order))
const paging = GqlFragments.args(fragments.paging)
if (paging.length > 0) {
field.addDirective(Gql.directive('paginate').addArgs(...paging))
}
}
public applyPrimaryKey(field: GqlField, key: TGqlPrimaryKey, scope: GqlBuildContext): void {
field.addDirective(Gql.directive('byKey', [Gql.arg(key.name, key.value)]))
}
public applyPayload(field: GqlField, payload: TGqlPayload, scope: GqlBuildContext): void {
if (payload.value) {
field.addArg('input', payload.value)
}
}
}Wire them in through the options — that is the whole integration:
@RepoEntitySet(() => Account, () => GqlEntityQuery, {
operations: new StaticOperationNaming({ list: 'accounts', one: 'accounts_by_pk' }),
filters: new DirectiveFilterCompiler(),
orders: new DirectiveOrderCompiler(),
args: new DirectivePlacement(),
})
public accounts!: GqlEntityQuery<Account>Application code does not change at all:
await context.accounts
.select(s => s.only('id', 'email'))
.where(f => f.eq('status', 'active'))
.orderBy('createdAt', 'desc')
.getAll()And because DirectiveFilterCompiler declares that it cannot do or, .orWhere(…) now fails with
QueryValidationError before a request leaves the process — the capability set followed the strategy.
The same shape produces a Relay connection: a paging compiler that emits first / after, a selection compiler that
wraps the fields in edges { node { … } } pageInfo { … }, a response adapter that unwraps edges.node back into
entities and fills TPage.cursor — plus pagingKind: 'cursor', so a plain .take(10) in application code already
arrives as a cursor window and the paging compiler never sees an offset it would have to translate:
@RepoEntitySet(() => Account, () => GqlEntityQuery, {
operations: new StaticOperationNaming({ list: 'accounts' }),
pagingKind: 'cursor',
paging: new RelayPagingCompiler(), // capabilities(): { paging: ['cursor'] }
selection: new RelaySelectionCompiler(),
response: new RelayResponseAdapter(),
})
public accounts!: GqlEntityQuery<Account>const page = await context.accounts.select(s => s.only('id', 'email')).take(10).after(cursor).getPage()
// query accounts($after: String) { accounts(first: 10, after: $after) {
// edges { node { id email } } pageInfo { hasNextPage endCursor } } }
// page.cursor → { end: 'c2', hasNext: true }Directives on the leaves, a wrapper around the page
Some schemas go one step further: the conditions are directives on the selected field itself, and paging wraps the selection instead of arguing with it — the same root field answers with a plain list when nothing is paged.
query namespace($page: Int, $limit: Int, $filter_name: [FilterInputTypeDefinition], $order_createdAt: OrderByDirectionDefinition) {
namespace(page: $page, limit: $limit) {
list {
id
name @filters(items: $filter_name)
createdAt @orderBy(direction: $order_createdAt, index: 0)
}
paginationInfo { total limit page }
}
}Nothing here needs an argument strategy of its own. The filter and the order compiler return field parts and the selection
compiler puts them where they belong; the paging compiler returns plain arguments, which the shipped ArgumentPlacement puts on the
root field:
public compile(order: TOrderNode[], scope: GqlBuildContext): TGqlFragment {
return {
fields: order.map((node, index) => {
const path = scope.fieldNames(scope.resolve(node.path))
return {
path,
directives: [Gql.directive('orderBy', [
Gql.arg('direction', Gql.variable(OrderDirection, node.direction.toUpperCase(), `order_${path.join('_')}`)),
Gql.arg('index', Gql.int(index)),
])],
}
}),
}
}The wrapper is the selection compiler's business, and it appears only when the AST carries a page — everything else delegates to the
shipped FieldSelectionCompiler, which is what places the directives of the two compilers above:
export class PaginatedSelectionCompiler implements ISelectionCompiler {
private readonly nodes = new FieldSelectionCompiler()
public capabilities(): TCapabilitySlice {
return this.nodes.capabilities()
}
public compile(selection: TResolvedSelection[], scope: GqlBuildContext, context?: TGqlSelectionContext): GqlField[] {
const fields = this.nodes.compile(selection, scope, context)
if (!context || context.target !== 'root' || !context.ast.paging) {
return fields
}
return [
new GqlField('list').addFields(fields),
new GqlField('paginationInfo').addFields(Gql.fields(['total', 'limit', 'page'])),
]
}
}A response adapter reads list and paginationInfo.total back — and falls through to the plain array when the query asked for no
page. The whole integration is five strategies and pagingKind:
@RepoEntitySet(() => Namespace, () => GqlEntityQuery, {
operations: new StaticOperationNaming({ list: 'namespace', one: 'namespaceByPk' }),
pagingKind: 'page', // a plain .take(20) becomes { kind: 'page', number: 1, size: 20 }
filters: new LeafFilterCompiler(), // → fragment.fields, one @filters per condition
orders: new LeafOrderCompiler(), // → fragment.fields, one @orderBy per ordering
paging: new PagePagingCompiler(), // → fragment.args, page & limit
selection: new PaginatedSelectionCompiler(),
response: new PaginatedResponseAdapter(),
})
public namespaces!: GqlEntityQuery<Namespace>const page = await context.namespaces
.select(s => s.only('id', 'name', 'createdAt'))
.where(f => f.eq('name', 'test'))
.orderBy('createdAt', 'desc')
.page(1, 20)
.getPage()
// page.total → 42All three examples live in test/integration in full.
Values, variables and types
By default every value is printed inline. Switch strategies and every value becomes a real GraphQL variable:
variables: new VariableValueStrategy()query accounts($status: String, $limit: Int) {
accounts(where: { status: { eq: $status } }, limit: $limit) { id email }
}{ status: 'active', limit: 20 }Two queries that differ only in values now print byte-identical documents, which is what makes persisted queries and
APQ caching work. The type of each variable is resolved by an IGqlTypeResolver; the shipped AttributeTypeResolver
looks, in order, at the type the caller passed, the types named in its options, gqlType on the field, and finally what
it can infer from the JavaScript value:
variables: new VariableValueStrategy(new AttributeTypeResolver({
types: { insert: 'accounts_insert_input!', update: 'accounts_set_input!' },
}))Before any of that, the value passes through the ValueSerializer of the compile context — dates become ISO strings,
entities become their primary key. Subclass it for a custom scalar and hand it over as the values option; every
strategy downstream sees the serialised form:
values: new MoneySerializer()Field metadata
This package extends EntityAttribute from the core through declaration merging. Import the package once (anywhere)
and the two fields below are available in @RepoEntityField:
declare module '@iappx/entity-repo' {
interface EntityAttribute {
gqlName?: string
gqlType?: string
}
}| Field option | Meaning |
| --- | --- |
| gqlType | The GraphQL type of the field, in GraphQL syntax: 'uuid!', '[String!]!', 'timestamptz'. Parsed by GqlTypeParser. Used whenever a value of this field becomes a variable. |
| gqlName | The name of the field in the GraphQL schema, when it differs from the source name the naming strategy produces. |
Escape hatches
No strategy set covers every schema, so every level has a way out:
| Level | Way out |
| --- | --- |
| Condition | rawFilter(payload, dialect?) — the payload is passed to the filter compiler as is. A payload aimed at another dialect is dropped. |
| Query | withMeta({ gqlDirectives, gqlArgs, gqlAlias, gqlOperationName }) — directives, arguments, an alias on the root field, or a name for the operation. |
| Field | select(s => s.withFieldMeta('email', { gqlDirectives: [Gql.directive('include', …)] })) |
| Relation | include('books', q => q.where(…).take(3).withMeta(…)) — the nested query goes through the same compilers and the same argument strategy. |
| Request | compile() gives you the TGqlRequest without sending it. |
The meta keys are constants — use GqlMetaKeys.directives rather than the literal string.
await context.accounts
.withMeta({ [GqlMetaKeys.directives]: [Gql.directive('cached', [Gql.arg('ttl', Gql.int(60))])] })
.getAll()
// query accounts { accounts @cached(ttl: 60) { … } }Transports
TGqlRequest is the neutral request. It carries both the printed document and the builder's operation object, so a
client that prefers one over the other is equally well served:
type TGqlRequest = {
document: string // the printed document
variables: Record<string, unknown>
operationName?: string
operation: GqlOperation // the @iappx/gql-builder document, still a tree
kind: TGqlOperationKind // list · one · aggregate · insert · update · delete · subscribe
rootField: string // the key the response adapter reads
meta?: TQueryMeta
}Bundled: FetchGqlTransport implements ITransport<TGqlRequest> posts the document, unwraps data, and throws
GqlRequestError carrying every entry of errors. It talks through an IHttpClient, so a custom one (retries, auth
refresh, a different HTTP library) is one small class.
Your own client: implement ITransport<TGqlRequest> — three lines with Apollo:
export class ApolloGqlTransport implements ITransport<TGqlRequest> {
public async send<TRes>(request: TGqlRequest): Promise<TRes> {
const result = await this.client.query({ query: gql(request.document), variables: request.variables })
return result.data as TRes
}
}Subscriptions go through the core's IStreamTransport<TGqlRequest>: subscribe(request) returns an
AsyncIterable of responses, and watch() turns each message into entities. The socket implementation is yours —
graphql-ws, SSE, or anything that yields responses.
Errors
| Error | Raised when |
| --- | --- |
| QueryValidationError | The composed capabilities cannot express the query; carries every issue at once. (from the query package) |
| UnsupportedOperationError | A compiler hits a node it cannot express. (from the query package) |
| GqlCompileError | The dialect is not configured for what was asked: no operation name, no primary key, an empty selection, an undeterminable variable type, a transport that cannot send or stream. |
| GqlRequestError | The endpoint answered with a non-2xx status, a body that is not JSON, or a GraphQL errors array. |
| GqlResponseError | The response adapter did not find what it expected under the root field. |
License
MIT — see LICENSE.
