Schemas

Migrations

Structural schema evolution workflow and reviewed migration edges.

You can skip this first

If you are trying to get your first app running, you can skip this page and return later.

Why Jazz migrations are different

Most migration systems are one-way: rewrite every row to the new shape, then cut over. That assumes you can stop the world long enough to upgrade — which doesn't hold when your clients are local-first, frequently offline, and updating their app on their own schedule.

Jazz keeps every schema version addressable by hash and translates rows between them on read and write. Clients on different versions stay interoperable, and nothing on disk is rewritten when you ship a new schema.

Schemas, lenses and branches

Every unique version of your schema.ts has a hash which can be used to refer to it. When creating migrations, you describe the changes required to move between two schema versions. This is known as a 'lens'. Fetching all intermediate lenses allows clients with any published schema version to read data created with any other published schema version.

Storage is partitioned by schema hash: rows written under a given schema live in that schema's own branch (env-{hash}-userBranch) and stay there unchanged. Non-adjacent reads compose lenses in sequence to bridge multiple schema versions.

In practice, this lets you:

  • Ship a schema change without waiting for every user to update their app.
  • Roll out platform-by-platform (mobile, desktop, web) on independent cadences.
  • Accept writes from clients that have been offline since before the new schema landed.

Workflow

  1. If this is the first migration you are creating, run:

    pnpm dlx jazz-tools@alpha migrations create

    This creates an initial snapshot of your schema in migrations/snapshots/. No migration file is created yet because there is no previous schema to diff against.

  2. Edit schema.ts — change the data shape as needed.

  3. Validate locally — optionally run pnpm dlx jazz-tools@alpha validate to surface any policy diagnostics without publishing. deploy runs the same checks; validate is most useful as a fast pre-publish sanity check or in CI.

  4. Create a migration stub for the updated schema — run:

    pnpm dlx jazz-tools@alpha migrations create --name <your-migration-name>

    By default, Jazz diffs the latest committed snapshot in migrations/snapshots/ against the current schema and writes a stub migration file into migrations/. It also saves a snapshot of the generated schema.

  5. Review and customise — the migration, if needed (see below).

  6. Publish — push the migration to the server:

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

    If you also want to publish the current schema, the migration and permissions in one step, you can run:

    pnpm dlx jazz-tools@alpha deploy <appId>

    deploy walks through the publish pipeline in one go:

    1. Publishes the current schema if the server does not already have it.
    2. If your previous permissions.ts was tied to an older schema hash, asks the server whether it already has a migration path between the two hashes. If not, pushes the local migration file that closes the gap (and fails with a helpful message if you haven't created one yet).
    3. Publishes the current permissions, attached to the current schema hash.

Permission-only changes in permissions.ts don't need a migration but still need to be deployed: pnpm dlx jazz-tools@alpha deploy <appId>. See Permissions for details.

The migration file

The generated stub describes the diff as declarative operations which carry enough information to run in either direction. That is how older clients can still read data written under a newer schema: the same operations replay in reverse.

If the diff contains ambiguities (e.g. a column was removed and a same-typed column was added, which could be a rename), the generated lens is marked as a draft. Draft lenses will fail at startup if they are in the path to a live schema. You need to review the draft lens and resolve the ambiguity before publishing.

Generated stub

Here's a generated stub for adding a description column:

migrations/20260318-unnamed-a01f5c72ec47-311995e9a178.ts
import { schema as s } from "jazz-tools";

export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: null }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

Customising defaults

Review generated defaults before you publish. For example, you might replace a nullable default with a domain-specific value:

migrations/20260318-add-description-a01f5c72ec47-311995e9a178.ts
import { schema as s } from "jazz-tools";

// Example of editing a generated migration stub.
export default s.defineMigration({
  migrate: {
    todos: {
      description: s.add.string({ default: "No description" }),
    },
  },
  fromHash: "a01f5c72ec47",
  toHash: "311995e9a178",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

Backwards defaults

When a newer schema drops a column that older clients still expect, define a backwards default so the lens can supply a value for those clients:

migrations/20260318-drop-legacy-priority-311995e9a178-73b65d082ab8.ts
import { schema as s } from "jazz-tools";

// Example: dropping a column with a backwards default.
// Clients still on the older schema continue seeing legacy_priority.
export default s.defineMigration({
  migrate: {
    todos: {
      legacy_priority: s.drop.int({ backwardsDefault: 0 }),
    },
  },
  fromHash: "311995e9a178",
  toHash: "73b65d082ab8",
  from: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
      legacy_priority: s.int(),
    }),
  },
  to: {
    todos: s.table({
      title: s.string(),
      done: s.boolean(),
      description: s.string().optional(),
      parentId: s.ref("todos").optional(),
      projectId: s.ref("projects").optional(),
      owner_id: s.string(),
    }),
  },
});

Migrating historical schemas

Jazz does not require you to create a migration for every schema change. You can just use the app and create new data. Existing data will still be stored in the database, but you will not be able to read it until you create a migration.

This is particularly useful when you are iterating on a feature that is not yet ready to be released.

The Jazz server will detect when there are rows that are not reachable from the current schema. It will log a warning and suggest you create a migration.

To do so, you'll need to create the migration using explicit to/from schema hashes:

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

--toHash defaults to the current local schema. When a requested hash is not already saved locally, Jazz resolves it from the server and saves a snapshot in migrations/snapshots/.

Exporting the compiled schema

pnpm dlx jazz-tools@alpha schema export prints the compiled structural schema as JSON to stdout. It also saves a snapshot of the schema in the local snapshot directory.

pnpm dlx jazz-tools@alpha schema export
pnpm dlx jazz-tools@alpha schema export --schema-dir ./packages/app
pnpm dlx jazz-tools@alpha schema export <appId> --schema-hash <hash> --server-url http://localhost:4200 --admin-secret <secret>

Without --schema-hash, Jazz exports the current local schema.ts. With --schema-hash, it loads the schema from the local snapshot folder or, if missing, from the server. --schema-dir and --schema-hash are mutually exclusive.

Server-backed commands require the app id so Jazz can resolve app-scoped routes like /apps/<appId>/schema/:hash.

FlagDefaultDescription
--schema-dir <path>current directoryPath to app root containing schema.ts
--schema-hash <hash>noneExport a stored structural schema by hash
--migrations-dir <p>./migrationsPath to migrations directory and snapshot folder
--server-url <url>JAZZ_SERVER_URLServer URL used when --schema-hash is not available locally
--admin-secret <sec>JAZZ_ADMIN_SECRETAdmin secret used when --schema-hash is not available locally

Migration flags

migrations create uses flags rather than positional hashes. When it needs to resolve missing schema hashes from a server, pass <appId> as the leading positional argument.

FlagDefaultDescription
--schema-dir <path>current directoryPath to app root containing schema.ts
--migrations-dir <p>./migrationsPath to migrations directory and snapshot folder
--server-url <url>JAZZ_SERVER_URLServer URL used when resolving missing schema
--admin-secret <sec>JAZZ_ADMIN_SECRETAdmin secret used when resolving missing schema
--fromHash <hash>latest snapshotOptional source schema hash
--toHash <hash>current schemaOptional target schema hash
--name <name>unnamedOptional migration filename label

Next steps

On this page