Open cristianoventura opened 1 month ago
I just wanted to chime in a bit with a pattern that's quite useful for migrations. It works really well with zod.
Schemas are structured so that each version is stored in its own directory, like so:
- schemas/
- settings/
- v1/
- index.ts
- otherSchema.ts
- v2/
- index.ts
- otherSchema.ts
- addedSchema.ts
- index.ts
- generatorFile/
- ...
The schemas/settings/index.ts
looks like this:
import { z } from "zod"
import * as v1 from "./v1"
import * as v2 from "./v2"
const AnySettingsSchema = z.discriminatedUnion("version", [
v1.SettingsSchema,
v2.SettingsSchema
])
function migrate(settings: z.infer<typeof AnySettingsSchema>): v2.SettingsSchema {
// Every version that's not the latest versions should export a `migrate` function that
// upgrades it to the _next_ version, e.g. from 1 to 2, 2 to 3, etc.
switch(settings.version) {
// We call the `migrate` function recursively, which causes us to upgrade from v1 to v2,
// v2 to v3, v3 to v4, etc. until we reach the current version which just returns the value
// itself.
case "1.0.0":
return migrate(v1.migrate(settings))
// If we get here, then the settings are at the latest version so just return them.
case "2.0.0":
return settings
default:
return exhaustive(settings)
}
}
// We use `transform` to apply the migration. From the viewpoint of other code, there's only
// one version of the schema and it's the one in `schemas/settings/index.ts`.
export const SettingsSchema = AnySettingsSchema.transform(migrate)
export type Settings = z.infer<typeof SettingsSchema>
export type {
SomeAuxType
} from "./v2"
When code needs to import the schema or its types it should always import them from the root index.ts
and never from the version specific folders. In grafana-k6-app we enforce this via no-restricted-modules eslint rule.
Let's say we want to add a new version to the above. The steps would be:
v3
folder and copy the entire v2
schema into it (Copying everything is just a safe-guard against inadvertently updating older versions of the schemas).v3
schema files.In ./v2/index.ts
, implement a migrate
function that takes a v2 schema and returns a v3 schema:
import * as v3 from "../v3"
// ...
export function migrate(settings: Settings): v3.Settings {
return doSomeMigrating(settings)
}
Update the settings/index.ts
file to use the v3
schema:
import { z } from "zod"
import * as v1 from "./v1"
import * as v2 from "./v2"
import * as v3 from "./v3" // Import new version
const AnySettingsSchema = z.discriminatedUnion("version", [
v1.SettingsSchema,
v2.SettingsSchema,
v3.SettingsSchema // Add to the available versions
])
// We change the return type to `v3.SettingsSchema`.
function migrate(settings: z.infer<typeof AnySettingsSchema>): v3.SettingsSchema {
switch(settings.version) {
case "1.0.0":
return migrate(v1.migrate(settings))
// Call the new migrate function which takes the settings to v3.
case "2.0.0":
return migrate(v2.migrate(settings))
// This is now the latest version.
case "3.0.0":
return settings
default:
return exhaustive(settings)
}
}
export const SettingsSchema = AnySettingsSchema.transform(migrate)
export type Settings = z.infer<typeof SettingsSchema>
// Update the export here to use v3
export type {
SomeAuxType,
NewAuxType
} from "./v3"
This pattern also works with other schemas such as the format of generator files.
We also need to consider this for generators.
We need to implement a mechanism that allows us to migrate the version of the settings schema to be backward/forward compatible.
Example:
If we release a new version of k6 Studio that changes the current settings schema with the following:
The new application version that introduces this change should detect and automatically rename it. The same applies to: