diff --git a/frontend/src/lib/userDraftLegacyMigration.test.ts b/frontend/src/lib/userDraftLegacyMigration.test.ts new file mode 100644 index 0000000000..a6cbfe5469 --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + migrateLegacyUserDrafts, + __resetUserDraftLegacyMigrationForTesting +} from './userDraftLegacyMigration' + +function encodeLegacy(value: unknown): string { + return btoa(encodeURIComponent(JSON.stringify(value))) +} + +function wrapped(value: V): string { + return JSON.stringify({ value }) +} + +beforeEach(() => { + localStorage.clear() + __resetUserDraftLegacyMigrationForTesting() +}) + +describe('migrateLegacyUserDrafts', () => { + it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => { + const legacyApp = { + summary: 'my app', + value: { foo: 'bar' }, + policy: {}, + path: 'u/me/dashboard' + } + localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dashboard')).toBeNull() + expect(localStorage.getItem('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp)) + }) + + it('migrates a legacy empty-path app draft (the `app` literal key)', () => { + const legacyApp = { summary: '', value: {}, policy: {}, path: '' } + localStorage.setItem('app', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app')).toBeNull() + expect(localStorage.getItem('userdraft/w/main/app/')).toBe(wrapped(legacyApp)) + }) + + it('migrates a legacy flow draft and strips the view-state envelope', () => { + const flow = { summary: 'f', value: { modules: [] }, path: 'u/me/myflow' } + const legacyBundle = { + flow, + path: 'u/me/myflow', + selectedId: 'settings', + draft_triggers: [{ id: 't1' }], + selected_trigger: null, + loadedFromHistory: undefined + } + localStorage.setItem('flow-u/me/myflow', encodeLegacy(legacyBundle)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('flow-u/me/myflow')).toBeNull() + // Only the inner Flow survives; the view-state envelope is dropped. + expect(localStorage.getItem('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow)) + }) + + it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => { + const legacy = { + files: { 'index.tsx': 'export default () => null' }, + runnables: {}, + data: { tables: [] } + } + localStorage.setItem('rawapp-u/me/site', encodeLegacy(legacy)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('rawapp-u/me/site')).toBeNull() + expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/site')).toBe( + wrapped({ ...legacy, summary: '' }) + ) + }) + + it('preserves an existing new-format entry instead of overwriting it', () => { + // Old and new both exist for the same item — the new one is presumed + // fresher. + localStorage.setItem('app-u/me/dash', encodeLegacy({ value: 'old' })) + const existingNew = wrapped({ value: 'new' }) + localStorage.setItem('userdraft/w/main/app/u/me/dash', existingNew) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dash')).toBeNull() + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBe(existingNew) + }) + + it('skips legacy keys whose decoded payload does not look like a Windmill draft', () => { + // A co-resident app on the same origin happens to use `app-foo`. We must + // not touch it. + const unrelated = btoa(encodeURIComponent(JSON.stringify({ random: 'data' }))) + localStorage.setItem('app-some_other_app', unrelated) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-some_other_app')).toBe(unrelated) + expect(localStorage.getItem('userdraft/w/main/app/some_other_app')).toBeNull() + }) + + it('is idempotent — the second invocation is a no-op', () => { + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ summary: '', value: {}, policy: {}, path: 'u/me/dash' }) + ) + migrateLegacyUserDrafts('main') + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).not.toBeNull() + + // Drop the migrated entry to detect any re-migration attempt. + localStorage.removeItem('userdraft/w/main/app/u/me/dash') + // Drop the source too, so re-running couldn't even find a source. + // (The sentinel alone should be enough; this just clarifies the intent.) + migrateLegacyUserDrafts('main') + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull() + }) + + it('skips entirely when no workspace is available', () => { + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ summary: '', value: {}, policy: {}, path: 'u/me/dash' }) + ) + migrateLegacyUserDrafts('') + + expect(localStorage.getItem('app-u/me/dash')).not.toBeNull() + }) + + it('handles malformed legacy payloads without throwing', () => { + localStorage.setItem('app-u/me/garbled', 'not-base64!!!') + expect(() => migrateLegacyUserDrafts('main')).not.toThrow() + // Migration didn't migrate, didn't crash — leaves the entry alone. + expect(localStorage.getItem('app-u/me/garbled')).toBe('not-base64!!!') + }) + + it('migrates multiple legacy entries in a single invocation', () => { + localStorage.setItem( + 'app-u/me/a', + encodeLegacy({ summary: '', value: {}, policy: {}, path: 'u/me/a' }) + ) + localStorage.setItem( + 'flow-u/me/b', + encodeLegacy({ flow: { summary: '', value: { modules: [] }, path: 'u/me/b' } }) + ) + localStorage.setItem( + 'rawapp-u/me/c', + encodeLegacy({ files: {}, runnables: {}, data: { tables: [] } }) + ) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('userdraft/w/main/app/u/me/a')).not.toBeNull() + expect(localStorage.getItem('userdraft/w/main/flow/u/me/b')).not.toBeNull() + expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/c')).not.toBeNull() + }) +}) diff --git a/frontend/src/lib/userDraftLegacyMigration.ts b/frontend/src/lib/userDraftLegacyMigration.ts new file mode 100644 index 0000000000..b25330bbae --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.ts @@ -0,0 +1,166 @@ +/** + * One-off migration from the pre-UserDraft localStorage autosave entries to + * the workspace-scoped `userdraft/w/{ws}/{kind}/{path}` format. + * + * Legacy keys (global, not workspace-scoped — assumed to belong to the user's + * current workspace at migration time): + * + * `flow` / `flow-{path}` base64 of `encodeState({ flow, path, selectedId, draft_triggers, ... })` + * `app` / `app-{path}` base64 of `encodeState(App)` + * `rawapp` / `rawapp-{path}` base64 of `encodeState({ files, runnables, data })` + * + * Target keys: `userdraft/w/{workspace}/{flow|app|raw_app}/{path}` storing + * `JSON.stringify({ value: })`. + * + * Idempotent: writes a sentinel under `MIGRATION_FLAG` after the first run so + * subsequent invocations are no-ops. Existing new-format entries are never + * overwritten — when both an old and a new entry exist for the same item, the + * old one is simply dropped on the assumption that the new entry is the more + * recent edit. + * + * This file is intentionally standalone — it does not import from + * `userDraft.svelte.ts` so the new code stays uncluttered by the legacy + * decoders. + */ + +const MIGRATION_FLAG = 'userdraft/legacy_migrated_v1' + +type LegacyKind = 'flow' | 'app' | 'raw_app' + +const LEGACY_PREFIXES: ReadonlyArray<{ prefix: string; newKind: LegacyKind }> = [ + // `rawapp` is listed before `app` even though our matcher uses exact / + // dash-separated comparison (so there's no ambiguity); it documents the + // intent that raw apps are a distinct kind, not a sub-case of apps. + { prefix: 'rawapp', newKind: 'raw_app' }, + { prefix: 'flow', newKind: 'flow' }, + { prefix: 'app', newKind: 'app' } +] + +function matchLegacyKey( + key: string +): { prefix: string; newKind: LegacyKind; path: string } | undefined { + for (const { prefix, newKind } of LEGACY_PREFIXES) { + if (key === prefix) return { prefix, newKind, path: '' } + if (key.startsWith(prefix + '-')) { + return { prefix, newKind, path: key.slice(prefix.length + 1) } + } + } + return undefined +} + +function decodeLegacyState(raw: string): unknown { + try { + return JSON.parse(decodeURIComponent(atob(raw))) + } catch { + return undefined + } +} + +/** + * Defensive shape check. The legacy keys (`app-foo`, `flow-foo`, ...) are + * unusual but not unique to Windmill — a co-resident app on the same origin + * might use the same name space. We require the decoded payload to have at + * least one of the fields the old format wrote. + */ +function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean { + if (decoded == null || typeof decoded !== 'object') return false + const obj = decoded as Record + switch (kind) { + case 'flow': + return 'flow' in obj && obj.flow != null && typeof obj.flow === 'object' + case 'app': + // `$appStore` (an App) was saved directly — it has `summary` / `value` / + // `policy` / `path` etc. The four-of-any check below tolerates pre-1.x + // shapes that didn't carry all four. + return 'summary' in obj || 'value' in obj || 'policy' in obj || 'path' in obj + case 'raw_app': + return 'files' in obj || 'runnables' in obj || 'data' in obj + } +} + +function transformLegacyValue(kind: LegacyKind, decoded: unknown): unknown { + const obj = decoded as Record + switch (kind) { + case 'flow': + // The legacy bundle wrapped the Flow alongside view-state fields + // (selectedId, draft_triggers, ...). The new entry stores only the + // Flow — the view-state lives elsewhere or is re-derived. + return obj.flow + case 'app': + // Legacy stored the App directly. + return obj + case 'raw_app': + // Legacy bundle missed the `summary` field that the new editor adds. + return { + files: obj.files ?? {}, + runnables: obj.runnables ?? {}, + data: obj.data ?? {}, + summary: typeof obj.summary === 'string' ? obj.summary : '' + } + } +} + +function newKey(workspace: string, kind: LegacyKind, path: string): string { + return `userdraft/w/${workspace}/${kind}/${path}` +} + +function listLocalStorageKeys(): string[] { + const out: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null) out.push(k) + } + return out +} + +/** + * Run the legacy → new-format migration. Idempotent: returns immediately if a + * previous run completed (signalled by `MIGRATION_FLAG`). + * + * The migration is workspace-scoped because the legacy keys had no notion of + * workspace — we treat the caller's current workspace as the owner of any + * surviving legacy entries. + */ +export function migrateLegacyUserDrafts(workspace: string): void { + if (typeof localStorage === 'undefined') return + if (!workspace) return + if (localStorage.getItem(MIGRATION_FLAG) !== null) return + + try { + for (const key of listLocalStorageKeys()) { + const match = matchLegacyKey(key) + if (!match) continue + const raw = localStorage.getItem(key) + if (raw == null) continue + + try { + const decoded = decodeLegacyState(raw) + if (!isPlausibleLegacyValue(match.newKind, decoded)) { + // Doesn't smell like a Windmill draft — leave it alone for the + // neighbouring app to deal with. + continue + } + const value = transformLegacyValue(match.newKind, decoded) + const target = newKey(workspace, match.newKind, match.path) + if (value !== undefined && localStorage.getItem(target) == null) { + localStorage.setItem(target, JSON.stringify({ value })) + } + localStorage.removeItem(key) + } catch (e) { + console.error('UserDraft legacy migration: failed to migrate', key, e) + } + } + localStorage.setItem(MIGRATION_FLAG, new Date().toISOString()) + } catch (e) { + console.error('UserDraft legacy migration: aborted', e) + } +} + +/** Test-only: clear the sentinel so the migration can re-run. */ +export function __resetUserDraftLegacyMigrationForTesting(): void { + try { + localStorage.removeItem(MIGRATION_FLAG) + } catch { + // ignore + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index abab3fe20d..646d602434 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -58,6 +58,7 @@ import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' + import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -364,8 +365,8 @@ async function loadCriticalAlertsMuted() { let g_muted = true const ws_muted = - (await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts || - false + (await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })) + .mute_critical_alerts || false if ($superadmin) { g_muted = (await SettingService.getGlobal({ @@ -418,6 +419,9 @@ $effect(() => { $workspaceStore && untrack(() => onLoad()) }) + $effect(() => { + if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!)) + }) $effect(() => { innerWidth && untrack(() => changeCollapsed()) })