From cff286bd15631f220dafe3c26619472fc29733c8 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 2 Jun 2026 18:37:29 +0200 Subject: [PATCH] feat: migrate localStorage drafts to DB on layout mount --- frontend/src/lib/userDraftDbMigration.ts | 173 ++++++++++++++ .../src/lib/userDraftLegacyMigration.test.ts | 223 ++++++++++++++++++ frontend/src/lib/userDraftLegacyMigration.ts | 192 +++++++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 16 ++ 4 files changed, 604 insertions(+) create mode 100644 frontend/src/lib/userDraftDbMigration.ts create mode 100644 frontend/src/lib/userDraftLegacyMigration.test.ts create mode 100644 frontend/src/lib/userDraftLegacyMigration.ts diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts new file mode 100644 index 0000000000..2a4b9d4461 --- /dev/null +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -0,0 +1,173 @@ +/** + * One-off migration from the localStorage-backed UserDraft autosave to + * the new per-user DB-backed `draft` table. + * + * Runs AFTER `migrateLegacyUserDrafts` (which folds the original + * `flow` / `app-{path}` / `rawapp-{path}` style keys into the + * intermediate `userdraft/w/{workspace}/{kind}/{path}` format). This + * file picks up from that intermediate format and pushes each entry + * over to `POST /drafts/save_draft`, deleting it from localStorage + * only after the POST returns successfully. + * + * Per-entry semantics: + * - A successful save deletes the source key, so on next page load + * it's gone. The migration is idempotent without a sentinel: any + * entry that failed (network error, parse error, ...) is simply + * left in place and retried on the next mount. + * - We do not gate on the current workspace — the key embeds its own + * workspace, and the auth token covers every workspace the user is + * a member of. Migrating only the active workspace would orphan + * entries for any other workspace the user had been editing in. + * + * Intentionally NOT importing from `userDraft.svelte.ts`: this is + * one-way LS-clearing scaffolding, kept here so the runtime module + * stays free of legacy decoders. + */ + +import { DraftService } from './gen' +import type { UserDraftItemKind } from './gen' + +// Mirror of `USER_DRAFT_ITEM_KINDS` from `userDraft.svelte.ts`. Inlined +// here so the migration module can be imported without pulling in the +// reactive runtime. Tested by the type assertion below: the compiler +// rejects this file if a kind is added to the OpenAPI schema but not +// listed here (and vice-versa). +const ITEM_KINDS = [ + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +] as const satisfies readonly UserDraftItemKind[] + +type _Exhaustive = + Exclude extends never ? true : never +const _: _Exhaustive = true +void _ + +const KEY_PREFIX = 'userdraft/w/' + +type ParsedKey = { + key: string + workspace: string + itemKind: UserDraftItemKind + path: string +} + +/** + * Split a `userdraft/w/{workspace}/{kind}/{path}` key into its parts. + * Returns `undefined` for keys that don't match the schema or whose + * kind isn't one we recognize — we ignore those rather than risk + * sending a junk POST. + */ +function parseKey(key: string): ParsedKey | undefined { + if (!key.startsWith(KEY_PREFIX)) return undefined + const rest = key.slice(KEY_PREFIX.length) + const firstSlash = rest.indexOf('/') + if (firstSlash <= 0) return undefined + const workspace = rest.slice(0, firstSlash) + const afterWorkspace = rest.slice(firstSlash + 1) + for (const kind of ITEM_KINDS) { + const kindPrefix = `${kind}/` + if (afterWorkspace.startsWith(kindPrefix)) { + const path = afterWorkspace.slice(kindPrefix.length) + if (!path) return undefined + return { key, workspace, itemKind: kind, path } + } + } + return undefined +} + +/** + * Extract the payload that was stored in localStorage. The LS schema + * was `{ value: V, lastWrittenAt?: number, remoteRev?: ..., ... }`. + * For the migration we only need `value`. Returns `undefined` when the + * slot is empty / unparseable / wrong shape. + */ +function readPayload(key: string): unknown { + try { + const raw = localStorage.getItem(key) + if (raw == null || raw === 'undefined') return undefined + const parsed = JSON.parse(raw) + if (parsed == null || typeof parsed !== 'object' || !('value' in parsed)) return undefined + return (parsed as { value: unknown }).value + } catch { + return undefined + } +} + +function collectKeys(): string[] { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null && k.startsWith(KEY_PREFIX)) keys.push(k) + } + return keys +} + +/** + * Walk every `userdraft/w/{ws}/{kind}/{path}` entry in localStorage, + * push each to `POST /drafts/save_draft` with `force: true`, and remove + * it from LS on a successful response. Entries that fail to migrate + * (parse error, network error, unrecognized kind, ...) stay in LS and + * are retried on the next mount. + * + * Resolves only when every candidate has been attempted. Logs per-entry + * errors but never throws — the caller is fire-and-forget. + */ +export async function migrateUserDraftsToDb(): Promise { + if (typeof localStorage === 'undefined') return + const keys = collectKeys() + if (keys.length === 0) return + + for (const key of keys) { + const parsed = parseKey(key) + if (!parsed) continue + const value = readPayload(key) + if (value === undefined) { + // Unparseable or empty — clear so we don't keep retrying it. + try { + localStorage.removeItem(key) + } catch { + // ignore + } + continue + } + try { + await DraftService.saveDraft({ + workspace: parsed.workspace, + kind: parsed.itemKind, + path: parsed.path, + requestBody: { value, force: true } + }) + try { + localStorage.removeItem(key) + } catch { + // Best-effort. If LS removal fails the next mount retries + // the save (force: true is idempotent). + } + } catch (e) { + // Leave the LS entry in place — the next mount tries again. + console.error('UserDraft LS→DB migration: failed for', key, e) + } + } +} diff --git a/frontend/src/lib/userDraftLegacyMigration.test.ts b/frontend/src/lib/userDraftLegacyMigration.test.ts new file mode 100644 index 0000000000..7069929897 --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.test.ts @@ -0,0 +1,223 @@ +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 }) +} + +// Read a migrated entry, strip the GC `lastWrittenAt` stamp so assertions +// can match the `{ value }` shape regardless of when the migration ran. +function storedShape(key: string): string | null { + const raw = localStorage.getItem(key) + if (raw == null) return null + const parsed = JSON.parse(raw) + delete parsed.lastWrittenAt + return JSON.stringify(parsed) +} + +beforeEach(() => { + localStorage.clear() + __resetUserDraftLegacyMigrationForTesting() +}) + +describe('migrateLegacyUserDrafts', () => { + it('migrates a legacy app draft to the workspace-scoped key with a { value } wrapper', () => { + // Shape mirrors what the legacy AppEditor wrote: `encodeState($appStore)`, + // i.e. the inner App value, not the wrapping AppWithLastVersion. + const legacyApp = { + grid: [], + fullscreen: false, + theme: undefined, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + localStorage.setItem('app-u/me/dashboard', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dashboard')).toBeNull() + expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp)) + }) + + it('migrates a legacy empty-path app draft (the `app` literal key)', () => { + const legacyApp = { + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + localStorage.setItem('app', encodeLegacy(legacyApp)) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app')).toBeNull() + expect(storedShape('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(storedShape('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(storedShape('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({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + 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('is idempotent — the second invocation is a no-op', () => { + localStorage.setItem( + 'app-u/me/dash', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + 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({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + 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('leaves keys whose path does not match the legacy `u|f/owner/name` shape alone', () => { + // A future feature or neighbouring code might pick a key like + // `app-recent` for its own purposes. The path doesn't look like a + // Windmill item path, so the migration must skip it. + localStorage.setItem('app-recent', 'whatever') + localStorage.setItem('app-some_other_app', 'whatever') + // `flow-u/me/foo` matches the shape and would be migrated, but the + // payload also needs to look like a Windmill draft (asserted below). + localStorage.setItem('flow-u/me/foo', encodeLegacy({ flow: { value: { modules: [] } } })) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-recent')).toBe('whatever') + expect(localStorage.getItem('app-some_other_app')).toBe('whatever') + expect(localStorage.getItem('userdraft/w/main/flow/u/me/foo')).not.toBeNull() + }) + + it('skips legacy-shaped keys whose payload does not look like a Windmill draft', () => { + // `app-u/me/dash` matches LEGACY_PATH_SHAPE and decodes to valid JSON, + // but none of the App-shape fields (grid/fullscreen/theme/ + // unusedInlineScripts/hiddenInlineScripts) are present. Treat it as + // unrelated and leave it untouched. + const unrelated = encodeLegacy({ random: 'data', count: 7 }) + localStorage.setItem('app-u/me/dash', unrelated) + const unrelatedFlow = encodeLegacy({ stepsState: {} }) + localStorage.setItem('flow-u/me/bar', unrelatedFlow) + + migrateLegacyUserDrafts('main') + + expect(localStorage.getItem('app-u/me/dash')).toBe(unrelated) + expect(localStorage.getItem('userdraft/w/main/app/u/me/dash')).toBeNull() + expect(localStorage.getItem('flow-u/me/bar')).toBe(unrelatedFlow) + expect(localStorage.getItem('userdraft/w/main/flow/u/me/bar')).toBeNull() + }) + + it('migrates multiple legacy entries in a single invocation', () => { + localStorage.setItem( + 'app-u/me/a', + encodeLegacy({ + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + }) + ) + 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..d4ee04ee61 --- /dev/null +++ b/frontend/src/lib/userDraftLegacyMigration.ts @@ -0,0 +1,192 @@ +/** + * 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' } +] + +/** + * A Windmill item path: `u//` or `f//`. The + * `` segment may itself contain slashes, so we don't constrain it + * past requiring at least one character. Used to reject incidentally-named + * localStorage keys (e.g. `app-recent` from a future feature, or a + * neighbouring app's data) before treating them as Windmill drafts. + */ +const LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/ + +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 + '-')) { + const path = key.slice(prefix.length + 1) + if (!LEGACY_PATH_SHAPE.test(path)) return undefined + return { prefix, newKind, path } + } + } + return undefined +} + +function decodeLegacyState(raw: string): unknown { + try { + return JSON.parse(decodeURIComponent(atob(raw))) + } catch { + return undefined + } +} + +/** + * Per-kind shape gate. The legacy keys (`app-foo`, `flow-foo`, ...) are + * unusual enough that nothing else in the codebase has used them, but + * matching `LEGACY_PATH_SHAPE` doesn't prove the payload is actually a + * Windmill draft (any base64-of-JSON could pass). Promoting a stray payload + * would silently surface as a phantom "Restored from local storage" toast + * on the next edit, so we reject anything that doesn't carry the fields the + * legacy writers actually produced. + */ +function isPlausibleLegacyValue(kind: LegacyKind, decoded: unknown): boolean { + if (decoded == null || typeof decoded !== 'object') return false + const obj = decoded as Record + switch (kind) { + case 'flow': + // Legacy FlowBuilder wrote { flow, path, selectedId, draft_triggers, ... }. + return obj.flow != null && typeof obj.flow === 'object' + case 'app': + // Legacy AppEditor wrote `encodeState($appStore)`, i.e. the inner App + // value (see `frontend/src/lib/components/apps/types.ts`) — NOT the + // wrapping AppWithLastVersion. It carries `grid`, `fullscreen`, + // `theme`, `unusedInlineScripts`, `hiddenInlineScripts` among other + // fields — any one of those is a strong signal it's actually a + // Windmill app payload. + return ( + 'grid' in obj || + 'fullscreen' in obj || + 'theme' in obj || + 'unusedInlineScripts' in obj || + 'hiddenInlineScripts' in obj + ) + case 'raw_app': + // Legacy RawAppEditor wrote { files, runnables, data }. + 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)) continue + const value = transformLegacyValue(match.newKind, decoded) + const target = newKey(workspace, match.newKind, match.path) + if (value !== undefined && localStorage.getItem(target) == null) { + // `lastWrittenAt` makes the migrated entry visible to + // `gcUserDrafts`. We stamp it as "now" so a freshly-migrated + // autosave gets the full retention window — sweeping it + // immediately on the first GC pass would lose work the + // legacy migration just rescued. + localStorage.setItem(target, JSON.stringify({ value, lastWrittenAt: Date.now() })) + } + 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 b60fcf88bf..bab8e8f4b0 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -58,6 +58,8 @@ 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 { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -422,6 +424,20 @@ $effect(() => { $workspaceStore && untrack(() => onLoad()) }) + // One-shot UserDraft migration chain. `migrateLegacyUserDrafts` folds + // the legacy `flow` / `app-…` / `rawapp-…` LS keys into the + // `userdraft/w/{ws}/{kind}/{path}` format; `migrateUserDraftsToDb` + // then pushes those onto the server-side draft table and clears LS + // on success. The order matters — the second step only sees what + // the first one normalized. + $effect(() => { + if ($workspaceStore) { + untrack(() => { + migrateLegacyUserDrafts($workspaceStore!) + void migrateUserDraftsToDb() + }) + } + }) $effect(() => { innerWidth && untrack(() => changeCollapsed()) })