GraphQL

The GraphQL API generated from the models and the functions, its names, its pages and its limits.

@ultimabase/graphql serves a GraphQL API generated from the models and the functions, on the HTTP server. Each request runs as its caller, under the models' access rules. Every model and every function of the application is served; listing graphql() in a model's use lets its .with({ graphql }) set its name and its limits, or hide it:

npm install @ultimabase/graphql graphql
import { graphql } from '@ultimabase/graphql'
import { allow, defineModel } from 'ultimabase'

export const Post = defineModel({
	name: 'Post',
	use: [graphql()],
	fields: (f) => ({
		title: f.string(),
		slug: f.string({ unique: true }),
		status: f.enumOf(['draft', 'published'], { default: 'draft' }),
		likes: f.number({ integer: true, default: 0 }),
		author: f.one(() => Person),
		revisions: f.many(() => Revision),
	}),
	// A client asks for 20 posts at most in one list.
}).with({ access: allow.public, graphql: { maxTake: 20 } })

The full example, with its tests: examples/graphql.

graphqlPlugin() goes in the application's plugins, after httpPlugin(), which it requires:

plugins: [
	httpPlugin(),
	graphqlPlugin({
		// A document nests 6 levels at most; 10 by default.
		maxDepth: 6,
		// It returns 2 000 rows at most, each list times the lists above
		// it; 20 000 by default.
		maxRows: 2000,
		// Kept in production too, as the schema is public; off there by
		// default.
		introspection: true,
		extend,
	}),
],

The API is on /graphql. Outside production, a browser opening that URL gets GraphiQL, and the server logs GraphiQL on http://localhost:3000/graphql.. A client sends a document as JSON, with POST:

/** A GraphQL request's response body, as a client sends it. */
async function graphql(
	query: string,
	variables: object = {},
	headers: object = {},
): Promise<unknown> {
	const response = await fetch(`${running.url}/graphql`, {
		method: 'POST',
		headers: { 'content-type': 'application/json', ...headers },
		body: JSON.stringify({ query, variables }),
	})
	return response.json()
}

What a model gets

Post gives six root fields:

FieldKindReturns
post(where)queryPost, null when absent
posts(where, orderBy, first…)queryPostConnection!, a page
createPost(data)mutationPost!
updatePost(where, data)mutationPost!
upsertPost(where, create, update)mutationPost!
deletePost(where)mutationDeletedPost!, as it was

Each takes the arguments of its query, in GraphQL's grammar. A row reads its id, createdAt and updatedAt besides its fields; a relation to one row reads null when the caller may not read it. A write nests its relations' writes: connect, create and connectOrCreate, and in an update update, disconnect, set and delete. Here a person is created with three posts:

/** Ada and her three posts, created in one write. */
const seed = () =>
	graphql(`
		mutation {
			createPerson(
				data: {
					name: "Ada"
					posts: {
						create: [
							{ title: "Notes", slug: "notes", likes: 12, status: published }
							{ title: "Sketch", slug: "sketch", likes: 3 }
							{ title: "Plans", slug: "plans", likes: 7, status: published }
						]
					}
				}
			) {
				id
				name
				posts {
					edges {
						node {
							slug
						}
					}
				}
			}
		}
	`)
expect(seeded).toEqual({
	data: {
		createPerson: {
			id: anId,
			name: 'Ada',
			posts: {
				edges: [
					{ node: { slug: 'notes' } },
					{ node: { slug: 'sketch' } },
					{ node: { slug: 'plans' } },
				],
			},
		},
	},
})

Pages

A list is a Relay connection: first rows after a cursor, or last rows before one. totalCount counts every row the filter matches; pageInfo.endCursor is where the next page starts:

const page = `query ($after: String) {
	posts(first: 2, after: $after, orderBy: [{ likes: desc }]) {
		totalCount
		edges { cursor node { title likes } }
		pageInfo { hasNextPage endCursor }
	}
}`
const first = await graphql(page)
expect(at(first, 'data', 'posts')).toEqual({
	totalCount: 3,
	edges: [
		{ cursor: aCursor, node: { title: 'Notes', likes: 12 } },
		{ cursor: aCursor, node: { title: 'Plans', likes: 7 } },
	],
	pageInfo: { hasNextPage: true, endCursor: aCursor },
})
const after = at(first, 'data', 'posts', 'pageInfo', 'endCursor')
const next = await graphql(page, { after })
expect(at(next, 'data', 'posts', 'edges')).toEqual([
	{ cursor: aCursor, node: { title: 'Sketch', likes: 3 } },
])

Without first, a list returns take rows, 100 by default; a client asks for maxTake at most, 100 by default, or the model's own: .with({ graphql: { maxTake: 20 } }) above. A relation's list is a connection too, with its own first, orderBy and where; only the root list has totalCount.

pagination: 'list' serves lists instead, with take and skip, at every depth:

plugins: [httpPlugin(), graphqlPlugin({ pagination: 'list' })],
await request(`mutation {
	createPerson(data: { name: "Ada", posts: { create: [
		{ title: "Notes", slug: "notes" }, { title: "Plans", slug: "plans" }
	] } }) { id }
}`)
const body = await request(`{
	posts(take: 1, skip: 1, orderBy: [{ title: asc }]) {
		title author { name posts(take: 5) { slug } }
	}
}`)
expect(body).toEqual({
	data: {
		posts: [
			{
				title: 'Plans',
				author: {
					name: 'Ada',
					posts: [{ slug: 'notes' }, { slug: 'plans' }],
				},
			},
		],
	},
})

Filters

where takes an input per field: equals, not, in and notIn; on numbers and dates lt, lte, gt and gte; on text contains, startsWith and endsWith, with mode: insensitive to ignore case. A relation to one row filters with is and isNot, a relation to many with some, every and none; AND, OR and NOT combine filters:

const body = await graphql(`
	{
		posts(
			where: {
				status: { equals: published }
				likes: { gte: 10 }
				author: { is: { name: { startsWith: "A" } } }
			}
		) {
			edges {
				node {
					title
				}
			}
		}
		people(where: { posts: { some: { likes: { lt: 5 } } } }) {
			edges {
				node {
					name
				}
			}
		}
	}
`)
expect(body).toEqual({
	data: {
		posts: { edges: [{ node: { title: 'Notes' } }] },
		people: { edges: [{ node: { name: 'Ada' } }] },
	},
})

One row

post(where) takes the id, a unique field, or a unique index, as { name_releasedOn: { name, releasedOn } }, and returns null when no row matches, or when the caller may not read it:

const body = await graphql(`
	{
		notes: post(where: { slug: "notes" }) {
			title
			author {
				name
			}
		}
		missing: post(where: { slug: "nowhere" }) {
			title
		}
	}
`)
expect(body).toEqual({
	data: {
		notes: { title: 'Notes', author: { name: 'Ada' } },
		missing: null,
	},
})

A query may also come over GET, as ?query=…&variables=…; a mutation comes only over POST:

const query = encodeURIComponent('{ post(where: { slug: "notes" }) { title } }')
const response = await fetch(`${running.url}/graphql?query=${query}`)
expect(await response.json()).toEqual({
	data: { post: { title: 'Notes' } },
})

Writes

updatePost and deletePost find their row as post does; a row they do not find is NOT_FOUND:

const body = await graphql(`
	mutation {
		updatePost(
			where: { slug: "sketch" }
			data: { likes: 4, status: published }
		) {
			title
			likes
			status
		}
	}
`)
expect(body).toEqual({
	data: { updatePost: { title: 'Sketch', likes: 4, status: 'published' } },
})

upsertPost updates the row its where finds, or creates it:

const upsert = `mutation ($ada: ID!) {
	upsertPost(
		where: { slug: "ideas" }
		create: { title: "Ideas", slug: "ideas", author: { connect: { id: $ada } } }
		update: { likes: 1 }
	) { title likes }
}`
expect(await graphql(upsert, { ada })).toEqual({
	data: { upsertPost: { title: 'Ideas', likes: 0 } },
})
expect(await graphql(upsert, { ada })).toEqual({
	data: { upsertPost: { title: 'Ideas', likes: 1 } },
})

deletePost answers the row as it was, without its relations:

const body = await graphql(`
	mutation {
		deletePost(where: { slug: "plans" }) {
			id
			title
		}
	}
`)
expect(body).toEqual({
	data: { deletePost: { id: anId, title: 'Plans' } },
})

Each root field of a mutation is its own write: a document with two mutations runs them in order, and the first stays when the second fails. Use a function with a transaction to write both or neither.

Errors

A refused request answers its error in errors, with its code and details under extensions, as Errors lists them; the field is null:

const body = await graphql(
	`
		mutation ($ada: ID!) {
			createPost(
				data: {
					title: "Again"
					slug: "notes"
					author: { connect: { id: $ada } }
				}
			) {
				id
			}
		}
	`,
	{ ada },
)
expect(body).toEqual({
	data: null,
	errors: [
		{
			message: 'Unique constraint failed on Post (slug).',
			locations: [{ line: 3, column: 6 }],
			path: ['createPost'],
			extensions: {
				code: 'UNIQUE_VIOLATION',
				details: { model: 'Post', fields: ['slug'] },
			},
		},
	],
})

A document the schema refuses, as an unknown field, has no extensions: GraphQL's own message says what is wrong. A message never repeats the value it refused, so a password never comes back. A server error is Internal error., with INTERNAL and a correlationId the server logs beside the error.

