From 04150131eeee35b21d0a01dc9123eb772d0840e1 Mon Sep 17 00:00:00 2001 From: Cristiano Ventura Date: Wed, 4 Dec 2024 10:00:44 -0500 Subject: [PATCH] feat: settings migration (#371) --- src/components/Settings/SettingsDialog.tsx | 2 +- src/schemas/settings/README.md | 27 +++++++++++++++ src/schemas/settings/index.test.ts | 34 +++++++++++++++++++ src/schemas/settings/index.ts | 30 ++++++++++++++++ .../{appSettings.ts => settings/v1/index.ts} | 23 +++++++++++-- src/schemas/settings/v2/index.ts | 32 +++++++++++++++++ src/settings.ts | 4 +-- src/types/settings.ts | 2 +- 8 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 src/schemas/settings/README.md create mode 100644 src/schemas/settings/index.test.ts create mode 100644 src/schemas/settings/index.ts rename src/schemas/{appSettings.ts => settings/v1/index.ts} (83%) create mode 100644 src/schemas/settings/v2/index.ts diff --git a/src/components/Settings/SettingsDialog.tsx b/src/components/Settings/SettingsDialog.tsx index 7dfa04a0..bea6ff6f 100644 --- a/src/components/Settings/SettingsDialog.tsx +++ b/src/components/Settings/SettingsDialog.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { ProxySettings } from './ProxySettings' -import { AppSettingsSchema } from '@/schemas/appSettings' +import { AppSettingsSchema } from '@/schemas/settings' import { RecorderSettings } from './RecorderSettings' import { AppSettings } from '@/types/settings' import { UsageReportSettings } from './UsageReportSettings' diff --git a/src/schemas/settings/README.md b/src/schemas/settings/README.md new file mode 100644 index 00000000..f92934a4 --- /dev/null +++ b/src/schemas/settings/README.md @@ -0,0 +1,27 @@ +# Settings migration + +Migrations are needed when changing the structure of existing settings file such as renaming or deleting keys. + +If you're simply adding new options to the settings file, a migration is not needed. In this case, extend the latest schema available. + +## Creating a new migration + +For when a new migration is needed: + +1. Create a directory for the new version (e.g. `/v2`). +2. Declare the new schema with appropriate changes. +3. In `/v2/index.ts`, implement a `migrate` function that takes a `v2` schema and returns a `v3` schema. +4. Update `/schemas/settings/index.ts` to use the new version: + +```ts +function migrate(settings: z.infer) { + +case '2.0': + return migrate(v2.migrate(settings)) + +} +``` + +5. Export types from the new version in `/schemas/settings/index.ts`. +6. Update the default settings in `src/settings.ts` according to the new schema. +7. Make changes to the remaining implementation to use the new schema. diff --git a/src/schemas/settings/index.test.ts b/src/schemas/settings/index.test.ts new file mode 100644 index 00000000..8bb9fe00 --- /dev/null +++ b/src/schemas/settings/index.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { migrate } from '.' +import * as v1 from './v1' + +describe('Settings migration', () => { + it('should migrate from v1 to latest', () => { + const v1Settings: v1.AppSettings = { + version: '1.0', + proxy: { + mode: 'regular', + port: 6000, + automaticallyFindPort: true, + }, + recorder: { + detectBrowserPath: true, + }, + windowState: { + width: 1200, + height: 800, + x: 0, + y: 0, + isMaximized: true, + }, + usageReport: { + enabled: true, + }, + appearance: { + theme: 'system', + }, + } + + expect(migrate(v1Settings).version).toBe('2.0') + }) +}) diff --git a/src/schemas/settings/index.ts b/src/schemas/settings/index.ts new file mode 100644 index 00000000..915fb18b --- /dev/null +++ b/src/schemas/settings/index.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import * as v1 from './v1' +import * as v2 from './v2' +import { exhaustive } from '../../utils/typescript' + +const AnySettingSchema = z.discriminatedUnion('version', [ + v1.AppSettingsSchema, + v2.AppSettingsSchema, +]) + +export function migrate(settings: z.infer) { + switch (settings.version) { + case '1.0': + return migrate(v1.migrate(settings)) + case '2.0': + return settings + default: + return exhaustive(settings) + } +} + +export const AppSettingsSchema = AnySettingSchema.transform(migrate) + +export { + AppearanceSchema, + ProxySettingsSchema, + RecorderSettingsSchema, + UsageReportSettingsSchema, + WindowStateSchema, +} from './v2' diff --git a/src/schemas/appSettings.ts b/src/schemas/settings/v1/index.ts similarity index 83% rename from src/schemas/appSettings.ts rename to src/schemas/settings/v1/index.ts index 84bf7df2..6d04b894 100644 --- a/src/schemas/appSettings.ts +++ b/src/schemas/settings/v1/index.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import * as v2 from '../v2' export const RegularProxySettingsSchema = z.object({ mode: z.literal('regular'), @@ -86,7 +87,7 @@ export const RecorderSettingsSchema = z } }) -const WindowStateSchema = z.object({ +export const WindowStateSchema = z.object({ x: z.number().int(), y: z.number().int(), width: z.number().int(), @@ -94,15 +95,31 @@ const WindowStateSchema = z.object({ isMaximized: z.boolean(), }) -const AppearanceSchema = z.object({ +export const AppearanceSchema = z.object({ theme: z.union([z.literal('light'), z.literal('dark'), z.literal('system')]), }) export const AppSettingsSchema = z.object({ - version: z.string(), + version: z.literal('1.0'), proxy: ProxySettingsSchema, recorder: RecorderSettingsSchema, windowState: WindowStateSchema, usageReport: UsageReportSettingsSchema, appearance: AppearanceSchema, }) + +// Migrate settings to the next version +export function migrate( + settings: z.infer +): v2.AppSettings { + return { + version: '2.0', + proxy: settings.proxy, + recorder: settings.recorder, + windowState: settings.windowState, + usageReport: settings.usageReport, + appearance: settings.appearance, + } +} + +export type AppSettings = z.infer diff --git a/src/schemas/settings/v2/index.ts b/src/schemas/settings/v2/index.ts new file mode 100644 index 00000000..758adfbb --- /dev/null +++ b/src/schemas/settings/v2/index.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { + AppearanceSchema, + ProxySettingsSchema, + RecorderSettingsSchema, + UsageReportSettingsSchema, + WindowStateSchema, +} from '../v1' + +export { + AppearanceSchema, + ProxySettingsSchema, + RecorderSettingsSchema, + UsageReportSettingsSchema, + WindowStateSchema, +} + +export const AppSettingsSchema = z.object({ + version: z.literal('2.0'), + proxy: ProxySettingsSchema, + recorder: RecorderSettingsSchema, + windowState: WindowStateSchema, + usageReport: UsageReportSettingsSchema, + appearance: AppearanceSchema, +}) + +export type AppSettings = z.infer + +// TODO: Migrate settings to the next version +export function migrate(settings: z.infer) { + return { ...settings } +} diff --git a/src/settings.ts b/src/settings.ts index f3ae40b7..d2983f72 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,13 +2,13 @@ import { app, dialog } from 'electron' import { writeFile, open } from 'fs/promises' import path from 'node:path' import { AppSettings } from './types/settings' -import { AppSettingsSchema } from './schemas/appSettings' +import { AppSettingsSchema } from './schemas/settings' import { existsSync, readFileSync } from 'fs' import { safeJsonParse } from './utils/json' import log from 'electron-log/main' const defaultSettings: AppSettings = { - version: '1.0', + version: '2.0', proxy: { mode: 'regular', port: 6000, diff --git a/src/types/settings.ts b/src/types/settings.ts index ea2559b4..599dbd3d 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -3,7 +3,7 @@ import { ProxySettingsSchema, RecorderSettingsSchema, UsageReportSettingsSchema, -} from '@/schemas/appSettings' +} from '@/schemas/settings' import { z } from 'zod' export type AppSettings = z.infer