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
ProcedureInputOutputDescription
{name}.listRow[]SELECT *
{name}.get{ <pk>: ... }Row | nullFetch by primary key
{name}.createRow minus serial PK, defaults, and ownerColumnRowINSERT ... RETURNING *
{name}.update{ where: { <pk> }, data: Partial<createInput> }RowUPDATE ... RETURNING *
{name}.delete{ <pk>: ... }RowDELETE ... 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

ValueDescription
"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.