Functions

Declare a server operation with a validated input, a typed output and an access rule, served by every transport.

A function is an operation the server runs: borrow a book, place an order, merge two accounts. defineFunction names it, validates its input with a Standard Schema (Zod, Valibot, ArkType…), types its output, and every transport serves it: GraphQL as a mutation, REST as POST /api/functions/borrowBook.

The examples write their schemas with Zod, a package of its own:

npm install zod
import { graphql } from '@ultimabase/graphql'
import {
	PublicError,
	allow,
	defineFunction,
	defineType,
	master,
} from 'ultimabase'
import type { Caller } from 'ultimabase'
import { z } from 'zod'
import { Book } from './models.ts'

// A named object: a model and data together.
export const Loan = defineType({
	name: 'Loan',
	fields: { book: Book, dueOn: z.iso.date() },
})

export const borrowBook = defineFunction({
	name: 'borrowBook',
	input: z.object({ book: z.string(), days: z.int().min(1).max(30) }),
	output: Loan,
	run: async ({ input, caller }) => {
		const book = await Book.findUniqueOrThrow(
			{ where: { id: input.book } },
			{ as: caller },
		)
		if (book.borrowedBy !== null) {
			throw new PublicError('BOOK_BORROWED', `${book.title} is out.`)
		}
		// Book is read-only for clients: the function writes as the server.
		const lent = await Book.update(
			{ where: { id: book.id }, data: { borrowedBy: userOf(caller) } },
			{ as: master },
		)
		const due = new Date(Date.now() + input.days * 86_400_000)
		return { book: lent, dueOn: due.toISOString().slice(0, 10) }
	},
}).with({ access: ({ caller }) => caller.kind === 'user' })

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

defineApp({ functions: [...] }) adds it to the application:

import { graphqlPlugin } from '@ultimabase/graphql'
import { httpPlugin } from '@ultimabase/http'
import { restPlugin } from '@ultimabase/rest'
import { defineApp, memory } from 'ultimabase'
import { borrowBook, returnBooks, searchBooks } from './functions.ts'
import { Book } from './models.ts'

export const app = defineApp({
	// Kept in memory, so the example needs no database.
	database: memory(),
	// Listens on PORT, or 3000.
	plugins: [httpPlugin(), restPlugin(), graphqlPlugin()],
	models: [Book],
	functions: [borrowBook, searchBooks, returnBooks],
})

run receives the validated input and the caller. A function is trusted server code: its queries pass { as: caller } to keep the caller's rules, or { as: master } to write what no client writes, as Book here.

Call it from server code

fn.run(input, { as }) does what a transport does: it checks the access rule, validates the input, then runs the function and returns its output, typed. A function without an input takes only { as }:

const loan = await borrowBook.run({ book: dune, days: 14 }, ada)
expect(loan.book.borrowedBy).toBe('ada')
expect(loan.dueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/)

// A PublicError thrown by `run` reaches the caller.
await expect(
	borrowBook.run({ book: dune, days: 7 }, grace),
).rejects.toMatchObject({ code: 'BOOK_BORROWED', message: 'Dune is out.' })

await returnBooks.run(ada)
const available = await searchBooks.run({ text: 'dune', available: true }, ada)
expect(available.map((book) => book.title)).toEqual(['Dune'])

A function that calls another uses run too, so the other's rule and validation apply.

Access and validation

.with({ access }) takes one rule, which receives { caller } only. A function without one is closed to all but Master. The rule runs before the input is validated, so a refused caller learns nothing of what the input should be:

// Without `as`, the caller is anonymous.
await expect(borrowBook.run({ book: dune, days: 14 })).rejects.toMatchObject({
	code: 'ACCESS_DENIED',
	message: 'borrowBook refused.',
	details: { function: 'borrowBook' },
})
await expect(
	borrowBook.run({ book: dune, days: 90 }, ada),
).rejects.toMatchObject({
	code: 'VALIDATION_FAILED',
	message: 'Invalid input.',
	details: { issues: [{ path: ['days'] }] },
})

A check that needs the input, or another row, throws from run: a PublicError reaches the client with its code, message and details. See Errors.

Queries and outputs

query: true makes a function a query: GraphQL serves it under Query, over GET and POST, and REST over GET. A function is a mutation otherwise.

