A versioned data migrator. Register {from, to, migrate} transforms and
walk any document — by its own schemaVersion field — forward to the
latest version (or a specific target), with optional downgrade support
and batch migration.
This fixes the classic bug in naive migrator implementations: running
every registered transform on every document regardless of its
starting version (so a v2 document gets the v1→v2 transform re-applied
on top of already-migrated data). Here, each transform only fires when
the document's current version actually matches that transform's from.
npm install @ferrow/schema-evolutionimport { SchemaMigrator } from "schema-evolution";
interface UserDoc {
schemaVersion: number;
firstName?: string;
lastName?: string;
fullName?: string;
}
const migrator = new SchemaMigrator<UserDoc>();
migrator.register({
from: 1,
to: 2,
migrate: (doc) => ({ ...doc, fullName: `${doc.firstName} ${doc.lastName}` }),
revert: (doc) => {
const [firstName, ...rest] = doc.fullName!.split(" ");
return { schemaVersion: doc.schemaVersion, firstName, lastName: rest.join(" ") };
},
});
const v1doc: UserDoc = { schemaVersion: 1, firstName: "Ada", lastName: "Lovelace" };
const latest = migrator.migrate(v1doc); // { schemaVersion: 2, fullName: "Ada Lovelace", ... }SchemaDoc is any object with a numeric schemaVersion field.
register(transform: Transform<T>): thisTransform:{ from: number, to: number, migrate(doc): doc, revert?(doc): doc }. Forward transforms must step exactly+1(to === from + 1) — this keeps gap detection well-defined. ThrowsSchemaMonotonicityErroron a bad step, or a plainErroron a duplicatefrom.migrate(doc: T, options?: { toVersion?: number }): TWalks the chain fromdoc.schemaVersiontotoVersion(default:latestVersion). Forward iftoVersionis higher, backward (viarevert) if lower. ThrowsSchemaGapErrorif a required step isn't registered, or a plainErrorif a downgrade step has norevert.migrateBatch(docs: T[], options?): { migrated: T[], errors: {index, doc, error}[] }Migrates each document independently; a failure on one doc doesn't stop the rest — failures are collected inerrorsinstead of thrown.latestVersion: number— highest version reachable by the registered chain.
Requiring each forward transform to step by exactly +1 is a deliberate
constraint: it means "is there a path from version N to version M" is
always answerable by walking N, N+1, N+2, ... and checking each step
exists, which is what makes gap detection precise instead of heuristic.
The old unconditional-run bug is why migrate() re-checks the document's
current version before every single step rather than just running
through the registered transform list — a v2 document handed to a
registry that also has a v1→v2 transform must never see that transform
applied to it.
Sponsored by Ferrow
Part of the ferrow-toolkit collection · Sponsored by Ferrow