Names

A model's list field is its name in the plural: Post gives posts, Category gives categories. .with({ graphql: { plural } }) names it otherwise, as people for Person:

export const Person = defineModel({
	name: 'Person',
	use: [graphql()],
	fields: (f) => ({ name: f.string(), posts: f.many(() => Post) }),
	// `persons` otherwise: the list field is `people`.
}).with({ access: allow.public, graphql: { plural: 'people' } })

.with({ graphql: false }) serves no root field: the model is read through the relations of the models that are served. A write of those connects, disconnects or sets its rows, and never creates or updates one:

export const Revision = defineModel({
	name: 'Revision',
	use: [graphql()],
	fields: (f) => ({ text: f.string(), post: f.one(() => Post) }),
	// No root field: a client reads revisions through their post.
}).with({ access: allow.readOnly, graphql: false })

A function is a mutation, or a query with query: true, named after the function; .with({ graphql: { name } }) renames it, and .with({ graphql: false }) keeps it off GraphQL:

export const topPosts = defineFunction({
	name: 'topPosts',
	use: [graphql()],
	query: true,
	input: z.object({ count: z.int().min(1).max(10) }),
	output: [Post],
	run: ({ input, caller }) =>
		Post.findMany(
			{ orderBy: { likes: 'desc' }, take: input.count },
			{ as: caller },
		),
	// Served as the query field `trending`.
}).with({ access: allow.public, graphql: { name: 'trending' } })

export const reindex = defineFunction({
	name: 'reindex',
	use: [graphql()],
	run: () => {},
	// Server code calls it; GraphQL does not serve it.
}).with({ access: allow.public, graphql: false })
const body = await graphql(`
	{
		people {
			edges {
				node {
					name
				}
			}
		}
		trending(count: 1) {
			title
		}
	}
`)
expect(body).toEqual({
	data: {
		people: { edges: [{ node: { name: 'Ada' } }] },
		trending: [{ title: 'Notes' }],
	},
})
const notes = await Post.findUniqueOrThrow(
	{ where: { slug: 'notes' } },
	{ as: master },
)
await Revision.create(
	{ data: { text: 'First draft', post: { connect: { id: notes.id } } } },
	{ as: master },
)
const body = await graphql(`
	{
		post(where: { slug: "notes" }) {
			revisions {
				edges {
					node {
						text
					}
				}
			}
		}
	}
`)
expect(body).toEqual({
	data: {
		post: { revisions: { edges: [{ node: { text: 'First draft' } }] } },
	},
})
const root = await graphql('{ revisions { totalCount } }')
expect(at(root, 'errors', '0', 'message')).toBe(
	'Cannot query field "revisions" on type "Query".',
)
const hidden = await graphql('mutation { reindex }')
expect(at(hidden, 'errors', '0', 'message')).toBe(
	'Cannot query field "reindex" on type "Mutation".',
)

Add fields with extend

extend takes the generated schema and returns the one to serve, built with graphql-js. A field it adds gets the request's GraphqlContext: context.caller to act as the caller, and context.select(info), the selection the client sent, relations included, to read every row the field returns in one query:

import type { GraphQLFieldConfigMap } from 'graphql'
import {
	assertObjectType,
	GraphQLList,
	GraphQLNonNull,
	GraphQLObjectType,
	GraphQLSchema,
	GraphQLString,
} from 'graphql'
import type { GraphqlContext } from '@ultimabase/graphql'
import { Post } from './models.ts'

export function extend(schema: GraphQLSchema): GraphQLSchema {
	const post = assertObjectType(schema.getType('Post'))
	const fields: GraphQLFieldConfigMap<unknown, GraphqlContext> = {
		me: {
			type: new GraphQLNonNull(GraphQLString),
			resolve: (_, __, context) => context.caller.kind,
		},
		drafts: {
			type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(post))),
			// `context.select(info)` reads the fields the client asked for,
			// relations included, in the same query.
			resolve: (_, __, context, info) =>
				Post.findMany(
					{ where: { status: 'draft' }, select: context.select(info) },
					{ as: context.caller },
				),
		},
	}
	const config = schema.toConfig()
	return new GraphQLSchema({
		...config,
		query: new GraphQLObjectType({
			name: 'Query',
			fields: { ...schema.getQueryType()?.toConfig().fields, ...fields },
		}),
		types: config.types.filter((type) => type.name !== 'Query'),
	})
}
const body = await graphql('{ me drafts { title author { name } } }')
expect(body).toEqual({
	data: {
		me: 'anonymous',
		drafts: [{ title: 'Sketch', author: { name: 'Ada' } }],
	},
})

Prefer a function with query: true for a new query: it is typed from its schema, served over REST too, and checked by its access rule. extend is for what a function cannot express, as a field on a model's type.

