/** * Forward-compatible, versioned settings migration. * * Historically, `localStorage["kon_settings"]` was a bare JSON object * (whatever `SettingsState` looked like at write time). That shape * tolerates new fields cleanly — spread over `defaults` — but does not * survive: * - renaming a field, even with a trivial default mapping * - a type change on an existing field * - a settings reset the user explicitly did not ask for (Handy #602) * * Versioned migrations fix all three. Every schema change bumps the * version constant and lands a `(from, to)` step that takes the stored * raw blob (or its previous-version transform) and returns the * next-version shape. `loadSettingsWithMigration` walks the chain * until it reaches `CURRENT_SETTINGS_VERSION`. * * Covers docs/whisper-ecosystem/brief.md item #11. */ import { parseStoredJson } from "$lib/utils/storage"; export const CURRENT_SETTINGS_VERSION = 1; /** Envelope written to localStorage. The raw blob is always kept * separately so a downgrade (user jumps to an older Kon build) can * still read v1 data even after we've bumped to v2. */ export interface VersionedSettings { version: number; data: T; } export type MigrationStep = (prev: unknown) => unknown; /** * The migration chain. Add an entry per schema change. * * Each step is indexed by its destination version — MIGRATIONS[2] is * the function that takes v1 data and returns v2 data. To add v2: * * MIGRATIONS[2] = (prev) => ({ ...(prev as object), newField: 42 }); * * Keep the steps pure — they run on load, every boot. */ export const MIGRATIONS: Record = { // v0 -> v1: fold a raw bare-object blob (pre-versioning) into the // versioned envelope. Spreading over the caller's `defaults` is the // caller's job; this step just preserves whatever keys were there. 1: (prev) => (prev && typeof prev === "object" ? prev : {}), }; function isVersionedEnvelope(raw: unknown): raw is VersionedSettings { return !!raw && typeof raw === "object" && "version" in (raw as Record) && "data" in (raw as Record) && typeof (raw as Record).version === "number"; } /** * Read `localStorage[key]`, run the migration chain, and return the * data blob at the current schema version. Callers then spread it * over their `defaults` to produce a full `SettingsState`. * * Corrupt / unparseable input returns `null` — caller falls back to * defaults. */ export function loadSettingsWithMigration(key: string): T | null { if (typeof localStorage === "undefined") return null; const raw = localStorage.getItem(key); if (!raw) return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } let version: number; let data: unknown; if (isVersionedEnvelope(parsed)) { version = parsed.version; data = parsed.data; } else { version = 0; data = parsed; } // Apply each migration step from (version + 1) up to the current // schema version. A missing step between two known versions is a // programming error — we warn and return null so the caller falls // back to defaults instead of silently skipping. for (let v = version + 1; v <= CURRENT_SETTINGS_VERSION; v++) { const step = MIGRATIONS[v]; if (!step) { console.warn( `settingsMigrations: missing step to v${v} (loaded v${version}, target v${CURRENT_SETTINGS_VERSION})`, ); return null; } data = step(data); } return data as T; } /** * Serialise the current settings object into a versioned envelope and * write it to localStorage. Idempotent — writing the same data twice * produces the same bytes. */ export function saveSettingsWithVersion(key: string, data: T): void { if (typeof localStorage === "undefined") return; const envelope: VersionedSettings = { version: CURRENT_SETTINGS_VERSION, data, }; try { localStorage.setItem(key, JSON.stringify(envelope)); } catch { // Quota exceeded / private-mode localStorage — drop the write // rather than throwing into the UI. Settings are regenerated from // memory on next save, or from defaults on next boot. } } /** * Back-compat shim: older call sites used `parseStoredJson` directly. * When a caller hasn't migrated yet, they can keep using this for * non-versioned blobs without disturbing the settings schema. */ export { parseStoredJson };