Schema & Migrations
Outer's schema builder defines tables and relations, and doubles as your migration history: every .schema() call registered on the builder chain becomes one migration step.
Defining a schema version
import { schema } from "@outerjs/server";
const v1_0 = schema("1.0.0")
.table("user", (t) => ({
id: t.text().primaryKey(),
email: t.text().unique(),
name: t.text(),
image: t.text().nullable(),
}))
.table("post", (t) => ({
id: t.serial().primaryKey(),
title: t.text(),
body: t.text().nullable(),
authorId: t.text().references("user", "id"),
}))
.relation("user", (rel) => rel.hasMany("post", { from: "id", to: "authorId" }))
.relation("post", (rel) => rel.belongsTo("user", { from: "authorId", to: "id" }))
.build();
Column types
text · varchar · integer · serial · boolean · timestamp · jsonb · uuid
timestamp maps to timestamptz in the generated DDL.
Column modifiers
.primaryKey() · .unique() · .nullable() · .default(expr: string) · .references(table, column)
Relation kinds
hasMany · hasOne · belongsTo · manyToMany
Type inference
SchemaResult<T>["_db"] is the fully inferred Kysely database type — { [tableName]: { [column]: TSType } }. Nullable columns become TSType | null | undefined.
Registering versions
Pass each schema version to .schema() on the builder, in order:
new Outer({ db: pglite() })
.schema(v1_0)
.schema(v1_1) // each call adds a migration step and updates the DB type
.build();
Each call also advances context.db's type to InferDB<T>, so procedures registered after a .schema() call see the new columns.
Running migrations
await server.migrator.migrateToLatest();
Outer uses a custom SchemaMigrationProvider that diffs consecutive schema versions. Each schema("x.y.z") call becomes one Kysely migration keyed by its version string, and migrations run in alphabetical version order.
- Up — creates new tables, adds new columns, drops removed columns.
- Down — reverses: drops added tables/columns, restores dropped ones.
Type changes on existing columns are not handled automatically — use context.db directly for those.
Auto-generated CRUD with .resource()
.resource(name, options?) generates five procedures for a table defined in the last .schema() call:
.resource("post", {
permissions: {
list: "public",
get: "public",
create: "authenticated",
update: "owner",
delete: "owner",
},
ownerColumn: "userId",
})
// Registers: post.list, post.get, post.create, post.update, post.delete
| Procedure | Input | Output | Description |
|---|---|---|---|
{name}.list | — | Row[] | SELECT * |
{name}.get | { <pk>: ... } | Row | null | Fetch by primary key |
{name}.create | Row minus serial PK, defaults, and ownerColumn | Row | INSERT ... RETURNING * |
{name}.update | { where: { <pk> }, data: Partial<createInput> } | Row | UPDATE ... RETURNING * |
{name}.delete | { <pk>: ... } | Row | DELETE ... RETURNING * |
Input types are derived from column definitions at build time. serial primary key columns, columns with .default(), and ownerColumn are omitted from create input.
create/update map common Postgres constraint violations to clean errors instead of a raw 500: unique/foreign-key violations → 409 CONFLICT, not-null/check violations → 400 BAD_REQUEST. update/delete on a row that doesn't exist → 404 NOT_FOUND. Unrecognized DB errors still surface as a generic 500 with no internal details leaked.
Permissions
| Value | Description |
|---|---|
"public" | No restriction (default) |
"authenticated" | User must be signed in — calls context.auth.api.getSession() internally |
"admin" | User must have role === "admin" (requires the Better Auth admin plugin) |
"owner" | User must own the row — requires ownerColumn; not valid for list or create |
When create is "authenticated" and ownerColumn is set, the current user's ID is automatically injected into the insert — no need to pass it in the request.
When update or delete is "owner", the existing row is fetched first and row[ownerColumn] is compared to the session user's ID. Returns 403 FORBIDDEN if they do not match.
// Full example
.resource("post", {
permissions: {
list: "public", // anyone can list
get: "public", // anyone can read
create: "authenticated", // must be signed in; userId auto-filled
update: "owner", // only the creator can edit
delete: "admin", // only admins can delete
},
ownerColumn: "userId",
})
.resource() currently registers its five CRUD procedures as loosely-typed AnyProcedure in the router type — their runtime behavior and validation are fully typed internally, but that derivation isn't yet mirrored at the type level. Use .procedure() directly if you need full type safety on a given endpoint.
Next: wire up Auth & Permissions, or write custom Procedures.