pothos-plugin-collections
v1.0.0
Published
Pothos plugin exposing t.collection (paginated child lists with count) and t.element (single-value per-parent lookups), both with automatic per-request batching.
Maintainers
Readme
Collections plugin
Adds two Pothos field methods:
t.collection— paginated child list with acountcompanion.t.element— at-most-one row per parent (no wrapper, no paging).
Both batch sibling resolutions across one request, so a list of N parents costs one loadItems call, not N.
Install
npm install pothos-plugin-collectionsBatching is built on @pothos/plugin-dataloader, so make sure the peer dependencies are present:
npm install @pothos/core @pothos/plugin-dataloader graphqlSetup
Add CollectionsPlugin to your builder. It pulls in @pothos/plugin-dataloader for you, so you don't register that plugin yourself — just keep it installed (see above):
import SchemaBuilder from '@pothos/core';
import CollectionsPlugin from 'pothos-plugin-collections';
const builder = new SchemaBuilder({
plugins: [CollectionsPlugin],
});t.collection and t.element are then available on every field builder.
t.collection
builder.objectField(OrganizationType, 'users', (t) =>
t.collection({
name: 'OrganizationUsers',
type: UserType,
maxItems: 100,
sort: UserSort,
args: { emailContains: t.arg.string({ required: false }) },
parentKey: (organization) => organization.id,
itemKey: (user) => user.organizationId,
loadItems: async (parentKeys, { skip, take, sort, emailContains }, ctx) => {
// Return all matching rows for every parent key, in any order.
},
loadCount: async (parentKeys, { emailContains }, ctx) => {
// Return one row per parent: { ...sameFieldsAsItemKey, count }.
// Omit parents with no matches (they count as zero).
},
}),
);The wrapper type has items: [Type] and count: Int. User filters live on the outer field; paging lives on items:
organization {
users(emailContains: "@acme") { # user args
items(skip: 0, take: 10, sort: NAME_A_TO_Z) { id name }
count
}
}t.element
builder.objectField(UserType, 'organization', (t) =>
t.element({
type: OrganizationType,
nullable: false, // default; throws a clear error if a parent has no row
parentKey: (user) => user.organizationId,
itemKey: (organization) => organization.id,
load: async (keys, _args, ctx) => {
// Return at most one row per key, in any order.
},
}),
);Inputs
| Field | Shape | Notes |
| --- | --- | --- |
| parentKey | (parent) => Key | Same Key shape as itemKey. Use primitives, or arrays of primitives for composite keys — not class instances. The plugin throws at first load if shapes don't match. |
| itemKey | (row) => Key | Reads enough of row to identify its parent. Must never return null / undefined. loadCount rows must include these fields. |
| loadItems | (parentKeys, args, ctx) => Row[] | Flat list, any order. Honor skip/take per parent, not across the union. |
| loadCount | (parentKeys, args, ctx) => { ...itemKeyFields, count }[] | One row per parent that has matches. |
| load (element) | (parentKeys, args, ctx) => Row[] | Up to one row per key. |
| args | Pothos arg map | Filters; passed to loadItems / loadCount / load. |
| maxItems | number | Enables skip/take on items; take is required and must be 1..maxItems. |
| sort | string[] or string-valued object | Enables an optional sort arg on items. |
| sortName | string | Optional override for the generated sort-enum type name (default `${name}Sort`). |
| nullable (element) | boolean | Defaults to false. When false, a missing row yields a clear plugin error per key instead of a generic GraphQL "Cannot return null". |
Errors from loadItems / loadCount / load are isolated per-group: if one user-args group fails, only its parents resolve to the error — sibling groups still resolve normally.
Sort
Define the allowed orderings as a string array or a string enum, and pass it as sort (this is the UserSort from the t.collection example above):
const UserSort = ['NEWEST_FIRST', 'NAME_A_TO_Z'] as const;
// or a string enum:
// enum UserSort { NEWEST_FIRST = 'NEWEST_FIRST', NAME_A_TO_Z = 'NAME_A_TO_Z' }- The
sortarg is always optional in the schema — adding new values is non-breaking. When omitted, the resolver receivesundefined; return a stable default. - Each enum value is a complete ordering (
NEWEST_FIRST,NAME_A_TO_Z), not a field plus direction. Keeps invalid combinations out of the schema and maps directly to UI labels. - Enum names and values pass through verbatim.
