feat(frontend): migrate legacy localStorage autosave entries

Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.

The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.
This commit is contained in:
Diego Imbert
2026-05-13 11:53:27 +02:00
parent 9ca787a18b
commit 6d73345c4b
3 changed files with 331 additions and 2 deletions
@@ -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<V>(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()
})
})
@@ -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: <transformed legacy 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<string, unknown>
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<string, unknown>
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
}
}
@@ -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())
})