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 graphqlimport { 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:
| Field | Kind | Returns |
|---|---|---|
post(where) | query | Post, null when absent |
posts(where, orderBy, first…) | query | PostConnection!, a page |
createPost(data) | mutation | Post! |
updatePost(where, data) | mutation | Post! |
upsertPost(where, create, update) | mutation | Post! |
deletePost(where) | mutation | DeletedPost!, 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)
| Option | Type | Default | Effect |
|---|---|---|---|
path | string | '/graphql' | Where the API, GraphiQL and subscriptions are served. |
pagination | 'connection' or 'list' | 'connection' | Relay connections, or lists with take and skip. |
take | number | 100 | The rows a list returns without first or take. |
maxTake | number | 100 | The most rows a list returns, unless the model sets its own. |
maxDepth | number | 10 | The deepest a document nests, its fields and its orderBy counted. |
maxRows | number | 20 000 | The most rows a document may return, each list times those above it. |
introspection | boolean | off in production | Introspection, and the "Did you mean" suggestions of refused fields. |
extend | (schema) => schema | none | The 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.
| On | Value | Effect |
|---|---|---|
| a model | { plural } | The list field's name. |
| a model | { maxTake } | The most rows its lists return. |
| a model | false | No root field; read through relations, and connected, never created. |
| a function | { name } | The field's name. |
| a function | false | Not served over GraphQL. |
Types
| Field | GraphQL 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: true | a list, as [String!]! |
| the id | ID! |
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
| Code | Message |
|---|---|
| none | The document is nested 8 levels deep; the most is 6. |
| none | The document asks for more than 2,000 rows. Ask for fewer with first or last. |
| none | The document asks for more than 20 root fields, a totalCount counted as one more. Send several documents. |
| none | The document repeats fields under one name too often. Ask for each field once. |
| none | Syntax Error: Document contains more than 2000 tokens. Parsing aborted. |
| none | GraphQL introspection has been disabled, …, in production. |
| none | Cannot perform mutations over GET, a 405. |
BAD_USER_INPUT | first is at most 20 on Post., first cannot be negative. |
BAD_USER_INPUT | after is not a cursor this API returned. |
BAD_USER_INPUT | Page forward with first and after, or backward with last and before, not both. |
| any kernel code | as Errors lists them, with details. |
INTERNAL | Internal 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. totalCounton the root list only.- Each root field of a mutation is its own write, with no transaction around the document.
- Subscriptions need
realtimePlugin().