Gathering detailed insights and metrics for zod-migrations
Gathering detailed insights and metrics for zod-migrations
Gathering detailed insights and metrics for zod-migrations
Gathering detailed insights and metrics for zod-migrations
@ryniaubenpm/eaque-eos-reiciendis
> **MSW 2.0 is finally here! 🎉** Read the [Release notes](https://github.com/ryniaubenpm/eaque-eos-reiciendis/releases/tag/v2.0.0) and please follow the [**Migration guidelines**](https://@ryniaubenpm/eaque-eos-reiciendisjs.io/docs/migrations/1.x-to-2.x)
prisma-package
Versioned npm package with Prisma schema, migrations, generated client and trimmed Zod schemas for Polinate projects.
npm install zod-migrations
Typescript
Module System
Node Version
NPM Version
Cumulative downloads
Total Downloads
Last Day
0%
NaN
Compared to previous day
Last Week
0%
NaN
Compared to previous week
Last Month
0%
NaN
Compared to previous month
Last Year
0%
NaN
Compared to previous year
Zod Migrations is like database migrations but for your zod schemas.
The idea for this library came from This Article. If you're interested in the differences between this and Cambria, I've written a little bit about that here.
The problem with unstructured data is that our business logic is often tied to the structure of the data, even if that structure is not enforced by the database. This means that when the structure of the data changes, we need to update our business logic to match. This can be a pain, especially if the data is being used in multiple places in our codebase.
For example, let's say that we are storing a person object in our database as a JSON blob (probably not a great idea btw, unless you have a good reason... at Remenu.io we did).
1{ 2 "name": "John Doe", 3 "age": 30, 4 "email": "" 5}
At the time we wrote this code, the type for a Person
object might look something like this:
1type Person = { 2 name: string; 3 age: number; 4 email: string; 5};
And a function that uses this object might look like this:
1function PersonCard({ person }: { person: Person }) { 2 return ( 3 <div> 4 <h1>{person.name}</h1> 5 <p>{person.age}</p> 6 <p>{person.email}</p> 7 </div> 8 ); 9}
Now let's say our boss says that we need to add a phone
field to the Person
object. And we need to change having a name to having a first name and a last name.
Now our Person
object looks like this:
1type Person = { 2 firstName: string; 3 lastName: string; 4 age: number; 5 email: string; 6 phone: string; 7};
So in change we update our PersonCard
function to look like this:
1function PersonCard({ person }: { person: Person }) { 2 return ( 3 <div> 4 <h1> 5 {person.firstName} {person.lastName} 6 </h1> 7 <p>{person.age}</p> 8 <p>{person.email}</p> 9 <p>{person.phone}</p> 10 </div> 11 ); 12}
but UH OH, we forgot to update the database! Now we have a bunch of Person
objects in the database that are missing the phone
field and have a name
field instead of firstName
and lastName
.
There are other solutions to this problem, that you can look at here, but this library is a solution that I think is pretty cool.
This library allows you to define a schema for your JSON data using, and build a transformer for it using a ZodMigrator
instance. My favorite way of thinking about a ZodMigrator
instance is that it is like a migration file for your JSON data.
Here is an example of how you might use this library to solve the problem above:
Your zod schema should ALWAYS look like the current state of your data. This is because the schema is used to validate the data, and if the schema doesn't match the data, then the data is invalid.
At first our zod schema might look like this:
1const personSchema = z.object({ 2 name: z.string(), 3 age: z.number(), 4 email: z.string(), 5});
But after the changes, it should look like this:
1const personSchema = z.object({ 2 firstName: z.string(), 3 lastName: z.string(), 4 age: z.number(), 5 email: z.string(), 6 phone: z.string(), 7});
Let's change our diction a little bit here and separate person schema into 2 schemas. One for the initial person object, and one for the CURRENT person object.
1const initialPersonSchema = z.object({ 2 name: z.string(), 3 age: z.number(), 4 email: z.string(), 5}); 6 7const currentPersonSchema = z.object({ 8 firstName: z.string(), 9 lastName: z.string(), 10 age: z.number(), 11 email: z.string(), 12 phone: z.string(), 13});
now we need to build a ZodMigrator
instance that transforms the initialPersonSchema
to the currentPersonSchema
1const personMigrator = createZodMigrator({
2 startingSchema: initialPersonSchema,
3 endingSchema: currentPersonSchema,
4});
This is not yet a valid ZodMigrator
instance, because we haven't told the migrator how to evolve an old shape yet. You can assert this in your typesystem by doing something like this:
1import { ZodMigratorCurrentShape } from "zod-migrations"; 2import { Equals } from "ts-toolbelt"; // or use your type equality checker 3 4type CurrentEvolution = ZodMigratorCurrentShape<typeof personMigrator>; 5type CurrentSchema = ZodMigrationSchema<typeof personMigrator>; 6 7function assertValidMigrator(): 1 { 8 // Should be red if you don't tell your migrator how to evolve an old shape 9 return 1 as Equals<CurrentEvolution, CurrentSchema>; 10}
Or we've also provided a utility that accomplishes this for you
1import { IsZodMigratorValid } from "zod-migrations"; 2 3function assertValidMigrator(): true { 4 return true as IsZodMigratorValid<typeof personMigrator>; 5}
In our case we...
note: we're doing this as a change and not a drop so we can set the new value for first name to be the old value for name
renamed the name
field to firstName
Added a lastName
field defaulting to an empty string
Added a phone
field defaulting to an empty string
To Evolve our schema we can simply do this:
1const personMigrator = createZodMigrator({
2 startingSchema: initialPersonSchema,
3 endingSchema: currentPersonSchema,
4})
5 .rename({
6 source: "name",
7 destination: "firstName",
8 })
9 .addMany({
10 defaultValues: {
11 lastName: "",
12 phone: "",
13 },
14 schema: z.object({
15 lastName: z.string(),
16 phone: z.string(),
17 }),
18 });
note: now you should see your validation function have no static errors
We can now use the personMigrator
instance to transform our data from any valid old shape to the new shape.
1personMigrator.transform({ 2 name: "Jon", 3 age: 30, 4 email: "jon@jon.com", 5}); // { firstName: "Jon", lastName: "", age: 30, email: "jon@jon.com", phone: "" }
Manually we can create a version safe schema simply by doing this:
1const versionSafePersonSchema = z.preprocess( 2 // this will take any old version of the person object and transform it to the new version 3 personMigrator.transform, 4 personSchema 5);
Now if we parse with our versionSafePersonSchema
we can be sure that the data will be in the correct format before parsing. All older versions of the data will be transformed to the new version before being parsed.
1versionSafePersonSchema.parse({ 2 name: "Jon", 3 age: 30, 4 email: "jon@doe.com", 5}); // { firstName: "Jon", lastName: "", age: 30, email: "jon@doe.com", phone: "" } 6 7versionSafePersonSchema.parse({ 8 firstName: "Jon", 9 lastName: "Jon", 10 age: 30, 11 email: "jon@doe.com", 12 phone: "555-555-5555", 13}); // { firstName: "Jon", lastName: Doe"", age: 30, email: "jon@doe.com", phone: "555-555-5555" }
For convenience, we can also use the built in safeSchema
method to do this for us. This method should also return never if the migrator is not valid, meaning that you'll get typesafety here as well. Note: it can be a bit harder to debug the error this way, which is why for now I reccomend using the IsZodMigratorValid
, CurrentZodMigratorShape
and z.infer
utilities.
1const versionSafePersonSchema = personMigrator.safeSchema();
This library works by applying a series of transformation objects that we call Mutators
, each mutator has some properties that define how it transforms the data. But the long story short is that when you dump an input in to be transformed, we take EACH mutator and figure out:
When we register the mutators we apply like so:
1function registerMutator(mutator: Mutator) {
2 mutator.beforeMutate({
3 paths: this.paths,
4 });
5
6 this.paths = mutator.rewritePaths(this.paths);
7 this.renames = mutator.rewriteRenames({ renames: this.renames });
8
9 this.mutators.push(mutator);
10}
Then when we transform the data we do something like this:
1transform(input){ 2 const mutators = this.mutators.filter(getAllInvalidMutators); 3 4 for (let mutator of mutators) { 5 input = mutator.up(input); 6 } 7}
This is a very simple way to do things, and it's not quite optimized for performance. But it's likely that it will be fine for many use cases. If you're a performance junkie, but as you're about to see, if you care about performance, we can gain alot of performance gains by using the stringify method (not stable yet).
The way that this library works is by applying a series of transformations to the data. If you want you can just apply ALL transformations to every object, it's not optimized but will likely be fine in most cases, but if you're a performance junkie there's a trick we use to speed things up.
The ZodMigrator
instance has a stringify
method that tags the data with a version number. This version number is used to determine if the data needs to be transformed. If the version number is the same or higher than the cycle of the transformations, then the data does not need to be transformed.
Under the hood it works like this:
1const personMigrator = new ZodMigrator() 2 // Set Up the Initial Fields 3 .add({ 4 path: "name", 5 schema: z.string(), 6 default: "", 7 }) // version 1 8 .add({ 9 path: "age", 10 schema: z.number(), 11 default: 0, 12 }) // version 2 13 .add({ 14 path: "email", 15 schema: z.string(), 16 default: "", 17 }) // version 3 18 .rename({ 19 source: "name", 20 destination: "firstName", 21 }) // version 4 22 .add({ 23 path: "lastName", 24 schema: z.string(), 25 default: "", 26 }) // version 5 27 .add({ 28 path: "phone", 29 schema: z.string(), 30 default: "", 31 }); // version 6
When we store our data we can tag it with the version number that we are on. This way we can avoid transforming the data if it is already in the correct format:
1await storeJSONData(personMigrator.stringify(data));
which will store data something like this
1{ 2 "name": "Jon", 3 "age": 30, 4 "email": "jon@jon.com" 5 "_zevo_version": 3 6}
Then when we retrieve data, we just need to make sure we don't strip out that _zevo_version
field with zod, so we have to modify our schema to look like this:
1const versionSafePersonSchema = z.preprocess( 2 personMigrator.transform, 3 personSchema.passthrough() // let's other keys in 4);
and now, we have a more performant transformer!
In many of our workflows, we already have nested zod schemas that are decoupled from each other. It doesn't always make sense to have a single schema that represents the entire object. In these cases, you can use the register
method to transform nested objects.
This library was built with Remenu.io in mind, and we use it to transform our JSON data before parsing it with zod. We have found it to be a very useful tool for managing changes to our JSON data.
Our data structure looks a little bit like this:
1const itemSchema = z.object({ 2 id: z.string(), 3 name: z.string(), 4 price: z.number(), 5}); 6const menuSchema = z.object({ 7 id: z.string(), 8 name: z.string(), 9 items: z.array(itemSchema), 10});
To account for changes to the itemSchema
we can use the register
method to transform the itemSchema
according to it's own ZodMigrator
that way these schemas can evolve independently kind of like tables in a database.
1const itemEvolver = new ZodMigrator() 2 .add({ 3 path: "id", 4 schema: z.string(), 5 defaultVal: "", 6 }) 7 .add({ 8 path: "name", 9 schema: z.string(), 10 defaultVal: "", 11 }) 12 .add({ 13 path: "price", 14 schema: z.number(), 15 defaultVal: 0, 16 }); 17 18const menuEvolver = new ZodMigrator() 19 .add({ 20 path: "id", 21 schema: z.string(), 22 defaultVal: "", 23 }) 24 .add({ 25 path: "name", 26 schema: z.string(), 27 defaultVal: "", 28 }) 29 .addNestedArray({ 30 path: "items", 31 schema: z.array(itemSchema), 32 });
should look something like this
1const evoSchema = createZodMigrations({ 2 startingSchema, 3 endingSchema, 4}) 5 .add({ 6 name: "status", 7 schema: z.enum("active", "inactive", "poorly-named"), 8 default: "inactive", 9 }) 10 .changeEnum({ 11 path: "status", 12 type: "remove", 13 values: [{ name: "poorly-named", defaultTo: "inactive" }], 14 }) 15 .changeEnum({ 16 path: "status", 17 type: "add", 18 values: ["in-progress"], 19 }) 20 .changeEnum({ 21 path: "status", 22 type: "change", 23 values: { 24 active: "todo", 25 inactive: "done", 26 }, 27 });
Right now transforms only go forward, but in theory there's a use case to have backwards transforms as well. In other words, if this is to be used in distributed systems, it's possible that you might want to transform data back to a previous version from a newer version as well.
It may be nice for some folks to have something like this available:
1const evoSchema = createZodMigrations({...}) 2 .add({ 3 name: "name", 4 schema: z.string(), 5 default: "", 6 }) 7 .add({ 8 name: "age", 9 schema: z.number(), 10 default: 0, 11 }) 12 .remove("age") 13 .upTo(2);
This would represent the Zod Migrator before age got removed. Then your transformer would take a version 3 object and transform it back to a version 2 object.
1{ 2 "name": "Jon" 3} 4// Turns into 5{ 6 "name": "Jon", 7 "age": 0 8}
This could be super useful in distributed applications where you might want to transform data back to a previous version.
Perhaps a format that looks something like this:
1restaurant 2 - add: 3 path: name 4 defaultValue: "" 5 zodType: string 6 - addNestedArray: 7 schema: item 8 path: items 9item 10 - add: 11 path: name 12 defaultValue: "" 13 zodType: string 14
Cambria is a library for defining transformations, this is a library for defining transformations. The difference is with Zod Migrator you define your transformations using zod schemas, which is a library for defining schemas. This means that you can use the same schema to validate your data and transform it.
This means:
Cambria tracks changes by using a graph data structure to represent the shape of the data. This is a very powerful way to track changes, but it is also very complex.
The Pros:
For example you can do things like this very smoothly in Cambria
go from this
1{ 2 "name": "jon", 3 "stuff": { 4 "age": 1, 5 "graduationYear": 2011 6 } 7}
to this with one migration
1{ 2 "name": "jon", 3 "age": 1, 4 "graduationYear": 2011 5}
The Cons:
Zod Migrator uses a much simpler approach to track changes, which is to apply a series of transformations to the data, then run those transformations back and forth to get the final result. But in order to know which changes to skip, it tags the data with a version number.
The Pros:
note: this may not always be a good thing, but in my case, since I'm using this to sync JSON with SQL tables, I think it's a good thing
The Cons:
You may run into type instantiation issues, this is because fluent interfaces are hard to type. If you run into this issue, you can use the consolidate
method to dump a type in at a moment in the chain. Look in the instantiation tests
for an example of how to accopmlish this.
No vulnerabilities found.
No security vulnerabilities found.