Relay plugin

Add globally identifiable nodes, cursor pagination, and Relay mutation fields.

The Relay plugin adds globally identifiable nodes, cursor-based connections, and mutation helpers. Use nodes when clients need to refetch an object by ID, and connections when clients need to page through a collection. A connection can return ordinary object types; its items do not have to implement the Node interface.

Usage

Install

npm install --save @pothos/plugin-relay

Setup

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';
const builder = new SchemaBuilder({
  plugins: [RelayPlugin],
  relay: {},
});

Options

The relay options object passed to builder can contain the following properties:

  • idFieldName: The name of the field that contains the global id for the node. Defaults to id.
  • idFieldOptions: Options to pass to the id field.
  • clientMutationId: omit (default) | required | optional. Determines if clientMutationId fields are created on relayMutationFields, and if they are required.
  • relayMutationFieldOptions: Default options for the relayMutationField method.
  • cursorType: String | ID. Determines type used for cursor fields. Defaults to String
  • nodeQueryOptions: Options for the node field on the query object, set to false to omit the field
  • nodesQueryOptions: Options for the nodes field on the query object, set to false to omit the field
  • nodeTypeOptions: Options for the Node interface type. Supports a name property to customize the type name.
  • pageInfoTypeOptions: Options for the PageInfo object type. Supports a name property to customize the type name.
  • clientMutationIdFieldOptions: Options for the clientMutationId field on mutation payloads
  • clientMutationIdInputOptions: Options for the clientMutationId field on mutation inputs
  • mutationInputArgOptions: Options for the input argument on Relay mutation fields
  • cursorFieldOptions: Options for the cursor field on an edge object.
  • nodeFieldOptions: Options for the node field on an edge object.
  • edgesFieldOptions: Options for the edges field on a connection object.
  • pageInfoFieldOptions: Options for the pageInfo field on a connection object.
  • hasNextPageFieldOptions: Options for the hasNextPage field on the PageInfo object.
  • hasPreviousPageFieldOptions: Options for the hasPreviousPage field on the PageInfo object.
  • startCursorFieldOptions: Options for the startCursor field on the PageInfo object.
  • endCursorFieldOptions: Options for the endCursor field on the PageInfo object.
  • beforeArgOptions: Options for the before arg on a connection field.
  • afterArgOptions: Options for the after arg on a connection field.
  • firstArgOptions: Options for the first arg on a connection field.
  • lastArgOptions: Options for the last arg on a connection field.
  • defaultConnectionTypeOptions: Default options for the Connection Object types.
  • defaultEdgeTypeOptions: Default options for the Edge Object types.
  • defaultPayloadTypeOptions: Default options for the Payload Object types.
  • defaultMutationInputTypeOptions: default options for the mutation Input types.
  • nodesOnConnection: If true, the nodes field will be added to the Connection object types.
  • defaultConnectionFieldOptions: Default options for connection fields defined with t.connection
  • brandLoadedObjects: Defaults to true. This will add a hidden symbol to objects returned from the load methods of Nodes that allows the default resolveType implementation to identify the type of the node. When this is enabled, you will not need to implement an isTypeOf check for most common patterns.

Customizing type names

If you have existing Node or PageInfo types in your schema that conflict with the ones generated by the relay plugin, you can customize the names using nodeTypeOptions and pageInfoTypeOptions:

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';
const builder = new SchemaBuilder({
  plugins: [RelayPlugin],
  relay: {
    nodeTypeOptions: {
      name: 'RelayNode',
      description: 'A node in the graph',
    },
    pageInfoTypeOptions: {
      name: 'RelayPageInfo',
      description: 'Pagination information',
    },
  },
});

Both options support all standard type options including name, description, and extensions.

Creating Nodes

Authorize direct node lookups

Defining a node adds direct lookups through node and nodes. Filters or permission checks on other root fields do not protect these lookups. Use Scope Auth to gate the lookup fields or apply a shared policy to the Node interface, and enforce entity-specific access where needed. See Relay node authorization for safer defaults and their limits.

To create objects that extend the Node interface, use builder.node. The id resolver supplies the local key, and loadOne uses that key to retrieve the record:

