Grafast plugin
Build fields with Grafast plans instead of resolvers, and resolve interfaces and unions with planType.
Grafast is a planning-based GraphQL executor: instead of a resolver per field, you write a plan that Grafast optimizes across the whole operation before running it. This plugin swaps the Pothos field builder from resolve to plan, so every field you define expects a plan. You still define objects, interfaces, and unions the same way, with builder.objectRef and friends; only the field bodies change.
Experimental package
This package is experimental and will have breaking changes in the near future.
This plugin does not work with most other Pothos plugins. Many plugins add runtime behavior by wrapping resolvers, and a plan-based field has no resolver to wrap.
Install
npm install --save @pothos/plugin-grafast grafast@>=0.1.1-beta.24Setup
Two things distinguish a Grafast build. InferredFieldOptionsKind: 'Grafast' tells the builder to expect plan on fields instead of resolve, and Grafast reads its context from a global Grafast.Context namespace, so you declare your context type in both places.
import SchemaBuilder from '@pothos/core';
import GrafastPlugin from '@pothos/plugin-grafast';
interface LeagueContext {
// request-scoped values your plans read from
}
declare global {
namespace Grafast {
interface Context extends LeagueContext {}
}
}
const builder = new SchemaBuilder<{
// Expect plans instead of resolvers.
InferredFieldOptionsKind: 'Grafast';
Context: LeagueContext;
}>({
plugins: [GrafastPlugin],
});Adding plans to fields
A field's plan receives step versions of its arguments (each prefixed with $) and returns a step. Combine steps with helpers like lambda to compute a value. See the Grafast documentation for the full catalog of steps.
import { lambda } from 'grafast';
builder.queryType({
fields: (t) => ({
pointDifferential: t.int({
args: {
scored: t.arg.int({ required: true }),
conceded: t.arg.int({ required: true }),
},
plan: (_, { $scored, $conceded }) =>
lambda([$scored, $conceded], ([scored, conceded]) => scored - conceded),
}),
}),
});Using resolvers
You can still write a plain resolve, but the plan-based executor does not pass the fourth GraphQLResolveInfo argument, so a resolver here sees only parent, args, and context.
builder.queryType({
fields: (t) => ({
pointDifferential: t.int({
args: {
scored: t.arg.int({ required: true }),
conceded: t.arg.int({ required: true }),
},
resolve: (_, { scored, conceded }) => scored - conceded,
}),
}),
});Don't use a resolver to load data; that defeats the point of planning. Use one only when it reads more clearly than a field that would otherwise be a one-line lambda plan.
Abstract types
Interfaces and unions usually need a plan to resolve an incoming record to its concrete type. You attach that plan with .withPlan on the ref, whose planType returns a $__typename step naming the concrete member. For the deeper model behind these plans, see the Grafast polymorphism docs.
Interfaces
Declare the interface, attach a plan that reads the type name off the record, then implement it. Object types join the interface the usual way through interfaces.
import { get, loadOne } from 'grafast';
interface MemberData {
id: string;
kind: 'Player' | 'Coach';
}
// The plan resolves each record to its concrete type via the `kind` field.
const Member = builder.interfaceRef<MemberData>('Member').withPlan({
planType: ($record) => ({
$__typename: get($record, 'kind'),
}),
});
Member.implement({
fields: (t) => ({
id: t.exposeID('id'),
}),
});
export const Player = builder.objectRef<MemberData>('Player').implement({
interfaces: [Member],
});
export const Coach = builder.objectRef<MemberData>('Coach').implement({
interfaces: [Member],
});A field returning the interface loads the record; the interface's own plan takes it from there:
const members = [
{ id: '1', kind: 'Player' },
{ id: '2', kind: 'Coach' },
] satisfies MemberData[];
function getMembersById(ids: readonly string[]): (MemberData | null)[] {
return ids.map((id) => members.find((m) => m.id === id) ?? null);
}
builder.queryFields((t) => ({
member: t.field({
type: Member,
args: {
id: t.arg.string({ required: true }),
},
plan: (_, $args) => loadOne($args.$id, getMembersById),
}),
}));Unions
A union works the same way: build it with builder.unionType, then attach the resolving plan with .withPlan:
interface SponsorData {
id: string;
kind: 'Sponsor';
}
export const Sponsor = builder.objectRef<SponsorData>('Sponsor').implement({
fields: (t) => ({
id: t.exposeID('id'),
}),
});
export const Entity = builder
.unionType('Entity', {
types: [Player, Coach, Sponsor],
})
.withPlan({
planType: ($record) => ({
$__typename: get($record, 'kind'),
}),
});Loading records with planForType
If the field that returns an abstract type has only an id to work with, let the type's plan do the loading. Give planType an explicit specifier step, load the record inside the plan, and return a planForType that hands each concrete type its data. Fields returning the type then only need to produce the id.
planForType is not fully type-safe; it will accept plans that resolve to data for the wrong type. This API is likely to change.
import { get, inhibitOnNull, loadOne, type Step } from 'grafast';
type EntityData = MemberData | SponsorData;
const entities = [
{ id: '1', kind: 'Player' },
{ id: '2', kind: 'Coach' },
{ id: '3', kind: 'Sponsor' },
] satisfies EntityData[];
function getEntitiesById(ids: readonly string[]): (EntityData | null)[] {
return ids.map((id) => entities.find((e) => e.id === id) ?? null);
}
export const Entity = builder
.unionType('Entity', {
types: [Player, Coach, Sponsor],
})
.withPlan({
planType: (
// An explicit specifier type lets query fields return just the id.
$specifier: Step<string>,
) => {
const $record = inhibitOnNull(loadOne($specifier, getEntitiesById));
return {
$__typename: get($record, 'kind'),
planForType: () => $record,
};
},
});
builder.queryFields((t) => ({
entity: t.field({
type: Entity,
args: {
id: t.arg.string({ required: true }),
},
// The Entity plan loads the record, so this returns only the id.
plan: (_, $args) => $args.$id,
}),
}));Plan reference
.withPlan is available on interface, union, and object refs. The plan object it takes accepts:
| Key | Where | Purpose |
|---|---|---|
planType | interface / union / object | Returns the plan for the type. On abstract types it returns { $__typename }, plus planForType, which is optional when the plan's specifier is the full record type but required when planType takes a narrowed specifier (an id, as in the planForType example above); on object types it returns the step Pothos hands to the type's fields. |
$__typename | returned from abstract planType | Step resolving to the concrete member's type name. |
planForType | returned from abstract planType | Loads the record for the resolved concrete type, so fields returning the abstract type can return only a specifier. |
toSpecifier | interface / union | Transforms the source step into the specifier step passed to planType. |
assertStep | object | Asserts the step passed to the object's planType, either a Step subclass or an assertion function. |