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 # Optional row-level policies
└── migrations/ # Reviewed migration edges
└── 20260331-add-description-aaa-bbb.tsschema.ts is the source of truth for your data model. permissions.ts is optional and must be a
separate file. 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(...) call registers a table and s.ref(...) defines typed relations between them.
projects: s.table({
name: s.string(),
}),
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
owner_id: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
}),s.ref() columns must be named with an Id or _id suffix (for example projectId or
owner_id). For s.array(s.ref()), use an Ids or _ids suffix instead. The runtime enforces
this convention and will throw if a ref column name does not match.
Validate locally
Validate your schema and permissions locally:
pnpm dlx jazz-tools@alpha validate
This validates schema.ts and the optional 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, you should create and push a migration edge as described in Migrations:
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>
pnpm dlx jazz-tools@alpha migrations push <appId> <fromHash> <toHash>
When you change your schema on a shared app, create and push a migration. See Migrations for details.
If you need to clear local browser data after a schema change, see Auth Lifecycle.
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.
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:
| Helper | Returns |
|---|---|
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 derived from 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:
import { schema as s } from "jazz-tools";
const schema = {
accounts: s.table({
name: s.string(),
}),
workspaces: s.table({
name: s.string(),
accountId: s.ref("accounts"),
}),
catalog_items: s.table({
title: s.string(),
workspaceId: s.ref("workspaces"),
}),
orders: s.table({
number: s.string(),
catalogItemId: s.ref("catalog_items"),
buyerId: s.ref("users"),
}),
shipments: s.table({
trackingCode: s.string(),
orderId: s.ref("orders"),
}),
users: s.table({
name: s.string(),
}),
support_tickets: s.table({
workspaceId: s.ref("workspaces"),
requesterId: s.ref("users"),
}),
};
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({ ordersViaCatalogItem: true }));Refs to tables inside the slice become typed relations and includes. Refs 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.Reverse relations are also derived only from the current slice. In the example above,
commerceApp.catalog_items has ordersViaCatalogItem, while supportApp.workspaces has
support_ticketsViaWorkspace.
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.