Schemas

Defining Tables

Define tables and relationships in schema.ts using the TypeScript DSL.

Project layout

A Jazz project has a small set of files at the app root:

app-root/
├── schema.ts            # Structural schema — tables and columns
├── permissions.ts       # Explicit operation policies
└── migrations/          # Reviewed migration edges
    └── 20260331-add-description-aaa-bbb.ts

schema.ts is the source of truth for your data model. permissions.ts contains authorization policies over your tables. The migrations/ directory holds reviewed migration stubs — see Migrations for the full workflow.

Table definitions

Tables are defined in schema.ts using the Jazz DSL. Each s.table(columns, relations) call declares its stored columns and named relations. The second argument is required; pass {} when the table has no relations.

schema.ts
todos: s.table(
  {
    title: s.string(),
    done: s.boolean(),
    priority: s.int().optional(),
    description: s.string().optional(),
    owner_id: s.uuid().optional(),
    parentId: s.uuid().optional(),
    projectId: s.uuid().optional(),
  },
  {
    parent: s.rel("todos", "parentId"),
    project: s.rel("projects", "projectId"),
    children: s.reverse("todos", "parent"),
  },
),
projects: s.table(
  {
    name: s.string(),
  },
  { todos: s.reverse("todos", "project") },
),

Explicit relations

Store row IDs in UUID columns, then use s.rel to name a forward relationship. In the example below, posts.author follows authorId to a user. Once that forward relationship is declared, s.reverse lets a user find their authoredPosts:

const schema = {
  posts: s.table(
    { authorId: s.uuid(), title: s.string() },
    {
      author: s.rel("users", "authorId"),
    },
  ),
  users: s.table(
    { name: s.string() },
    {
      authoredPosts: s.reverse("posts", "author"),
    },
  ),
};
const app = s.defineApp(schema);

author is the public relation name, users is its target table, and authorId is the local UUID column.

authoredPosts names a reverse traversal of the posts.author relation; the second argument to s.reverse is a forward relation name, not a column name. No suffix convention, generated code, or automatic reverse relation is involved. A UUID column without a relation declaration remains an ordinary UUID column.

Use s.uuid().optional() for an optional ID and s.array(s.uuid()) for an array of IDs; point s.rel at that column in both cases. Optionality and cardinality come from the column. A declared relation does not guarantee the target row exists or is readable: a peer may not have synced it, it may have been deleted, or permissions may hide it. See Missing references.

App construction validates the complete schema, including relations outside a typed slice. Targets and local columns must exist, forward columns must have supported UUID shapes, and reverse declarations must resolve to a forward relation pointing back to their table. Relation names must not collide with stored column names.

Validate locally

Validate your schema and permissions locally:

pnpm dlx jazz-tools@alpha validate

This validates schema.ts and permissions.ts, then compiles them into Jazz's internal schema representation.

When Jazz reports a difference between the old and new schema hashes after changing schema.ts, and your app already has data, create a migration and deploy the updated app as described in Migrations:

pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>
pnpm dlx jazz-tools@alpha deploy <appId>

When you change your schema on a shared app, prepare any required migration and run jazz-tools deploy <appId>. See Migrations for details.

Exporting the app

s.defineApp(schema) converts your schema definition into a typed app object. This is what you pass to queries, mutations, and subscriptions throughout your application code.

schema.ts
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema);

export type Todo = s.RowOf<typeof app.todos>;

The app object has one typed table handle per table (e.g. app.todos, app.projects). Table handles are query builders — you chain .where(), .include(), .orderBy() and other methods directly on them.

Type helpers

Extract precise TypeScript types from any table handle:

HelperReturns
s.RowOf<typeof app.todos>The row type (all columns, id included)
s.InsertOf<typeof app.todos>The insert shape (no id, respects optionals and defaults)
s.WhereOf<typeof app.todos>The where(...) input shape for that table

Very Large Schemas

For most apps, s.defineApp(schema) is the right export: it gives you one typed table handle for each table in the schema, and Jazz uses the same schema for runtime validation, migrations, query planning, and TypeScript inference.

Very large apps can hit a different tradeoff. The runtime schema may need to contain hundreds of tables, while a given feature area only works with a much smaller subset. Because typed relations and reverse relations are declared in the app schema, asking TypeScript to understand the whole graph can make editor and build performance worse than the code you are writing actually needs.

Use s.defineSliceableApp(schema) when you want one complete runtime schema but smaller typed app surfaces:

schema.ts
import { schema as s } from "jazz-tools";

const schema = {
  accounts: s.table({ name: s.string() }, {}),
  users: s.table({ name: s.string() }, {}),
  orders: s.table(
    { number: s.string(), catalogItemId: s.uuid(), buyerId: s.uuid() },
    {
      catalogItem: s.rel("catalog_items", "catalogItemId"),
      buyer: s.rel("users", "buyerId"),
    },
  ),
  shipments: s.table(
    { trackingCode: s.string(), orderId: s.uuid() },
    {
      order: s.rel("orders", "orderId"),
    },
  ),
  support_tickets: s.table(
    { workspaceId: s.uuid(), requesterId: s.uuid() },
    {
      workspace: s.rel("workspaces", "workspaceId"),
      requester: s.rel("users", "requesterId"),
    },
  ),
  catalog_items: s.table(
    { title: s.string(), workspaceId: s.uuid() },
    {
      workspace: s.rel("workspaces", "workspaceId"),
      orders: s.reverse("orders", "catalogItem"),
    },
  ),
  workspaces: s.table(
    { name: s.string(), accountId: s.uuid() },
    {
      account: s.rel("accounts", "accountId"),
      tickets: s.reverse("support_tickets", "workspace"),
    },
  ),
};

const sliceableApp = s.defineSliceableApp(schema);

export const commerceApp = sliceableApp.slice(
  "accounts",
  "workspaces",
  "catalog_items",
  "orders",
  "shipments",
);
export const supportApp = sliceableApp.slice("accounts", "workspaces", "support_tickets");

Each slice returns a normal typed App surface for only the selected tables:

await db.all(commerceApp.orders.include({ catalogItem: true }));
await db.all(commerceApp.catalog_items.include({ orders: true }));

Declared relations to tables inside the slice become typed relations and includes. UUID columns referring to tables outside the slice remain valid scalar ID columns:

type Order = s.RowOf<typeof commerceApp.orders>;
// Order["catalogItemId"] is string, and commerceApp.orders.include({ catalogItem: true }) is typed.
// Order["buyerId"] is string, but there is no typed `buyer` include unless `users` is in the slice.

Explicit reverse relations are available only when their source tables are in the current slice. In the example above, commerceApp.catalog_items has orders, while supportApp.workspaces has tickets.

All slices share the complete runtime schema:

commerceApp.wasmSchema === sliceableApp.wasmSchema;
supportApp.wasmSchema === sliceableApp.wasmSchema;

That means schema hashing, migrations, permissions, runtime validation, query planning, inserts, updates, and row transforms still see the full schema. The slice only limits the TypeScript app graph you ask the compiler to expand.

On this page