type UserShape = { id: string; name: string };
const users: UserShape[] = [
  { id: '1', name: 'Ada' },
  { id: '2', name: 'Grace' },
  { id: '3', name: 'Katherine' },
];
const User = builder.objectRef<UserShape>('User');
builder.node(User, {
  id: { resolve: (user) => user.id },
  loadOne: (id) => users.find((user) => user.id === id) ?? null,
  fields: (t) => ({ name: t.exposeString('name') }),
});
builder.queryType({});

builder.node will create an object type that implements the Node interface. It will also create the Node interface the first time it is used. The resolve function for id should return a number or string, which will be converted to a globalID. The relay plugin adds two new query fields node and nodes which can be used to directly fetch nodes using global IDs by calling the provided loadOne or loadMany method. Each node will only be loaded once by id, and cached if the same node is loaded multiple times in the same request. You can provide loadWithoutCache or loadManyWithoutCache instead if caching is not desired, or you are already using a caching datasource like a dataloader.

With the default encoding, Ada's local ID "1" becomes the global ID "VXNlcjox". A node(id: "VXNlcjox") query returns Ada with that same global ID, wherever she appears in the schema. Global IDs identify records; they do not grant access. The loader must still enforce the application's access rules when needed.

For an application that already uses classes, pass the class to builder.node and provide name: 'User'. The default class check uses instanceof or the constructor on the prototype. Use this as an alternative to the ref above, with a loader that returns class instances.

Choose one loading option: loadOne, loadMany, loadWithoutCache, or loadManyWithoutCache. Batch loaders must return one value per ID in the same order, including null for missing nodes. Use a fresh context object for every request to isolate the node cache.

By default (unless brandLoadedObjects is set to false) any nodes loaded through one of the load* methods will be branded so that the default resolveType method can identify the GraphQL type for the loaded object. This means isTypeOf is only required for union and interface fields that return node objects that are manually loaded, where the union or interface does not have a custom resolveType method that knows how to resolve the node type.

parsing node ids

By default all node ids are parsed as string. This behavior can be customized by providing a custom parse function for your node's ID field:

// Alternative User definition for a data source with numeric IDs.
type UserShape = { id: number; name: string };
const users: UserShape[] = [{ id: 1, name: 'Ada' }];
const User = builder.objectRef<UserShape>('User');

builder.node(User, {
  id: {
    resolve: (user) => user.id,
    parse: (id) => {
      const parsed = Number(id);
      if (!Number.isSafeInteger(parsed) || parsed < 1) {
        throw new Error('Invalid User ID');
      }
      return parsed;
    },
  },
  loadOne: (id) => users.find((user) => user.id === id) ?? null,
  fields: (t) => ({ name: t.exposeString('name') }),
});

Global IDs

To make it easier to create globally unique ids the relay plugin adds new methods for creating globalID fields.

builder.queryFields((t) => ({
  singleID: t.globalID({ resolve: () => ({ id: '1', type: User }) }),
  listOfIDs: t.globalIDList({
    resolve: () => users.map((user) => ({ id: user.id, type: User })),
  }),
}));

The returned IDs can either be a string (which is expected to already be a globalID), or an object with an id and a type. The type can be either the name of a type as a string, or any object that can be used in a type parameter.

There are also new methods for adding globalIDs in arguments or fields of input types:

builder.queryType({
  fields: (t) => ({
    fieldThatAcceptsGlobalID: t.boolean({
      args: {
        id: t.arg.globalID({
          required: true,
        }),
        idList: t.arg.globalIDList(),
      },
      resolve(parent, args) {
        console.log(`Get request for type ${args.id.typename} with id ${args.id.id}`);
        return true;
      },
    }),
  }),
});

globalIDs used in arguments expect the client to send a globalID string, but will automatically be converted to an object with 2 properties (id and typename) before they are passed to your resolver in the arguments object.

Limiting global ID args to specific types

globalID input's can be configured to validate the type of the globalID. This is useful if you only want to accept IDs for specific node types.