// A query: served over GraphQL's Query and REST's GET.
export const searchBooks = defineFunction({
	name: 'searchBooks',
	query: true,
	input: z.object({ text: z.string().min(1), available: z.boolean() }),
	output: [Book],
	run: ({ input, caller }) =>
		Book.findMany(
			{
				where: {
					_search: input.text,
					...(input.available ? { borrowedBy: null } : {}),
				},
			},
			{ as: caller },
		),
}).with({ access: allow.public })

The output is a model (Book), a list ([Book]), a model or null (Book.nullable()), a defineType holding models and data (Loan above), or a Standard Schema for data alone. Without an output, run returns nothing and GraphQL answers true:

// No input and no output: GraphQL answers `true`.
export const returnBooks = defineFunction({
	name: 'returnBooks',
	// The transports whose key `.with` sets.
	use: [graphql()],
	run: async ({ caller }) => {
		const borrowedBy = userOf(caller)
		await Book.updateMany(
			{ where: { borrowedBy }, data: { borrowedBy: null } },
			{ as: master },
		)
	},
}).with({
	access: ({ caller }) => caller.kind === 'user',
	// Named `giveBack` in GraphQL; REST keeps `returnBooks`.
	graphql: { name: 'giveBack' },
})

The output is typed, not validated: run must return what it declares.

Over the transports

An object input gives GraphQL one argument per property, and REST one query parameter per property on a GET, or a JSON body on a POST. Both answer with the same output:

// Sends a GraphQL document, as a client without a session does.
async function request(query: string): Promise<unknown> {
	const response = await fetch(`${running.url}/graphql`, {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ query }),
	})
	return response.json()
}
// An object input gives one argument per property.
expect(
	await request('{ searchBooks(text: "dune", available: true) { title } }'),
).toEqual({ data: { searchBooks: [{ title: 'Dune' }] } })
// Renamed by `.with({ graphql: { name } })`.
expect(await request('mutation { giveBack }')).toMatchObject({
	errors: [
		{
			message: 'returnBooks refused.',
			extensions: { code: 'ACCESS_DENIED' },
		},
	],
})
// A query is a GET, its input in the query string.
const found = await fetch(
	`${running.url}/api/functions/searchBooks?text=dune&available=true`,
)
expect(await found.json()).toMatchObject([{ title: 'Dune' }])

// A mutation is a POST, its input in the body.
const borrowed = await fetch(`${running.url}/api/functions/borrowBook`, {
	method: 'POST',
	headers: { 'content-type': 'application/json' },
	body: JSON.stringify({ book: dune, days: 14 }),
})
expect(borrowed.status).toBe(403)
expect(await borrowed.json()).toMatchObject({
	code: 'ACCESS_DENIED',
	message: 'borrowBook refused.',
})

A client selecting a relation under a returned row gets it read again as the caller, with its rules. A function opens no transaction: see Transactions to write all or nothing.

Reference

defineFunction(definition)

KeyTypeDefaultEffect
namestringrequiredThe GraphQL field's name and the REST route's.
run(facts) => outputrequiredThe operation; may be async.
inputa Standard SchemanoneValidates the input; without it, run receives none.
outputsee belownoneThe output's type; without it, run returns nothing.
querybooleanfalseA query, over GET too; a mutation otherwise.
use[graphql()], [rest()]…[]The transports whose key .with sets. Every transport serves it.
descriptionstringnoneShown in the GraphQL schema and the OpenAPI document.

.with({ access, graphql, rest }) sets its rule and, for a transport of use, { name } to rename it there or false to hide it.

What run receives

FactValue
inputthe validated input, with the schema's defaults and transforms
callerwho calls: see Access rules
clientover a transport, { address, cookies }; undefined from server code

client.cookies has get(name), set(name, value, options?) and clear(name, options?), written on the HTTP response.

Outputs

Outputrun returnsGraphQL
nonenothingBoolean!
Booka rowBook!
[Book]rows[Book!]!
Book.nullable()a row or nullBook
a defineTypean object of its fieldsthe type
a Standard Schemathe schema's typefrom its JSON Schema

A schema given .meta({ id: 'Line' }) is the shared GraphQL type Line, and LineInput as an input.

Errors

CodeWhendetails
ACCESS_DENIEDThe rule refused: borrowBook refused.{ function }
VALIDATION_FAILEDThe input does not match: Invalid input.{ issues: [{ path, message }] }

Limits

  • The access rule is synchronous and receives no input.
  • A schema without a JSON Schema export, as z.date(), stops startup: z.iso.datetime() is a DateTime in GraphQL.
  • A name another function or a model's generated query already takes stops startup, naming both.

On this page