Sessions
Sign-in, done
- Sign up An email and a password, hashed with Argon2id.
- Sign in A session, by HttpOnly cookie or Bearer token.
- Verify, reset Links valid 24 hours and 1 hour.
- Sign out Ends the session and clears the cookie.
For TypeScript teams who want a backend they own
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.
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
stringRequiredbooleanfalse unless setwhere({ 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
published: trueServedpublished: 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
published: trueServedpublished: 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
[{ title: 'Hello' }]The rule applies here too{ 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
ACCESS_DENIEDNo rule opens it: only server code writesexamples/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
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 },
})Users & access
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
Access
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
{ name: 'Ada' }Signed innullAnonymousData
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
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
['The Dispossessed', 'The Lathe of Heaven']Sorted and paged in the read'Ace'A relation of a relationRelations
Queries
Transactions
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
Decrement
The sending account, locked until the end.
Check
A negative balance throws INSUFFICIENT_FUNDS.
Increment
The receiving account, in the same commit.
await transfer.run({ from, to, amount: 30 }, ada)
expect(await balances()).toEqual([70, 30])Balances
[100, 0]Two accounts[70, 30]Both writes savedawait expect(
transfer.run({ from, to, amount: 130 }, ada),
).rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' })
// The decrement was undone.
expect(await balances()).toEqual([100, 0])Balances
INSUFFICIENT_FUNDSRefused after the decrement[100, 0]The decrement undoneBeyond CRUD
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
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 })Your own plugin
definePlugin declares what a plugin brings; the kernel types it in every application that lists it.
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.
Not yet. UltimaBase is pre-release, and its API may still change before 0.1.
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.
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.
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.
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.
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.
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
One command writes an application with a model, its tests and a GraphQL API. The quickstart takes it from there.
Apache-2.0 · Pre-release