builder.queryField('userByGlobalID', (t) =>
  t.field({
    type: User,
    args: {
      id: t.arg.globalID({ for: User, required: true }),
    },
    resolve: (_parent, { id }) => users.find((user) => user.id === id.id) ?? null,
  }),
);

To accept several node types, pass an array of their refs to for. The plugin rejects IDs whose type is outside that list before calling the resolver.

Creating Connections

The t.connection field builder method can be used to define connections. This method will automatically create the Connection and Edge objects used by the connection, and add before, after, first, and last arguments. The first time this method is used, it will also create the PageInfo type.

The following field continues the User example. resolveArrayConnection slices the array and computes cursors and page information:

import { resolveArrayConnection } from '@pothos/plugin-relay';

builder.queryField('users', (t) =>
  t.connection({
    type: User,
    resolve: (_parent, args) => resolveArrayConnection({ args }, users),
  }),
);

const schema = builder.toSchema();

Query users(first: 2), then pass its pageInfo.endCursor to users(first: 2, after: ...). The first page contains Ada and Grace; the next contains Katherine. A node's id identifies the object across the schema, while an edge's cursor identifies a position in this connection. Pass a returned node id to the root node(id: ...) field to refetch the same user.

Treat connection cursors as opaque values. Forward pagination uses first with after; backward pagination uses last with before. The array helper rejects negative page sizes and returns an empty edge list when the requested page falls beyond the data.

Connections can also contain ordinary objects. This independent schema uses four users with local IDs: Ada, Grace, Katherine, and Dorothy. Its User type does not implement Node:

import SchemaBuilder from '@pothos/core';
import RelayPlugin, { resolveArrayConnection } from '@pothos/plugin-relay';

const builder = new SchemaBuilder({ plugins: [RelayPlugin], relay: {} });
const users = [
  { id: '1', name: 'Ada' },
  { id: '2', name: 'Grace' },
  { id: '3', name: 'Katherine' },
  { id: '4', name: 'Dorothy' },
];
const User = builder.objectRef<(typeof users)[number]>('User').implement({
  fields: (t) => ({ id: t.exposeID('id'), name: t.exposeString('name') }),
});


builder.queryType({
  fields: (t) => ({
    users: t.connection({
      type: User,
      resolve: (_parent, args) => resolveArrayConnection({ args }, users),
    }),
  }),
});

export const schema = builder.toSchema();

resolveArrayConnection paginates data already in memory. The offset and cursor helpers below let a data source fetch only the needed records from a larger collection.

The remaining connection examples are independent patterns using application types and data sources. For a custom connection resolver, return the following shape:

builder.queryFields((t) => ({
  numbers: t.connection(
    {
      type: NumberThing,
      resolve: (parent, { first, last, before, after }) => {
        return {
          pageInfo: {
            hasNextPage: false,
            hasPreviousPage: false,
            startCursor: 'abc',
            endCursor: 'def',
          },
          edges: [
            {
              cursor: 'abc',
              node: new NumberThing(123),
            },
            {
              cursor: 'def',
              node: new NumberThing(123),
            },
          ],
        };
      },
    },
    {
      name: 'NameOfConnectionType', // optional, will use ParentObject + capitalize(FieldName) + "Connection" as the default
      fields: (tc) => ({
        // define extra fields on Connection
        // We need to use a new variable for the connection field builder (eg tc) to get the correct types
      }),
      edgesField: {}, // optional, allows customizing the edges field on the Connection Object
      // Other options for connection object can be added here
    },
    {
      // Same as above, but for the Edge Object
      name: 'NameOfEdgeType', // optional, will use Connection name + "Edge" as the default
      fields: (te) => ({
        // define extra fields on Edge
        // We need to use a new variable for the connection field builder (eg te) to get the correct types
      }),
      nodeField: {}, // optional, allows customizing the node field on the Edge Object
    },
  ),
}));

Manually implementing connections can be cumbersome, so there are a couple of helper methods that can make resolving connections a little easier.

For a data source that accepts an offset and limit, use resolveOffsetConnection. Its callback requests one extra record to determine hasNextPage; honor the supplied limit and return records in a stable order:

import { resolveOffsetConnection } from '@pothos/plugin-relay';

