feat(B.1 #11): versioned settings schema with forward migration

Adds src/lib/utils/settingsMigrations.ts exposing
loadSettingsWithMigration() / saveSettingsWithVersion() around a
{version, data} envelope in localStorage["kon_settings"]. The
migration chain is indexed by destination version so adding a v2
is one MIGRATIONS[2] = (prev) => next entry away from working.

Legacy bare-object settings blobs are treated as v0 and folded into
v1 identically to before — no user-facing reset — but an unreadable
blob now surfaces a single Settings reset toast instead of silently
dropping data.

Covers Handy #602 ('settings reset on update') and unblocks the
other B.2/B.3 SettingsState additions listed in
docs/whisper-ecosystem/workstream-B.md: every subsequent field
lands behind a MIGRATIONS step, so older Kon builds stay readable.

Co-authored-by: jars <jakejars@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-04-21 11:35:07 +00:00
committed by Jake
parent db9e119c1b
commit cc3bffa72c
2 changed files with 160 additions and 4 deletions

View File

@@ -18,6 +18,10 @@ import type {
import { toasts } from "$lib/stores/toasts.svelte.ts"; import { toasts } from "$lib/stores/toasts.svelte.ts";
import { errorMessage } from "$lib/utils/errors.js"; import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js"; import { parseStoredJson } from "$lib/utils/storage.js";
import {
loadSettingsWithMigration,
saveSettingsWithVersion,
} from "$lib/utils/settingsMigrations.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { hasTauriRuntime } from "$lib/utils/runtime.js";
export const page = $state<PageState>({ export const page = $state<PageState>({
@@ -68,9 +72,25 @@ function canUseStorage(): boolean {
function loadSettings(): SettingsState { function loadSettings(): SettingsState {
if (!canUseStorage()) return { ...defaults }; if (!canUseStorage()) return { ...defaults };
// Versioned migration: reads the envelope or an unversioned legacy
// blob, runs the migration chain (currently identity: bare object ->
// v1), and returns the current-shape data. Falls back to defaults
// on corruption — the toast below surfaces the reset to the user.
const migrated = loadSettingsWithMigration<Partial<SettingsState>>(SETTINGS_KEY);
if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) {
// There was data but it failed to migrate — tell the user we
// reset rather than silently mangling their preferences.
queueMicrotask(() => {
toasts.warn(
"Settings reset",
"Your saved settings couldn't be read, so Kon fell back to defaults. "
+ "Earlier Kon builds can still read your old data.",
);
});
}
return { return {
...defaults, ...defaults,
...(parseStoredJson<Partial<SettingsState>>(localStorage.getItem(SETTINGS_KEY)) ?? {}), ...(migrated ?? {}),
}; };
} }
@@ -78,9 +98,11 @@ export const settings = $state<SettingsState>(loadSettings());
export function saveSettings() { export function saveSettings() {
if (!canUseStorage()) return; if (!canUseStorage()) return;
try { // Versioned envelope {version, data}. Older Kon builds that used
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); // the bare-object layout can still spread the `data` key over
} catch {} // their defaults — they will just silently drop fields they don't
// know about, which is the same behaviour as today.
saveSettingsWithVersion(SETTINGS_KEY, { ...settings });
} }
function loadProfiles(): Profile[] { function loadProfiles(): Profile[] {

View File

@@ -0,0 +1,134 @@
/**
* 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<T> {
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<number, MigrationStep> = {
// 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<unknown> {
return !!raw
&& typeof raw === "object"
&& "version" in (raw as Record<string, unknown>)
&& "data" in (raw as Record<string, unknown>)
&& typeof (raw as Record<string, unknown>).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<T>(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<T>(key: string, data: T): void {
if (typeof localStorage === "undefined") return;
const envelope: VersionedSettings<T> = {
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 };