For TypeScript teams who want a backend they own

Your data model. A working backend.

One access rule per model, enforced over GraphQL, REST and your server code. Fully typed, no codegen, on your own PostgreSQL, and every capability is a plugin you choose.

Read the docsGitHub

Open source · Pre-release, not for production yet · Node.js 24.7+

import { defineModel } from 'ultimabase'

export const Post = defineModel({
	name: 'Post',
	fields: (f) => ({
		title: f.string(),
		published: f.boolean({ default: false }),
	}),
}).with({
	// Clients read the published posts; the other rules, left out, are
	// closed: only server code writes.
	access: { read: ({ where }) => where({ published: true }) },
})

Model → types

Post

  • titlestringRequired
  • publishedbooleanfalse unless set
  • readwhere({ published: true })Every client read joins this filter
// `{ as: master }` is your server code, past every rule: it writes a
// published post and a draft.
await Post.createMany(
	{ data: [{ title: 'Hello', published: true }, { title: 'Draft' }] },
	{ as: master },
)
// `request` sends a GraphQL query as an anonymous client would.
expect(await request('{ posts { edges { node { title } } } }')).toEqual({
	data: { posts: { edges: [{ node: { title: 'Hello' } }] } },
})

What a client reads

One post of two

  • Hellopublished: trueServed
  • Draftpublished: falseLeft out by the rule
// The same rule over REST: the published post, not the draft.
const response = await fetch(`${running.url}/api/posts`)
expect(await response.json()).toMatchObject({
	edges: [{ node: { title: 'Hello' } }],
})

GET /api/posts

The same post

  • Hellopublished: trueServed
  • Draftpublished: falseLeft out by the same rule
// Server code without `{ as }` reads as an anonymous client, so a
// forgotten caller never leaks a draft. The result is typed from the
// model: no generated code.
const found = await Post.findMany({ select: { title: true } })
expectTypeOf(found).toEqualTypeOf<{ readonly title: string }[]>()
expect(found).toEqual([{ title: 'Hello' }])

Post.findMany

Typed from the model

  • found[{ title: 'Hello' }]The rule applies here too
  • type{ readonly title: string }[]Nothing generated
// No rule opens the writes: a client's mutation is refused.
expect(
	await request('mutation { createPost(data: { title: "Mine" }) { id } }'),
).toMatchObject({
	data: null,
	errors: [{ extensions: { code: 'ACCESS_DENIED' } }],
})

What a client writes

Nothing

  • createPostACCESS_DENIEDNo rule opens it: only server code writes
One model, one rule, every way in: the code of examples/introduction, run by its tests. See the application ↓

Write the model and its rules once. The plugins you list build on them: the APIs, sign-in, the database, and yours, each shown below with the example its tests run.

Plugins

A small kernel. Room for your stack.

The kernel holds the models, the queries and the rules. The server, the APIs, sign-in and the databases are plugins: an application lists the ones it uses, and yours are written the same way.

export const app = defineApp({
	// Kept in memory, so the example needs no database.
	database: memory(),
	// Listens on PORT, or 3000; serves /graphql and /api.
	plugins: [httpPlugin(), graphqlPlugin(), restPlugin()],
	models: [Post],
})
import { graphqlPlugin } from '@ultimabase/graphql'
import { httpPlugin } from '@ultimabase/http'
import { postgres } from '@ultimabase/postgres'
import { defineApp } from 'ultimabase'
import { Project, Task } from './models.ts'

export const app = defineApp({
	// Outside production, a start syncs the tables to the models; in
	// production it checks that the migrations deployed match them.
	database: postgres({ url: process.env['DATABASE_URL'] }),
	// Listens on PORT, or 3000.
	plugins: [httpPlugin(), graphqlPlugin()],
	models: [Project, Task],
	// Accepted in production, where it needs 32 characters at least.
	masterKey: { key: process.env['MASTER_KEY'], production: true },
})
The models stay the same: the database is one line of the application.

Users & access

Who signs in. Who sees what.

Sign-in is two plugins, and access a rule beside each model and field. Every API and your server code apply the same rules.

Sessions

Sign-in, done

  1. Sign up An email and a password, hashed with Argon2id.
  2. Sign in A session, by HttpOnly cookie or Bearer token.
  3. Verify, reset Links valid 24 hours and 1 hour.
  4. Sign out Ends the session and clears the cookie.
Read more

Access

Rules at every level

Operations
Who reads, creates, updates and deletes.
Rows
A filter every read joins.
Fields
Hidden or read-only, by caller.
Each row
An ACL by user, by role or for the public.
Roles
That inherit others, in your rules.
Read more
const response = await post(
	`mutation ($input: SignUpInput!) {
		signUp(input: $input) { token expiresAt user { name } }
	}`,
	{
		input: {
			email: 'ada@example.com',
			password: 'correct horse',
			name: 'Ada',
		},
	},
)
const { token } = signedUp.parse(await response.json()).data.signUp
const bearer = { authorization: `Bearer ${token}` }
expect(await viewer(bearer)).toEqual({ data: { viewer: { name: 'Ada' } } })
expect(await viewer({})).toEqual({ data: { viewer: null } })

viewer

The token is the user

  • With the token{ name: 'Ada' }Signed in
  • WithoutnullAnonymous

Data

More than columns.

Typed fields of every kind, virtual fields computed on read, relations written and filtered in one call, and the queries a product needs.

const mug = await Product.findUniqueOrThrow(
	{
		where: { sku: 'MUG-001' },
		select: { label: true, priceIn: { currency: 'USD' } },
	},
	{ as: master },
)
expect(mug).toEqual({
	label: 'Mug (MUG-001)',
	priceIn: { amount: '13.20', currency: 'USD' },
})

Computed on read

From the fields it needs

name: 'Mug'sku: 'MUG-001'

label 'Mug (MUG-001)'

price: '12.00'currency: 'USD'

priceIn 13.20 USD

const author = await Author.findUniqueOrThrow(
	{
		where: { id: ursula.id },
		include: {
			books: {
				orderBy: { title: 'asc' },
				take: 10,
				include: { publisher: true, tags: true },
			},
		},
	},
	{ as: master },
)
expect(author.books.map((book) => book.title)).toEqual([
	'The Dispossessed',
	'The Lathe of Heaven',
])
expect(author.books[0]?.publisher.name).toBe('Ace')

include

An author, with books

  • books['The Dispossessed', 'The Lathe of Heaven']Sorted and paged in the read
  • books[0].publisher'Ace'A relation of a relation

Transactions

Related writes. One transaction.

Writes inside transaction() commit together, or none of them does. A throw rolls them back.

import { PublicError, defineFunction, master, transaction } from 'ultimabase'
import { z } from 'zod'
import { Account } from './models.ts'

export const transfer = defineFunction({
	name: 'transfer',
	input: z.object({ from: z.string(), to: z.string(), amount: z.int().min(1) }),
	run: async ({ input, caller }) => {
		// NOT_FOUND unless the caller owns the account.
		await Account.findUniqueOrThrow(
			{ where: { id: input.from } },
			{ as: caller },
		)
		const as = { as: master }
		await transaction(async () => {
			// The update locks the row: a second transfer waits for this one.
			const from = await Account.update(
				{
					where: { id: input.from },
					data: { balance: { decrement: input.amount } },
				},
				as,
			)
			if (from.balance < 0) {
				// Throwing rolls back the decrement.
				throw new PublicError('INSUFFICIENT_FUNDS', 'The balance is too low.')
			}
			await Account.update(
				{
					where: { id: input.to },
					data: { balance: { increment: input.amount } },
				},
				as,
			)
		})
	},
}).with({ access: ({ caller }) => caller.kind === 'user' })

transfer

Both writes, or neither

  1. 01

    Decrement

    The sending account, locked until the end.

  2. 02

    Check

    A negative balance throws INSUFFICIENT_FUNDS.

  3. 03

    Increment

    The receiving account, in the same commit.

await transfer.run({ from, to, amount: 30 }, ada)
expect(await balances()).toEqual([70, 30])

Balances

Committed

  • Before[100, 0]Two accounts
  • After[70, 30]Both writes saved
await expect(
	transfer.run({ from, to, amount: 130 }, ada),
).rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' })
// The decrement was undone.
expect(await balances()).toEqual([100, 0])

Balances

Rolled back

  • transfer 130INSUFFICIENT_FUNDSRefused after the decrement
  • After[100, 0]The decrement undone

Beyond CRUD

Go beyond reads and writes.

The official plugins add a trash, live data and files. Yours add fields, hooks, functions, sign-in or a transport the same way.

Soft delete

A delete you can undo

  1. Visible
  2. In the trash
  3. Restored
import { graphql } from '@ultimabase/graphql'
import { softDelete } from '@ultimabase/soft-delete'
import { allow, defineModel } from 'ultimabase'

export const Post = defineModel({
	name: 'Post',
	// Adds `deletedAt`, `Post.trash` and `Post.restore`.
	use: [graphql(), softDelete()],
	fields: (f) => ({ title: f.string() }),
	// Open to every client, to keep the example on soft delete.
}).with({ access: allow.public })
Read more

Your own plugin

Written like ours

definePlugin declares what a plugin brings; the kernel types it in every application that lists it.

Models
Fields, indexes, hooks and commands.
Types
Field types of its own, validated.
Callers
Identities, such as an API key.
Serving
Functions, routes and transports.
Read more

A few things to know.

How does it compare to Parse or Supabase?

Like Parse Server, it is a backend you run yourself, with models, access rules, hooks and functions. Unlike Supabase's policies, written in SQL, its rules are TypeScript beside each model, and apply to your server code too. Everything is typed from the models, with no generated code.

Can I use it in production?

Not yet. UltimaBase is pre-release, and its API may still change before 0.1.

Is UltimaBase a hosted service?

No. It is a library and a command line: the application is yours, and runs wherever Node.js runs, on your own PostgreSQL. See Deploy.

Do I generate code?

No. The types come from the models: queries, rules and hooks are typed as you write them, and the GraphQL schema and the OpenAPI document are built when the application starts.

How does the database change with the models?

In development, ultimabase dev syncs the tables to the models at each start. In production, ultimabase migrate create writes a migration file you review and commit, and ultimabase migrate deploy applies it. See PostgreSQL.

Which databases does it support?

PostgreSQL, and memory() for a first application and the tests. Another database takes an adapter, checked by the contract suite: see Write a database adapter.

Can it run on several instances?

Sessions are rows in the database, and with PostgreSQL the realtime plugin hears changes through pg_notify. Running several instances behind a load balancer is not documented yet.

What is not there yet?

An admin interface, sign-in with OAuth or a magic link, background jobs and multi-tenancy: they come after 0.1. Sign-in today is an email and a password, and a plugin can add other callers, such as an API key: see Write a plugin.

Start

Build your first backend.

One command writes an application with a model, its tests and a GraphQL API. The quickstart takes it from there.

Follow the quickstart

Apache-2.0 · Pre-release