JOLT DATA SDK GUIDE · 03

Schema migrations

Jolt data can outlive one version of an application. A migration turns an older stored value into the one current Schema Class before application code sees it.

You keep Post, not PostV1, PostV2, and PostV3. Historical shapes stay inside small, deterministic migration steps.

Declare each step

Suppose version 1 called its text field message. Version 2 renamed that field to text. Version 3 added a required list of tags.

src/posts.tsts
import {
  Collection,
  Field,
  Migrations,
  Read,
  Schema,
  SchemaMigrationError,
} from "jolt-sdk/data";

export const PostMigrations = Migrations.create()
  .to(2, value => Migrations.rename(value, { message: "text" }))
  .to(3, value => ({
    ...value,
    tags: value.tags ?? [],
  }));

@Schema({ version: 3, migrations: PostMigrations })
export class Post {
  @Field.string()
  text!: string;

  @Field.array(Field.string)
  tags!: string[];

  @Field.dateTime()
  postedAt!: Date;
}

export const Posts = Collection.create(Post, {
  access: {
    read: Read.AnyIdentity,
    create: true,
    update: true,
  },
});

export function explainMigrationFailure(error: unknown): string {
  if (error instanceof SchemaMigrationError) {
    return `Could not upgrade schema ${error.fromVersion} to ${error.toVersion}`;
  }
  throw error;
}

Migrations.rename() returns a new value. It never adds helper fields to your application data. A normal .to(...) step can perform any other pure value transformation.

What happens on a read

When the Data SDK reads an older record, it:

  1. reads the stored schema version;
  2. applies every required step in order;
  3. validates the result against the current Schema Class; and
  4. gives application code only the current Post type.

The daemon and protocol do not interpret the schema. Migration belongs to the application-facing SDK boundary.

Test old data directly

Use the Resource's migrate() helper in a focused test. Pass the historical version and a plain stored value; do not recreate old model classes.

src/posts.test.tsts
import { describe, expect, it } from "vitest";
import { Post, Posts } from "./migrations";

describe("Data SDK migrations guide", () => {
  it("migrates version one data into the current Schema Class", () => {
    const post = Posts.migrate({
      version: 1,
      value: {
        message: "Hello from version one",
        postedAt: "2026-08-31T12:00:00.000Z",
      },
    });

    expect(post).toBeInstanceOf(Post);
    expect(post.text).toBe("Hello from version one");
    expect(post.tags).toEqual([]);
    expect(post.postedAt).toEqual(new Date("2026-08-31T12:00:00.000Z"));
  });
});

This helper exists for migration tests and lower-level tooling. Ordinary reads apply migrations automatically.

Keep migrations boring

A migration must produce the same output every time. It must not read the clock, call the network, inspect the current user, or mutate its input.

Every version between the stored value and the current schema needs a step. A missing step, a thrown transformation, or a final value that does not validate throws SchemaMigrationError. Catch that error by type when a tool or advanced application needs to report the failed version range.

Migrations.rename() also refuses to overwrite an existing destination or send two old fields to the same new field. Failing is safer than silently discarding stored data.

Continue learning