builder.queryFields((t) => ({
  things: t.connection({
    type: SomeThing,
    resolve: (parent, args) => {
      return resolveOffsetConnection({ args }, ({ limit, offset }) => {
        return getThings(offset, limit);
      });
    },
  }),
}));

resolveOffsetConnection has a few default limits to prevent unintentionally allowing too many records to be fetched at once. These limits can be configured using the following options:

{
  args: ConnectionArguments;
  defaultSize?: number; // defaults to 20
  maxSize?: number; // defaults to 100
  totalCount?: number // required to support using `last` without `before`
}

For APIs where you have the full array available you can use resolveArrayConnection, which works just like resolveOffsetConnection and accepts the same options.

import { resolveArrayConnection } from '@pothos/plugin-relay';

builder.queryFields((t) => ({
  things: t.connection({
    type: SomeThing,
    resolve: (parent, args) => {
      return resolveArrayConnection({ args }, getAllTheThingsAsArray());
    },
  }),
}));

Cursor based pagination can be implemented using the resolveCursorConnection method. The following example uses Prisma and assumes createdAt is unique. If timestamps can repeat, use a unique cursor and matching ordering/filtering (for example, a timestamp and ID pair) so records are not skipped between pages. The callback must honor limit, including the extra record requested by the helper, and reverse the ordering when inverted is true.

import { resolveCursorConnection, ResolveCursorConnectionArgs } from '@pothos/plugin-relay';

builder.queryField('posts', (t) =>
  t.connection({
    type: Post,
    resolve: (_, args) =>
      resolveCursorConnection(
        {
          args,
          toCursor: (post) => post.createdAt.toISOString(),
        },
        // Manually defining the arg type here is required
        // so that typescript can correctly infer the return value
        ({ before, after, limit, inverted }: ResolveCursorConnectionArgs) =>
          prisma.post.findMany({
            take: limit,
            where: {
              createdAt: {
                lt: before,
                gt: after,
              },
            },
            orderBy: {
              createdAt: inverted ? 'desc' : 'asc',
            },
          }),
      ),
  }),
);

Relay Mutations

You can use the relayMutationField method to define relay compliant mutation fields. This method generates a mutation field, input object, and payload object. By default it omits clientMutationId; set relay.clientMutationId to required or optional to include matching input and payload fields.

Example usage:

builder.mutationType({});

const items = new Map([['1', { id: '1' }]]);

const deleteItem = builder.relayMutationField(
  'deleteItem',
  {
    inputFields: (t) => ({
      id: t.id({
        required: true,
      }),
    }),
  },
  {
    nullable: false, // You can optionally change the nullability of the mutation field here
    resolve: async (root, args, ctx) => {
      if (items.has(args.input.id)) {
        items.delete(args.input.id);

        return { success: true };
      }

      return { success: false };
    },
  },
  {
    outputFields: (t) => ({
      success: t.boolean({
        resolve: (result) => result.success,
      }),
    }),
  },
);

Which produces the following graphql types:

input DeleteItemInput {
  id: ID!
}

type DeleteItemPayload {
  success: Boolean
}

type Mutation {
  deleteItem(input: DeleteItemInput!): DeleteItemPayload!
}

The relayMutationField has 4 arguments:

  • name: Name of the mutation field
  • inputOptions: Options for the input object or a ref to an existing input object
  • fieldOptions: Options for the mutation field
  • payloadOptions: Options for the Payload object

The inputOptions has a couple of non-standard options:

  • name which can be used to set the name of the input object
  • argName which can be used to overwrite the default arguments name (input).

The payloadOptions object also accepts a name property for setting the name of the payload object.

You can also access refs for the created input and payload objects so you can re-use them in other fields:

const { inputType: DeleteItemInput, payloadType: DeleteItemPayload } = deleteItem;

Reusing connection objects

In some cases you may want to create a connection object type that is shared by multiple fields. To do this, you will need to create the connection object separately and then create a fields using a ref to your connection object:

import { resolveArrayConnection } from '@pothos/plugin-relay';

const UsersConnection = builder.connectionObject(
  { type: User, name: 'UsersConnection' },
  { name: 'UsersEdge' },
);

