grafana / k6-studio

Desktop application for Mac and Windows (Linux coming soon) designed to help you generate k6 test scripts
GNU Affero General Public License v3.0
129 stars 1 forks source link

Settings migration #265

Open cristianoventura opened 2 weeks ago

cristianoventura commented 2 weeks ago

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:

{
   version: '1.0.0',
   proxy: {
--     mode: 'regular'
++     type: 'regular',
   }
}

The new application version that introduces this change should detect and automatically rename it. The same applies to:

allansson commented 1 week 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:

  1. Create a 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).
  2. Make the necessary changes to the v3 schema files.
  3. 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)
    }
  4. 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.