Limits

A document from a caller other than Master is refused before it runs when it nests deeper than maxDepth, or may return more than maxRows rows, each list counting its first times the lists above it. A list asking for more than its maxTake is refused as it runs:

const message = async (query: string) =>
	at(await graphql(query), 'errors', '0', 'message')
// The model's maxTake.
expect(await message('{ posts(first: 21) { totalCount } }')).toBe(
	'first is at most 20 on Post.',
)
// maxDepth: 8 levels, the leaf `name` included.
expect(
	await message(
		'{ people { edges { node { posts { edges { node { author { name } } } } } } } }',
	),
).toBe('The document is nested 8 levels deep; the most is 6.')
// maxRows: 100 people and 20 posts of each.
const rows =
	'{ people(first: 100) { edges { node { posts(first: 20) { edges { cursor } } } } } }'
expect(await message(rows)).toBe(
	'The document asks for more than 2,000 rows. Ask for fewer with first or last.',
)
// Master is not limited.
const key = { 'x-ultimabase-master-key': 'dev-master-key' }
expect(at(await graphql(rows, {}, key), 'errors')).toBeUndefined()

Each document is also bounded for every caller, Master included: 2 000 tokens, and 1 000 pairs of fields sharing one name. The first is ten times the standard introspection query.

Reference

graphqlPlugin(options)

OptionTypeDefaultEffect
pathstring'/graphql'Where the API, GraphiQL and subscriptions are served.
pagination'connection' or 'list''connection'Relay connections, or lists with take and skip.
takenumber100The rows a list returns without first or take.
maxTakenumber100The most rows a list returns, unless the model sets its own.
maxDepthnumber10The deepest a document nests, its fields and its orderBy counted.
maxRowsnumber20 000The most rows a document may return, each list times those above it.
introspectionbooleanoff in productionIntrospection, and the "Did you mean" suggestions of refused fields.
extend(schema) => schemanoneThe schema to serve, built from the generated one.

It requires httpPlugin(). With realtimePlugin(), it serves subscriptions over WebSocket on path. Master is limited by neither maxTake, maxDepth, maxRows, nor the 20 root fields.

.with({ graphql })

It needs graphql() in the model's or the function's use; without it, the model or the function is served with the defaults.

OnValueEffect
a model{ plural }The list field's name.
a model{ maxTake }The most rows its lists return.
a modelfalseNo root field; read through relations, and connected, never created.
a function{ name }The field's name.
a functionfalseNot served over GraphQL.

Types

FieldGraphQL type
f.string()String
f.number()Float; Int with integer: true
f.boolean()Boolean
f.dateTime()DateTime, an ISO 8601 instant
f.date()Date, a day, as '2026-07-31'
f.decimal()Decimal, a string, as '12.50'
f.json()JSON; a typed JSON field gets its own types
f.enumOf([...])an enum, as PostStatus
list: truea list, as [String!]!
the idID!
f.one(), f.many()the row's type, or its connection
f.virtual(output)its output's type; its input's properties are arguments

A required field is non-null in its output, unless a field rule may hide it, and in createPost's data unless it has a default. A row's types are Post, PostConnection, PostEdge, PostWhereInput, PostWhereUniqueInput, PostOrderByInput, PostCreateInput, PostUpdateInput and DeletedPost; a nested write's inputs name their relation, as PostCreateWithoutAuthorInput. The number operations of queries, as increment, are for server code: a GraphQL write sets values.

Errors

CodeMessage
noneThe document is nested 8 levels deep; the most is 6.
noneThe document asks for more than 2,000 rows. Ask for fewer with first or last.
noneThe document asks for more than 20 root fields, a totalCount counted as one more. Send several documents.
noneThe document repeats fields under one name too often. Ask for each field once.
noneSyntax Error: Document contains more than 2000 tokens. Parsing aborted.
noneGraphQL introspection has been disabled, …, in production.
noneCannot perform mutations over GET, a 405.
BAD_USER_INPUTfirst is at most 20 on Post., first cannot be negative.
BAD_USER_INPUTafter is not a cursor this API returned.
BAD_USER_INPUTPage forward with first and after, or backward with last and before, not both.
any kernel codeas Errors lists them, with details.
INTERNALInternal error., with correlationId.

At start:

  • Person: its list field would be named person too; set .with({ graphql: { plural } }).
  • Posts: the root field posts is Post's; set .with({ graphql: { plural } }).
  • posts: the root field posts is Post's; set .with({ graphql: { name } })., for a function.

Limits

  • No node(id) query, no batched requests and no persisted queries.
  • totalCount on the root list only.
  • Each root field of a mutation is its own write, with no transaction around the document.
  • Subscriptions need realtimePlugin().

On this page