Migrations
Workflows evolve over time. When you change state schemas, old snapshots stored in your database no longer match the new shape. Migrations let you transform those snapshots through a versioned pipeline so they can be safely restored.
The modelVersion field on every snapshot is the anchor — it records which version of your schema produced the data. When you bump modelVersion on the definition, old snapshots carry a lower number and need to be migrated before deserialize() will accept them.
Defining a Migration Pipeline
defineMigrations() takes a definition and a map of version-keyed transform functions:
const migrations = defineMigrations(definition, {
2: (snap) => ({
...snap,
// biome-ignore lint/suspicious/noExplicitAny: migration functions operate on unknown data
data: { ...(snap.data as any), status: "active" },
}),
3: (snap) => {
// biome-ignore lint/suspicious/noExplicitAny: migration functions operate on unknown data
const data = snap.data as any;
return {
...snap,
data: { ...data, fullName: `${data.firstName} ${data.lastName}` },
};
},
});Each key is either a plain transform function or an object with a description and an up function:
const migrationsV2 = defineMigrations(definitionV2, {
2: {
description: "Add status field",
up: (snap) => ({
...snap,
data: { ...(snap.data as Record<string, unknown>), status: "active" },
}),
},
});The description is optional but shows up in onStep callbacks (see Observability Callbacks).
Each key is the target version — the function transforms from (key - 1) to key. Migration functions operate on unknown data, so as any casts are expected here. Type safety is restored at the deserialize() boundary, not inside the migration functions themselves.
The pipeline auto-stamps modelVersion after each step, even if your function sets it. You only transform data and, optionally, state.
defineMigrations() validates the map at creation time and throws if:
- Any key is
<= 1(version 1 is the baseline — no migration needed) - There are gaps in the version sequence (e.g., keys 2 and 4 but not 3)
- The highest key doesn't match the definition's
modelVersion
If the definition has modelVersion: 1 (or unset) and the map is empty, a valid pipeline is returned with nothing to run.
Running Migrations
migrate() runs the pipeline from the snapshot's current version to the target:
const result = migrate(migrations, oldSnapshot);
if (result.ok) {
// result.snapshot is now at the target modelVersion
} else {
// result.error is a MigrationError
console.error(result.error.message);
}migrate() is synchronous. Migration functions are pure data transforms — no async needed.
migrate() validates before running and returns an error (without throwing) if:
snapshot.definitionNamedoesn't match the pipeline's definition namesnapshot.modelVersionis not a positive integer- The snapshot is already at a higher version than the target (can't downgrade)
If the snapshot is already at the target version, it is returned as-is.
The Full Pattern: migrate() then deserialize()
migrate() and deserialize() are separate calls. Run migration first to bring the snapshot up to the current schema version, then deserialize to validate and reconstruct the typed workflow:
async function loadWorkflow() {
const raw = JSON.parse(await db.get(`workflow:${id}`));
const migrated = migrate(migrations, raw);
if (!migrated.ok) {
console.error(migrated.error); // MigrationError: step details
return;
}
const restored = definition.deserialize(migrated.snapshot);
if (!restored.ok) {
console.error(restored.error); // ValidationError: schema mismatch
return;
}
// restored.workflow is a fully typed Workflow<TConfig>
const workflow = restored.workflow;
void workflow;
}Observability Callbacks
migrate() accepts an optional third argument with lifecycle callbacks:
const observedResult = migrate(migrations, oldSnapshot, {
onStep: (fromVersion, toVersion, _snapshot, description) => {
console.log(`Migrated ${fromVersion} → ${toVersion}: ${description ?? "no description"}`);
},
onError: (error) => {
logger.error("Migration step failed", {
from: error.fromVersion,
to: error.toVersion,
cause: error.cause,
});
},
});onStep fires after each successful step and receives the post-migration snapshot. onError fires when a step fails, before the error result is returned.
Error Handling
When a migration step throws, migrate() catches it and returns a MigrationError:
const migrationResult = migrate(migrations, oldSnapshot);
if (!migrationResult.ok) {
const err = migrationResult.error; // MigrationError
console.log(err.fromVersion); // version the step started from
console.log(err.toVersion); // version the step was trying to reach
console.log(err.cause); // the original thrown value
console.log(err.message); // "Migration 2 → 3 failed: ..."
}MigrationError extends Error and is exported from @rytejs/core.
Testing Migrations
@rytejs/testing provides three utilities for migration testing. All are synchronous and framework-agnostic.
testMigration — single step
Verifies one migration function in isolation. Constructs a snapshot at version from, runs the migration to from + 1, and asserts the output data deep-equals expected:
testMigration(migrations, {
from: 1,
input: { firstName: "Alice", lastName: "Smith" },
expected: { firstName: "Alice", lastName: "Smith", status: "active" },
});Pass state to control which state name appears in the test snapshot. Defaults to the first state in the definition.
testMigrationPath — full chain
Runs the entire pipeline from from to the target version and asserts the final version and data:
testMigrationPath(migrations, {
from: 1,
input: { firstName: "Alice", lastName: "Smith" },
expectVersion: 3,
expected: { fullName: "Alice Smith", status: "active" },
});testMigrationRestore — migrate + restore round-trip
Runs migrate() then definition.deserialize() and asserts the deserialize succeeds. Use this to catch cases where migration produces data that satisfies the pipeline but fails schema validation:
testMigrationRestore(migrations, {
from: 1,
input: { firstName: "Alice", lastName: "Smith" },
expectState: "Draft",
});expectState is optional. The definition is derived from the pipeline.