builder.queryFields((t) => ({
  // Normal fields need explicit connection arguments.
  usersAsField: t.field({
    type: UsersConnection,
    args: { ...t.arg.connectionArgs() },
    resolve: (_parent, args) => resolveArrayConnection({ args }, users),
  }),
  // Connection fields add the arguments automatically.
  usersAsConnection: t.connection(
    {
      type: User,
      resolve: (_parent, args) => resolveArrayConnection({ args }, users),
    },
    UsersConnection,
  ),
}));

builder.connectionObject creates the connect object type and the associated Edge type. t.arg.connectionArgs() will create the default connection args.

Reusing edge objects

This is an alternative to the connection definition above. Create an edge ref, then pass it to connection definitions that share its shape:

import { resolveArrayConnection } from '@pothos/plugin-relay';

const UsersEdge = builder.edgeObject({ name: 'UsersEdge', type: User });
const UsersConnection = builder.connectionObject(
  { type: User, name: 'UsersConnection' },
  UsersEdge,
);

builder.queryField('usersWithSharedEdge', (t) =>
  t.connection(
    {
      type: User,
      resolve: (_parent, args) => resolveArrayConnection({ args }, users),
    },
    { name: 'SharedEdgeUsersConnection' },
    UsersEdge,
  ),
);

builder.connectionObject creates the connect object type and the associated Edge type. t.arg.connectionArgs() will create the default connection args.

Expose nodes

The t.node and t.nodeList methods can be used to add additional node fields. the expected return values of id and ids fields is the same as the resolve value of t.globalID, and can either be a globalID or an object with an id and a type.

Loading nodes by id uses a request cache, so the same node will only be loaded once per request, even if it is used multiple times across the schema.

builder.queryFields((t) => ({
  extraNode: t.node({ id: () => ({ id: '1', type: User }) }),
  moreNodes: t.nodeList({
    ids: () => users.map((user) => ({ id: user.id, type: User })),
  }),
}));

decoding and encoding global ids

The relay plugin exports decodeGlobalID and encodeGlobalID as helper methods for interacting with global IDs directly. If you accept a global ID as an argument you can use the decodeGlobalID function to decode it:

import { decodeGlobalID, encodeGlobalID } from '@pothos/plugin-relay';

const adaID = encodeGlobalID('User', '1');

builder.mutationType({});

builder.mutationField('renameUser', (t) =>
  t.field({
    type: User,
    args: {
      id: t.arg.id({ required: true }),
      name: t.arg.string({ required: true }),
    },
    resolve: (_parent, { id: globalID, name }) => {
      const { typename, id } = decodeGlobalID(globalID);
      if (typename !== 'User') {
        throw new Error('Expected a User ID');
      }
      const user = users.find((candidate) => candidate.id === id);
      if (!user) {
        return null;
      }
      user.name = name;
      return user;
    },
  }),
);

Using custom encoding for global ids

In some cases you may want to encode global ids differently than the build in ID encoding. To do this, you can pass a custom encoding and decoding function into the relay options of the builder:

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';
const builder = new SchemaBuilder({
  plugins: [RelayPlugin],
  relay: {
    encodeGlobalID: (typename: string, id: string | number | bigint) => `${typename}:${id}`,
    decodeGlobalID: (globalID: string) => {
      const [typename, id] = globalID.split(':');

      return { typename, id };
    },
  },
});

Using custom resolve for node and or nodes field

If you need to customize how nodes are loaded for the node and or nodes fields you can provide custom resolve functions in the builder options for these fields. This alternative builder uses the users array above; register its User node after creating the builder. Manually loaded objects include __typename so the Node interface can resolve their type:

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';

function customUserLoader({ id }: { id: string }) {
  const user = users.find((user) => user.id === id);
  return user ? { ...user, __typename: 'User' } : null;
}

const builder = new SchemaBuilder({
  plugins: [RelayPlugin],
  relay: {
    nodeQueryOptions: {
      resolve: (root, { id }, context, info, resolveNode) => {
        // use custom loading for User nodes
        if (id.typename === 'User') {
          return customUserLoader(id);
        }

        // fallback to normal loading for everything else
        return resolveNode(id);
      },
    },
    nodesQueryOptions: {
      resolve: (root, { ids }, context, info, resolveNodes) => {
        return ids.map(async (id) => {
          if (id.typename === 'User') {
            return customUserLoader(id);
          }

          // it would be more efficient to load all the nodes at once
          // but it is important to ensure the resolver returns nodes in the right order
          // we are resolving nodes one at a time here for simplicity
          return (await resolveNodes([id]))[0];
        });
      },
    },
  },
});

Extending all connections

There are 2 builder methods for adding fields to all connection objects: builder.globalConnectionField and builder.globalConnectionFields. These methods work like many of the other methods on the builder for adding fields to objects or interfaces.

builder.globalConnectionField('totalCount', (t) =>
  t.int({
    nullable: false,
    resolve: (parent) => 123,
  }),
);
// Or
builder.globalConnectionFields((t) => ({
  totalCount: t.int({
    nullable: false,
    resolve: (parent) => 123,
  }),
}));

In the above example, we are just returning a static number for our totalCount field. To make this more useful, we need to have our resolvers for each connection actually return an object that contains a totalCount for us. To guarantee that resolvers correctly implement this behavior, we can define custom properties that must be returned from connection resolvers when we set up our builder:

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';
const builder = new SchemaBuilder<{
  Connection: {
    totalCount: number;
  };
}>({
  plugins: [RelayPlugin],
  relay: {},
});

Register the same User node and users array with this replacement builder. TypeScript will ensure that objects returned from each connection resolver include a totalCount property, which we can use in our connection fields:

builder.globalConnectionField('totalCount', (t) =>
  t.int({
    nullable: false,
    resolve: (parent) => parent.totalCount,
  }),
);

Note that adding additional required properties will make it harder to use the provided connection helpers since they will not automatically return your custom properties. You will need to manually add in any custom props after getting the result from the helpers:

import { resolveArrayConnection } from '@pothos/plugin-relay';

builder.queryField('users', (t) =>
  t.connection({
    type: User,
    resolve: (_parent, args) => ({
      ...resolveArrayConnection({ args }, users),
      totalCount: users.length,
    }),
  }),
);

Changing nullability of edges and nodes

If you want to change the nullability of the edges field on a Connection or the node field on an Edge you can configure this in 2 ways:

Globally

import SchemaBuilder from '@pothos/core';
import RelayPlugin from '@pothos/plugin-relay';
const builder = new SchemaBuilder<{
  DefaultEdgesNullability: false;
  DefaultNodeNullability: true;
}>({
  plugins: [RelayPlugin],
  relay: {
    edgesFieldOptions: {
      nullable: false,
    },
    nodeFieldOptions: {
      nullable: true,
    },
  },
});

The types provided for DefaultEdgesNullability and DefaultNodeNullability must match the values provided in the nullable option of edgesFieldOptions and nodeFieldOptions respectively. This will set the default nullability for all connections created by your builder.

nullability for edges fields defaults to { list: options.defaultFieldNullability, items: true } and the nullability of node fields is the same as options.defaultFieldNullability (which defaults to true).

Per connection

builder.queryFields((t) => ({
  things: t.connection({
    type: SomeThing,
    edgesNullable: {
      items: true,
      list: false,
    },
    nodeNullable: false,
    resolve: (parent, args) => {
      return resolveOffsetConnection({ args }, ({ limit, offset }) => {
        return getThings(offset, limit);
      });
    },
  }),
}));
// Or

const ThingsConnection = builder.connectionObject({
  type: SomeThing,
  name: 'ThingsConnection',
  edgesNullable: {
    items: true,
    list: false,
  },
  nodeNullable: false,
});

Extending the Node interface

Use relay.nodeTypeOptions to configure the interface itself, including authScopes when using Scope Auth. See Relay node authorization for how interface scopes apply to implementing types and how they differ from authorizing the root lookup fields.

Use the nodeInterfaceRef method of your Builder to reference the interface.

For example, to add a field on the interface:

builder.interfaceField(builder.nodeInterfaceRef(), 'extra', (t) =>
  t.string({
    resolve: () => 'it works',
  }),
);