From c62070304f222f40b52ca0786260434e62c76712 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 8 Jun 2026 06:16:17 +0530 Subject: [PATCH] feat(migration): add v4-to-v5 settings migration runner with backup --- src/main/updater/migration-runner.js | 38 ++++++++++++++++ src/renderer/lib/migrations/v4-to-v5.ts | 26 +++++++++++ src/renderer/lib/validators.ts | 3 +- tests/unit/lib/migrations/v4-to-v5.test.ts | 32 +++++++++++++ .../main/updater/migration-runner.test.js | 45 +++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/main/updater/migration-runner.js create mode 100644 src/renderer/lib/migrations/v4-to-v5.ts create mode 100644 tests/unit/lib/migrations/v4-to-v5.test.ts create mode 100644 tests/unit/main/updater/migration-runner.test.js diff --git a/src/main/updater/migration-runner.js b/src/main/updater/migration-runner.js new file mode 100644 index 0000000..9f082a3 --- /dev/null +++ b/src/main/updater/migration-runner.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +const path = require('path'); + +class MigrationRunner { + constructor({ dir, transform }) { + this.dir = dir; + this.transform = transform; + this.file = path.join(dir, 'settings.json'); + this.backup = path.join(dir, 'settings.v4.bak.json'); + } + + run() { + if (!fs.existsSync(this.file)) { + this._writeDefaults(); + return 'fresh'; + } + const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8')); + if (raw && raw['migration.version'] === 5) { + return 'skipped'; + } + try { + const v5 = this.transform(raw); + fs.copyFileSync(this.file, this.backup); + fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2)); + return 'migrated'; + } catch (err) { + console.error('[migration-runner] transform failed:', err.message); + return 'failed'; + } + } + + _writeDefaults() { + const v5 = this.transform({}); + fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2)); + } +} + +module.exports = { MigrationRunner }; diff --git a/src/renderer/lib/migrations/v4-to-v5.ts b/src/renderer/lib/migrations/v4-to-v5.ts new file mode 100644 index 0000000..0a41d66 --- /dev/null +++ b/src/renderer/lib/migrations/v4-to-v5.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { settingsSchema } from '@/lib/validators'; + +export const v4SettingsSchema = z.object({ + theme: z.enum(['light', 'dark', 'auto']).default('auto'), + customCss: z.string().optional().nullable(), + recentFiles: z.array(z.string()).default([]), + editorFontSize: z.number().min(10).max(28).default(14), + keyBindings: z.record(z.string(), z.string()).optional(), + snippets: z.array(z.unknown()).default([]), +}); + +export function migrateV4ToV5(v4: unknown): z.infer { + const parsed = v4SettingsSchema.parse(v4); + const defaults = settingsSchema.parse({}); + return { + ...defaults, + ...parsed, + theme: parsed.theme === 'auto' ? 'system' : parsed.theme, + customCssPath: parsed.customCss ?? null, + recentFiles: parsed.recentFiles, + editorFontSize: parsed.editorFontSize, + userBindings: parsed.keyBindings ?? defaults.userBindings ?? {}, + snippets: parsed.snippets, + }; +} diff --git a/src/renderer/lib/validators.ts b/src/renderer/lib/validators.ts index ae40bae..b26fafa 100644 --- a/src/renderer/lib/validators.ts +++ b/src/renderer/lib/validators.ts @@ -6,7 +6,7 @@ export const settingsSchema = z.object({ lineNumbers: z.boolean().default(true), wordWrap: z.boolean().default(true), minimap: z.boolean().default(true), - theme: z.enum(['light', 'dark', 'auto']).default('auto'), + theme: z.enum(['light', 'dark', 'system']).default('system'), accentColor: z.enum(['brand', 'blue', 'green', 'purple', 'orange']).default('brand'), fontFamily: z.enum(['system', 'jetbrains', 'fira']).default('system'), pdfFormat: z.enum(['letter', 'a4', 'legal']).default('a4'), @@ -21,6 +21,7 @@ export const settingsSchema = z.object({ welcomeDismissed: z.boolean().default(false), editorFontSize: z.number().min(10).max(28).default(14), customCssPath: z.string().nullable().default(null), + userBindings: z.record(z.string(), z.string()).default({}), }); export type Settings = z.infer; diff --git a/tests/unit/lib/migrations/v4-to-v5.test.ts b/tests/unit/lib/migrations/v4-to-v5.test.ts new file mode 100644 index 0000000..b37d43c --- /dev/null +++ b/tests/unit/lib/migrations/v4-to-v5.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest'; +import { migrateV4ToV5, v4SettingsSchema } from '@/lib/migrations/v4-to-v5'; + +describe('migrateV4ToV5', () => { + test('maps theme auto to system', () => { + const out = migrateV4ToV5({ theme: 'auto', editorFontSize: 14 }); + expect(out.theme).toBe('system'); + expect(out.editorFontSize).toBe(14); + }); + + test('drops unknown keys, applies defaults', () => { + const out = migrateV4ToV5({ theme: 'light', weirdKey: 1 }); + expect(out.theme).toBe('light'); + expect(out.weirdKey).toBeUndefined(); + expect(out.tabSize).toBe(4); // default + }); + + test('throws on invalid v4 input', () => { + expect(() => migrateV4ToV5({ theme: 42 })).toThrow(); + }); + + test('is idempotent — running twice yields the same v5 output', () => { + const v4 = { theme: 'dark', editorFontSize: 16, recentFiles: ['/a.md'] }; + const a = migrateV4ToV5(v4); + const b = migrateV4ToV5(v4); + expect(a).toEqual(b); + }); + + test('exposes the v4 schema for use by main', () => { + expect(v4SettingsSchema).toBeDefined(); + }); +}); diff --git a/tests/unit/main/updater/migration-runner.test.js b/tests/unit/main/updater/migration-runner.test.js new file mode 100644 index 0000000..7c3cc74 --- /dev/null +++ b/tests/unit/main/updater/migration-runner.test.js @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { MigrationRunner } = require('../../../../src/main/updater/migration-runner'); + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mig-test-')); +} + +describe('MigrationRunner', () => { + test('no-op when migration.version is already 5', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ 'migration.version': 5 })); + const r = new MigrationRunner({ dir, transform: (v) => v }); + expect(r.run()).toBe('skipped'); + fs.rmSync(dir, { recursive: true }); + }); + + test('runs migration on missing v5 marker, writes v5 file, backs up v4', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' })); + const r = new MigrationRunner({ dir, transform: (v) => ({ ...v, theme: 'system', 'migration.version': 5 }) }); + expect(r.run()).toBe('migrated'); + expect(JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']).toBe(5); + expect(fs.existsSync(path.join(dir, 'settings.v4.bak.json'))).toBe(true); + fs.rmSync(dir, { recursive: true }); + }); + + test('returns fresh on missing file', () => { + const dir = tmpDir(); + const r = new MigrationRunner({ dir, transform: () => ({ 'migration.version': 5 }) }); + expect(r.run()).toBe('fresh'); + expect(fs.existsSync(path.join(dir, 'settings.json'))).toBe(true); + fs.rmSync(dir, { recursive: true }); + }); + + test('returns failed and preserves v4 on transform throw', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' })); + const r = new MigrationRunner({ dir, transform: () => { throw new Error('boom'); } }); + expect(r.run()).toBe('failed'); + expect(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8')).toContain('light'); + fs.rmSync(dir, { recursive: true }); + }); +});