mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
refactor: drop vestigial LS-era code from UserDraft
This commit is contained in:
@@ -50,18 +50,6 @@ export type UserDraftOptions = {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type UserDraftUseOptions<V> = UserDraftOptions & {
|
||||
/**
|
||||
* Initial in-memory value used when no draft has been seeded yet.
|
||||
* No longer eagerly persisted: persistence is the editor's job (it
|
||||
* fetches the draft from the backend via `get_draft=true` and seeds
|
||||
* the handle via `setDraftAndMeta`). This option is kept for the
|
||||
* rare callers that want a synchronous fallback while the editor
|
||||
* load is in flight.
|
||||
*/
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
export type UserDraftListOptions = UserDraftOptions & {
|
||||
itemKinds?: readonly UserDraftItemKind[]
|
||||
}
|
||||
@@ -71,11 +59,10 @@ export type UserDraftListOptions = UserDraftOptions & {
|
||||
* handle for. The shape mirrors `use()`'s arguments, just bundled into
|
||||
* one object so a getter can return a list of them.
|
||||
*/
|
||||
export type UserDraftSpec<V> = {
|
||||
export type UserDraftSpec = {
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
workspace?: string
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +123,6 @@ export type UserDraftEntry<V = unknown> = {
|
||||
path: string
|
||||
value: V | undefined
|
||||
meta: UserDraftMeta
|
||||
live: boolean
|
||||
}
|
||||
|
||||
export type LiveEditorDraft = {
|
||||
@@ -330,27 +316,6 @@ export const UserDraft = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Autosave gate: persist `value` only when it differs (after
|
||||
* `normalizeForCompare`) from the `deployed` baseline; otherwise
|
||||
* remove any draft. Without this, opening and closing an editor with
|
||||
* no edits would leave a no-op draft that restore guards treat as
|
||||
* unsaved work.
|
||||
*/
|
||||
saveIfChanged<V>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: V,
|
||||
deployed: V | undefined,
|
||||
opts?: UserDraftOptions
|
||||
): void {
|
||||
if (deepEqual(normalizeForCompare(value), normalizeForCompare(deployed))) {
|
||||
UserDraft.remove(itemKind, path, opts)
|
||||
} else {
|
||||
UserDraft.save(itemKind, path, value, opts)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the current draft value from the in-memory cell. Returns
|
||||
* `undefined` when no editor has mounted a handle for this
|
||||
@@ -455,8 +420,7 @@ export const UserDraft = {
|
||||
itemKind: entry.itemKind,
|
||||
path: entry.path,
|
||||
value: snapshotDraftValue(unwrap(stored)),
|
||||
meta: extractMeta(stored),
|
||||
live: true
|
||||
meta: extractMeta(stored)
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -524,7 +488,7 @@ export const UserDraft = {
|
||||
use<V = unknown>(
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
opts?: UserDraftUseOptions<V>
|
||||
opts?: UserDraftOptions
|
||||
): UserDraftHandle<V> {
|
||||
// `use()` is a single-spec wrapper around `useMany`. We untrack
|
||||
// the getter so reactive opts (e.g. `$workspaceStore`) are
|
||||
@@ -533,19 +497,12 @@ export const UserDraft = {
|
||||
// unmounts." Use `useMany` directly if you want spec changes to
|
||||
// release/acquire entries as you go.
|
||||
const handles = UserDraft.useMany<V>(() =>
|
||||
untrack(() => [
|
||||
{
|
||||
itemKind,
|
||||
path,
|
||||
workspace: opts?.workspace,
|
||||
defaultValue: opts?.defaultValue
|
||||
}
|
||||
])
|
||||
untrack(() => [{ itemKind, path, workspace: opts?.workspace }])
|
||||
)
|
||||
return handles[0]
|
||||
},
|
||||
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec<V>[]): UserDraftHandle<V>[] {
|
||||
useMany<V = unknown>(getSpecs: () => UserDraftSpec[]): UserDraftHandle<V>[] {
|
||||
// Reactive handles array, reconciled against the latest
|
||||
// `getSpecs()` output. Indices line up with the spec array.
|
||||
// Handles for the same `(workspace, kind, path)` tuple are
|
||||
@@ -567,7 +524,7 @@ export const UserDraft = {
|
||||
seen.add(mk)
|
||||
|
||||
if (!acquired.has(mk)) {
|
||||
acquireEntry(ws, spec.itemKind, spec.path, spec.defaultValue)
|
||||
acquireEntry(ws, spec.itemKind, spec.path)
|
||||
acquired.add(mk)
|
||||
}
|
||||
let handle = handleCache.get(mk)
|
||||
@@ -613,12 +570,7 @@ export const UserDraft = {
|
||||
}
|
||||
}
|
||||
|
||||
function acquireEntry(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string,
|
||||
defaultValue: unknown
|
||||
): void {
|
||||
function acquireEntry(workspace: string, itemKind: UserDraftItemKind, path: string): void {
|
||||
const mk = mapKey(workspace, itemKind, path)
|
||||
const existing = entries.get(mk)
|
||||
if (existing) {
|
||||
@@ -631,9 +583,7 @@ function acquireEntry(
|
||||
// reconcile.
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
const cell = $state<{ val: StoredDraft<unknown> | undefined }>({
|
||||
val: wrap(defaultValue)
|
||||
})
|
||||
const cell = $state<{ val: StoredDraft<unknown> | undefined }>({ val: undefined })
|
||||
stateRef = cell
|
||||
// Mirror every observable change of `cell.val` to the DB
|
||||
// syncer. Reading `cell.val` alone only subscribes to the proxy
|
||||
@@ -697,7 +647,7 @@ function acquireEntry(
|
||||
// isn't invoked. Unreachable in production (Svelte runs it
|
||||
// synchronously). The fallback cell has no sync effect, so writes
|
||||
// in tests stay in-memory.
|
||||
const fallback = $state<{ val: StoredDraft<unknown> | undefined }>({ val: wrap(defaultValue) })
|
||||
const fallback = $state<{ val: StoredDraft<unknown> | undefined }>({ val: undefined })
|
||||
entries.set(mk, {
|
||||
count: 1,
|
||||
workspace,
|
||||
@@ -769,22 +719,6 @@ function makeHandle<V>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-removal-of-localStorage shim. UserDraft no longer persists to
|
||||
* localStorage, so there is nothing to GC. Kept as a callable export so
|
||||
* existing wiring in `+layout.svelte` (and similar) stays a one-line
|
||||
* no-op rather than a build failure.
|
||||
*/
|
||||
export function gcUserDrafts(_maxAgeMs?: number): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Vestigial — no localStorage layer means no GC retention window. Kept
|
||||
* for source compatibility with code that still references the constant.
|
||||
*/
|
||||
export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** Test-only: clear all in-memory entries. */
|
||||
export function __resetUserDraftForTesting(): void {
|
||||
entries.clear()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,223 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* 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' }
|
||||
]
|
||||
|
||||
/**
|
||||
* A Windmill item path: `u/<owner>/<name…>` or `f/<folder>/<name…>`. The
|
||||
* `<name…>` 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<string, unknown>
|
||||
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<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)) 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
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,6 @@
|
||||
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 { gcUserDrafts } from '$lib/userDraft.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { base } from '$app/paths'
|
||||
import { Menubar } from '$lib/components/meltComponents'
|
||||
@@ -424,19 +422,6 @@
|
||||
$effect(() => {
|
||||
$workspaceStore && untrack(() => onLoad())
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore) untrack(() => migrateLegacyUserDrafts($workspaceStore!))
|
||||
})
|
||||
// Sweep UserDraft entries that haven't been touched in 30 days. Runs
|
||||
// once on mount and on a 30-min timer so a single very long session
|
||||
// also clears out stale autosaves over time. Live entries stamp
|
||||
// `lastWrittenAt` on every persist, so the sweep only touches truly
|
||||
// dormant records.
|
||||
$effect(() => {
|
||||
gcUserDrafts()
|
||||
const interval = setInterval(() => gcUserDrafts(), 30 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
$effect(() => {
|
||||
innerWidth && untrack(() => changeCollapsed())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user