diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index d8c2e181af..c70a69273e 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -50,18 +50,6 @@ export type UserDraftOptions = { workspace?: string } -export type UserDraftUseOptions = 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 = { +export type UserDraftSpec = { itemKind: UserDraftItemKind path: string workspace?: string - defaultValue?: V } /** @@ -136,7 +123,6 @@ export type UserDraftEntry = { 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( - 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( itemKind: UserDraftItemKind, path: string, - opts?: UserDraftUseOptions + opts?: UserDraftOptions ): UserDraftHandle { // `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(() => - untrack(() => [ - { - itemKind, - path, - workspace: opts?.workspace, - defaultValue: opts?.defaultValue - } - ]) + untrack(() => [{ itemKind, path, workspace: opts?.workspace }]) ) return handles[0] }, - useMany(getSpecs: () => UserDraftSpec[]): UserDraftHandle[] { + useMany(getSpecs: () => UserDraftSpec[]): UserDraftHandle[] { // 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 | undefined const destroyRoot = $effect.root(() => { - const cell = $state<{ val: StoredDraft | undefined }>({ - val: wrap(defaultValue) - }) + const cell = $state<{ val: StoredDraft | 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 | undefined }>({ val: wrap(defaultValue) }) + const fallback = $state<{ val: StoredDraft | undefined }>({ val: undefined }) entries.set(mk, { count: 1, workspace, @@ -769,22 +719,6 @@ function makeHandle( } } -/** - * 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() diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts deleted file mode 100644 index 05679b823b..0000000000 --- a/frontend/src/lib/userDraft.test.ts +++ /dev/null @@ -1,1145 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// Capture onDestroy callbacks so we can simulate component teardown without -// a real component context. -const onDestroyCallbacks: Array<() => void> = [] - -vi.mock('svelte', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - onDestroy: (fn: () => void) => { - onDestroyCallbacks.push(fn) - } - } -}) - -// Imported AFTER vi.mock so the module sees the mocked onDestroy. -const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } = - await import('./userDraft.svelte') -const { workspaceStore } = await import('./stores') -const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter') - -function flushDestroyCallbacks(): void { - const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length) - for (const cb of callbacks) cb() -} - -// UserDraft.use debounces localStorage writes by 500 ms via -// useLocalStorageValue. Tests assert localStorage state synchronously after -// writes, so we use fake timers and call this helper to fast-forward past -// the debounce window before each assertion. -function flushPersist(): void { - vi.runAllTimers() -} - -// Helper: localStorage payloads are always wrapped as { value: } so -// future metadata fields can be added without breaking existing entries. -function wrapped(value: V): string { - return JSON.stringify({ value }) -} - -// Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so -// assertions can stay focused on value + rev metadata. Real entries always -// carry `lastWrittenAt` once written; the GC tests below assert on it -// directly via `localStorage.getItem`. -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(() => { - __resetUserDraftForTesting() - onDestroyCallbacks.length = 0 - localStorage.clear() - workspaceStore.set('test_ws') - vi.useFakeTimers() -}) - -describe('UserDraft.save / get / remove (no observers)', () => { - it('save writes a wrapped { value } payload under the workspace-scoped key', () => { - UserDraft.save('flow', 'u/me/myflow', { hello: 'world' }) - - expect(storedShape('userdraft/w/test_ws/flow/u/me/myflow')).toBe(wrapped({ hello: 'world' })) - }) - - it('get reads from a wrapped localStorage payload when no observer is registered', () => { - localStorage.setItem('userdraft/w/test_ws/script/u/me/script1', wrapped('code')) - - expect(UserDraft.get('script', 'u/me/script1')).toBe('code') - }) - - it('get returns undefined when nothing is stored', () => { - expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined() - }) - - it('get returns undefined when the stored payload is malformed', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json') - expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined() - }) - - it('get returns undefined when the stored payload is unwrapped (pre-migration entry)', () => { - // Drafts written before the wrapping was introduced look like the raw - // value rather than { value: ... }. They must be ignored rather than - // surface as undefined-shaped drafts. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/raw', JSON.stringify({ hello: 'world' })) - expect(UserDraft.get('flow', 'u/me/raw')).toBeUndefined() - expect(UserDraft.has('flow', 'u/me/raw')).toBe(false) - }) - - it('remove clears the localStorage entry', () => { - UserDraft.save('app', 'u/me/app1', { grid: [] }) - expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull() - - UserDraft.remove('app', 'u/me/app1') - expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull() - }) - - it('uses the workspace from opts when provided', () => { - UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' }) - - expect(storedShape('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1)) - // Default workspace key must remain empty. - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull() - }) - - it('supports trigger kinds as item kinds', () => { - UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] }) - - expect(storedShape('userdraft/w/test_ws/trigger_kafka/u/me/topic1')).toBe( - wrapped({ brokers: ['localhost:9092'] }) - ) - }) - - it('throws when neither opts.workspace nor $workspaceStore is set', () => { - workspaceStore.set(undefined) - expect(() => UserDraft.save('flow', 'u/me/x', 1)).toThrow(/no workspace/) - }) -}) - -describe('UserDraft live editor draft registry', () => { - it('stores the live editor storage path and effective path per workspace and kind', () => { - UserDraft.setLiveEditorDraft({ - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/generated_script' - }) - - expect(UserDraft.getLiveEditorDraft('script')).toEqual({ - workspace: 'test_ws', - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/generated_script' - }) - }) - - it('keeps live editor registrations isolated by workspace', () => { - UserDraft.setLiveEditorDraft({ - workspace: 'ws_a', - itemKind: 'flow', - storagePath: '', - effectivePath: 'u/me/a' - }) - UserDraft.setLiveEditorDraft({ - workspace: 'ws_b', - itemKind: 'flow', - storagePath: '', - effectivePath: 'u/me/b' - }) - - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe( - 'u/me/a' - ) - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe( - 'u/me/b' - ) - }) - - it('clears only the matching live editor storage path when provided', () => { - UserDraft.setLiveEditorDraft({ - itemKind: 'raw_app', - storagePath: '', - effectivePath: 'u/me/live_app' - }) - - UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' }) - expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined() - - UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' }) - expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined() - }) - - // Pins the slot-collision contract behind the SessionWrapper - // `isActiveSession` gate. Without the gate, two warm-mounted session - // editors on the same (workspace, kind) — e.g. `/sessions` keeping 3 - // warm sessions and two of them have script editors open in the same - // workspace — both call setLiveEditorDraft and the hidden one can - // clobber the visible one's claim. The fix in - // {Script,Flow,RawApp}EditorView returns early when `isActiveSession` - // is false, so only the visible session writes to the slot. - it('collides per (workspace, kind) when two callers both set — the hidden session can hijack', () => { - UserDraft.setLiveEditorDraft({ - workspace: 'ws_collide', - itemKind: 'script', - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - UserDraft.setLiveEditorDraft({ - workspace: 'ws_collide', - itemKind: 'script', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - // Last write wins — exactly the bug Codex flagged: a hidden warm - // session B mounted after the active A overwrites A's claim. - expect(UserDraft.getLiveEditorDraft('script', { workspace: 'ws_collide' })).toMatchObject({ - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - }) - - it('with the active-session gate, only the visible session claims the slot', () => { - // Simulate the effect bodies in {Script,Flow,RawApp}EditorView: each - // returns early when isActiveSession is false. Session A is active, - // Session B is warm-mounted but hidden. - function registerIfActive(opts: { - isActive: boolean - workspace: string - storagePath: string - effectivePath: string - }) { - if (!opts.isActive) return - UserDraft.setLiveEditorDraft({ - workspace: opts.workspace, - itemKind: 'flow', - storagePath: opts.storagePath, - effectivePath: opts.effectivePath - }) - } - registerIfActive({ - isActive: true, - workspace: 'ws_gate', - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - registerIfActive({ - isActive: false, - workspace: 'ws_gate', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_gate' })).toMatchObject({ - storagePath: 'session_a_path', - effectivePath: 'u/me/a' - }) - }) - - it('cleanup on the deactivating session is a no-op once the new active session has claimed the slot', () => { - // Active-session swap: A becomes hidden (cleanup runs), B becomes - // active and registers. Even if Svelte flushes B's effect before - // A's cleanup, A's cleanup is keyed on its own storagePath and is - // guarded so it doesn't clobber B's claim. Verified here by running - // the cleanups in reverse order. - UserDraft.setLiveEditorDraft({ - workspace: 'ws_swap', - itemKind: 'raw_app', - storagePath: 'session_b_path', - effectivePath: 'u/me/b' - }) - // A's cleanup runs after — should be a no-op. - UserDraft.clearLiveEditorDraft('raw_app', { - workspace: 'ws_swap', - storagePath: 'session_a_path' - }) - expect(UserDraft.getLiveEditorDraft('raw_app', { workspace: 'ws_swap' })).toMatchObject({ - storagePath: 'session_b_path' - }) - }) - - it('can remove persisted global draft storage without blanking the live editor', () => { - const draft = { path: 'u/me/live_script', content: 'export async function main() {}' } - localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft)) - const handle = UserDraft.use('script', '') - UserDraft.setLiveEditorDraft({ - itemKind: 'script', - storagePath: '', - effectivePath: 'u/me/live_script' - }) - - deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, { - preserveLiveDraft: true - }) - flushPersist() - - expect(handle.draft).toEqual(draft) - expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull() - }) -}) - -describe('UserDraft.use() — observer sync', () => { - it('loads the existing localStorage value on first use', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded')) - - const handle = UserDraft.use('flow', 'u/me/loaded') - expect(handle.draft).toBe('preloaded') - }) - - it('two handles on the same key share the same underlying state', () => { - const a = UserDraft.use('flow', 'u/me/shared') - const b = UserDraft.use('flow', 'u/me/shared') - - a.draft = 42 - expect(b.draft).toBe(42) - - b.draft = 99 - expect(a.draft).toBe(99) - }) - - it('save() propagates to live use() handles and persists immediately', () => { - const handle = UserDraft.use('flow', 'u/me/observed') - expect(handle.draft).toBeUndefined() - - UserDraft.save('flow', 'u/me/observed', 7) - expect(handle.draft).toBe(7) - expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7)) - - UserDraft.save('flow', 'u/me/observed', 9) - expect(handle.draft).toBe(9) - expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) - }) - - it('get() returns a cloneable snapshot of live handle values', () => { - const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '') - handle.draft = { path: 'u/me/live', nested: { value: 1 } } - - const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '') - expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } }) - expect(draft).not.toBe(handle.draft) - expect(() => structuredClone(draft)).not.toThrow() - }) - - it('remove() clears localStorage without touching the in-memory handle', () => { - // Seed localStorage so the live handle initialises from it. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1)) - const handle = UserDraft.use('flow', 'u/me/removed') - expect(handle.draft).toBe(1) - - UserDraft.remove('flow', 'u/me/removed') - // Live handle keeps its current value — remove() only wipes the - // persisted side. This is what lets callers run UserDraft.remove - // during navigation without flickering the editor UI. - expect(handle.draft).toBe(1) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull() - }) - - it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => { - // Seed: handle holds a divergent local autosave. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit')) - const handle = UserDraft.use('flow', 'u/me/discard') - expect(handle.draft).toBe('local-edit') - - // Reset to a known backend baseline. - UserDraft.discard('flow', 'u/me/discard', 'backend-baseline') - flushPersist() - - // In-memory handle reflects the fallback immediately. - expect(handle.draft).toBe('backend-baseline') - // LS is cleared and stays cleared — the fallback must NOT round-trip - // back into storage (that would make the next reload "restore" the - // fallback as if it were a real autosave). - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull() - }) - - it('discard() with undefined fallback clears both LS and in-memory state', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit')) - const handle = UserDraft.use('flow', 'u/me/wipe') - expect(handle.draft).toBe('local-edit') - - UserDraft.discard('flow', 'u/me/wipe', undefined) - flushPersist() - - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull() - }) - - it('the second write through the handle setter persists to localStorage', () => { - const handle = UserDraft.use('flow', 'u/me/setter') - - // First write is the baseline — not persisted. - handle.draft = 'initial' - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBeNull() - - // Second (and onwards) persists. - handle.draft = 'persisted' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted')) - }) - - it('setting handle.draft = undefined after edits removes the localStorage entry', () => { - const handle = UserDraft.use('flow', 'u/me/clear') - handle.draft = 'initial' // baseline, not persisted - handle.draft = 'edited' // persisted - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull() - - handle.draft = undefined - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull() - expect(handle.draft).toBeUndefined() - }) - - it('two handles in different workspaces are isolated', () => { - const a = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_a' }) - const b = UserDraft.use('flow', 'u/me/iso', { workspace: 'ws_b' }) - - a.draft = 1 - b.draft = 2 - - expect(a.draft).toBe(1) - expect(b.draft).toBe(2) - }) - - it('save() falls back to localStorage when no handle is registered', () => { - UserDraft.save('flow', 'u/me/noobs', 'fallback') - // First use() afterwards loads the persisted value. - const handle = UserDraft.use('flow', 'u/me/noobs') - expect(handle.draft).toBe('fallback') - }) -}) - -describe('UserDraft.use() — defaultValue', () => { - it('returns defaultValue when localStorage has no entry', () => { - const handle = UserDraft.use('flow', 'u/me/withdefault', { defaultValue: 'fallback' }) - - expect(handle.draft).toBe('fallback') - }) - - it('does not persist the defaultValue on first read', () => { - UserDraft.use('flow', 'u/me/lazyDefault', { defaultValue: 'fallback' }) - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull() - }) - - it('localStorage value wins over defaultValue', () => { - localStorage.setItem('userdraft/w/test_ws/flow/u/me/overridden', wrapped('persisted')) - - const handle = UserDraft.use('flow', 'u/me/overridden', { - defaultValue: 'fallback' - }) - - expect(handle.draft).toBe('persisted') - }) - - it('second write through the setter persists even though defaultValue was set', () => { - const handle = UserDraft.use('flow', 'u/me/writeDefault', { - defaultValue: 'fallback' - }) - - // First write is the initial-value baseline. - handle.draft = 'initial' - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBeNull() - - handle.draft = 'modified' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified')) - }) -}) - -describe('UserDraft — empty path (new-item drafts persist across reloads)', () => { - it('use() with empty path persists subsequent edits to localStorage', () => { - const handle = UserDraft.use('flow', '', { defaultValue: 0 }) - - // First write under saveInitialValue=false counts as the baseline and - // is skipped — only the user's subsequent edits persist. - handle.draft = 99 - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() - handle.draft = 100 - flushPersist() - // The "+ Flow / + Script / …" buttons are expected to call - // `UserDraft.remove(kind, '')` to wipe before navigating; an - // unguarded /add reload therefore restores the previous session. - expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100)) - }) - - it('two handles with empty path share state per workspace', () => { - const a = UserDraft.use('flow', '') - const b = UserDraft.use('flow', '') - - a.draft = 1 - expect(b.draft).toBe(1) - - b.draft = 2 - expect(a.draft).toBe(2) - }) - - it('save() with empty path writes to localStorage when no handle is live', () => { - UserDraft.save('flow', '', 5) - expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(5)) - }) - - it('get() with empty path falls back to localStorage when no handle is live', () => { - localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11)) - expect(UserDraft.get('flow', '')).toBe(11) - }) - - it('remove() with empty path clears localStorage', () => { - localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1)) - UserDraft.remove('flow', '') - expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull() - }) -}) - -describe('UserDraft — rev metadata for staleness checks', () => { - it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => { - const handle = UserDraft.use('flow', 'u/me/atomic') - - // Single atomic write — under saveInitialValue=false this counts as the - // initial baseline and shouldn't hit localStorage yet. - handle.setDraftAndMeta('backendValue', { - remoteRev: 42, - remoteDraftRev: '2026-01-01T00:00:00Z' - }) - expect(handle.draft).toBe('backendValue') - expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' }) - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull() - - // A subsequent user edit persists *with* the rev metadata. - handle.draft = 'userEdit' - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe( - JSON.stringify({ - value: 'userEdit', - remoteRev: 42, - remoteDraftRev: '2026-01-01T00:00:00Z' - }) - ) - }) - - it('setMeta updates only the rev fields, preserving the value', () => { - const handle = UserDraft.use('flow', 'u/me/setmeta') - handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted - handle.draft = 'edited' // persisted with remoteRev: 1 - - handle.setMeta({ remoteRev: 2 }) - expect(handle.draft).toBe('edited') - expect(handle.meta).toEqual({ remoteRev: 2 }) - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( - JSON.stringify({ value: 'edited', remoteRev: 2 }) - ) - }) - - it('handle.draft setter preserves rev metadata across user edits', () => { - const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve') - handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' }) - handle.draft = { count: 1 } // first edit, persisted - handle.draft = { count: 2 } // another edit - - expect(handle.meta).toEqual({ remoteRev: 'v1' }) - flushPersist() - expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe( - JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' }) - ) - }) - - it('UserDraft.getMeta reads from localStorage when no live handle exists', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/getmeta', - JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' }) - ) - expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({ - remoteRev: 7, - remoteDraftRev: '2026-01-02' - }) - }) - - it('UserDraft.getMeta returns empty object when there is no entry', () => { - expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({}) - }) - - it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/savepreserve', - JSON.stringify({ value: 'old', remoteRev: 5 }) - ) - UserDraft.save('flow', 'u/me/savepreserve', 'new') - - expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( - JSON.stringify({ value: 'new', remoteRev: 5 }) - ) - }) - - it('UserDraft.save persists immediately when a live handle exists', () => { - const handle = UserDraft.use('flow', 'u/me/live-save') - - UserDraft.save('flow', 'u/me/live-save', 'external') - - expect(handle.draft).toBe('external') - expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external')) - }) - - it('UserDraft.save preserves live rev metadata while forcing persistence', () => { - const handle = UserDraft.use('flow', 'u/me/live-save-meta') - handle.setDraftAndMeta('baseline', { remoteRev: 5 }) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull() - - UserDraft.save('flow', 'u/me/live-save-meta', 'external') - - expect(handle.draft).toBe('external') - expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe( - JSON.stringify({ value: 'external', remoteRev: 5 }) - ) - }) - - it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/legacy', - JSON.stringify({ value: 'no-rev' }) - ) - const handle = UserDraft.use('flow', 'u/me/legacy') - expect(handle.draft).toBe('no-rev') - expect(handle.meta).toEqual({}) - }) - - it('setMeta({ force: true }) persists immediately, bypassing the first-write skip', () => { - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/forceack', - JSON.stringify({ value: 'edited', remoteRev: 'v1' }) - ) - const handle = UserDraft.use('flow', 'u/me/forceack') - - // Without force, this is the entry's first state mutation and gets - // swallowed by saveInitialValue=false — localStorage would still - // hold the old remoteRev. - handle.setMeta({ remoteRev: 'v2' }, { force: true }) - - expect(handle.meta).toEqual({ remoteRev: 'v2' }) - expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe( - JSON.stringify({ value: 'edited', remoteRev: 'v2' }) - ) - }) -}) - -describe('checkStaleness', () => { - let checkStaleness: ( - meta: { remoteRev?: string | number; remoteDraftRev?: string | number }, - currentRev: string | number | undefined, - currentDraftRev?: string | number | undefined - ) => 'draft' | 'version' | null - - beforeEach(async () => { - // Re-import to dodge ESM caching surprises across test files. - ;({ checkStaleness } = await import('./userDraft.svelte')) - }) - - it('returns null for legacy entries with no recorded rev', () => { - expect(checkStaleness({}, 'h1', '2026-01-01')).toBeNull() - }) - - it('returns null when meta matches current revs exactly', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd1')).toBeNull() - expect(checkStaleness({ remoteRev: 'h1' }, 'h1', undefined)).toBeNull() - }) - - it('returns "draft" when a newer DB draft was pushed on the remote', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', 'd2')).toBe('draft') - }) - - it('returns "draft" when the remote gained a DB draft that we didn\'t baseline against', () => { - expect(checkStaleness({ remoteRev: 'h1' }, 'h1', 'd1')).toBe('draft') - }) - - it('returns "version" when the deployed rev moved and draft revs match', () => { - expect(checkStaleness({ remoteRev: 'h1' }, 'h2', undefined)).toBe('version') - }) - - it('returns "version" when the baseline draft was deleted on the remote (no current draft)', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h1', undefined)).toBe( - 'version' - ) - }) - - it('prefers "draft" over "version" when both have changed', () => { - expect(checkStaleness({ remoteRev: 'h1', remoteDraftRev: 'd1' }, 'h2', 'd2')).toBe('draft') - }) -}) - -describe('UserDraft.use() — reference counting & cleanup', () => { - it('destroys the entry when the last handle is released', () => { - // First handle acquires the entry. - const a = UserDraft.use('flow', 'u/me/ref') - a.draft = 1 // baseline write — not persisted - - // Second handle increments the count. - const b = UserDraft.use('flow', 'u/me/ref') - expect(b.draft).toBe(1) - - // onDestroy for both handles got registered. - expect(onDestroyCallbacks.length).toBe(2) - - // Releasing one handle keeps the entry alive — save() still updates handle a. - const firstCb = onDestroyCallbacks.shift()! - firstCb() - - UserDraft.save('flow', 'u/me/ref', 2) - expect(a.draft).toBe(2) - // External save() calls persist immediately, even with a live handle. - expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2)) - - // Releasing the second handle drops the entry; subsequent save() - // must go straight to localStorage rather than mutating in-memory - // state (which no longer exists). - const secondCb = onDestroyCallbacks.shift()! - secondCb() - - UserDraft.save('flow', 'u/me/ref', 3) - // UserDraft.save without a live entry writes synchronously. - expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3)) - }) - - it('a fresh use() after cleanup re-reads the latest persisted value', () => { - const a = UserDraft.use('flow', 'u/me/cycle') - a.draft = 'initial' // baseline — not persisted - a.draft = 'edited' // persisted (after debounce) - flushPersist() - flushDestroyCallbacks() - - // After all handles release, a brand-new use() must pick up the - // value persisted to localStorage from the previous round. - const b = UserDraft.use('flow', 'u/me/cycle') - expect(b.draft).toBe('edited') - }) - - it('coalesces a typing storm into a single localStorage write per 500 ms window', () => { - const handle = UserDraft.use('flow', 'u/me/debounce') - handle.draft = 'baseline' // first write — skipped under saveInitialValue=false - - // Three quick edits inside the 500 ms window: in-memory updates every - // time, but localStorage stays untouched until the timer fires. - handle.draft = 'one' - vi.advanceTimersByTime(100) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - handle.draft = 'two' - vi.advanceTimersByTime(100) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - handle.draft = 'three' - expect(handle.draft).toBe('three') - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBeNull() - - // After the window elapses, only the latest value lands. - vi.advanceTimersByTime(500) - expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three')) - }) -}) - -describe('UserDraft.useMany()', () => { - it('acquires one handle per spec in the synchronous initial reconcile', () => { - // `useMany`'s sync reconcile populates handles[0..] before returning, - // so callers (and `use()`'s 1-len wrapper) can use them immediately - // without waiting for an `$effect` tick. - const handles = UserDraft.useMany(() => [ - { itemKind: 'flow', path: 'u/me/many', workspace: 'a' }, - { itemKind: 'flow', path: 'u/me/many', workspace: 'b' } - ]) - expect(handles.length).toBe(2) - - // Each spec gets its own entry in the workspace-keyed store. - handles[0].draft = 0 // baseline - handles[0].draft = 1 // persisted - handles[1].draft = 0 - handles[1].draft = 9 - flushPersist() - expect(storedShape('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1)) - expect(storedShape('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9)) - - // One component-level onDestroy releases every acquired entry. - expect(onDestroyCallbacks.length).toBe(1) - }) -}) - -describe('gcUserDrafts', () => { - let gcUserDrafts: (maxAgeMs?: number) => void - let USER_DRAFT_GC_MAX_AGE_MS: number - const DAY = 24 * 60 * 60 * 1000 - - beforeEach(async () => { - ;({ gcUserDrafts, USER_DRAFT_GC_MAX_AGE_MS } = await import('./userDraft.svelte')) - }) - - it('sweeps entries whose lastWrittenAt is older than the cutoff', () => { - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - const old = Date.now() - 31 * DAY - const fresh = Date.now() - 1 * DAY - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/old', - JSON.stringify({ value: 1, lastWrittenAt: old }) - ) - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/fresh', - JSON.stringify({ value: 2, lastWrittenAt: fresh }) - ) - // Unrelated keys are left alone. - localStorage.setItem('some_other_key', 'unrelated') - - gcUserDrafts() - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/old')).toBeNull() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/fresh')).not.toBeNull() - expect(localStorage.getItem('some_other_key')).toBe('unrelated') - }) - - it('backfills lastWrittenAt on entries lacking it, instead of sweeping them immediately', () => { - // Pre-GC-feature entry (legacy migration output, or just an old entry - // from earlier in this PR's lifecycle): no `lastWrittenAt`. First GC - // pass should stamp it as "now" rather than wipe it on sight. - localStorage.setItem('userdraft/w/test_ws/flow/u/me/legacy', JSON.stringify({ value: 'data' })) - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - - gcUserDrafts() - - const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/legacy') - expect(raw).not.toBeNull() - const parsed = JSON.parse(raw!) - expect(parsed.lastWrittenAt).toBe(Date.now()) - expect(parsed.value).toBe('data') - }) - - it('exposes a 30-day default retention window', () => { - expect(USER_DRAFT_GC_MAX_AGE_MS).toBe(30 * 24 * 60 * 60 * 1000) - }) - - it('respects a custom maxAgeMs', () => { - vi.setSystemTime(new Date('2026-06-01T00:00:00Z')) - localStorage.setItem( - 'userdraft/w/test_ws/flow/u/me/two_hours_ago', - JSON.stringify({ value: 1, lastWrittenAt: Date.now() - 2 * 60 * 60 * 1000 }) - ) - - gcUserDrafts(60 * 60 * 1000) // 1h cutoff - - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/two_hours_ago')).toBeNull() - }) -}) - -describe('normalizeForCompare', () => { - it('returns undefined for undefined input', () => { - expect(normalizeForCompare(undefined)).toBeUndefined() - }) - - it('drops keys whose value is undefined (mirrors JSON.stringify persistence)', () => { - const out = normalizeForCompare({ a: 1, b: undefined, c: { d: undefined, e: 2 } }) - expect(out).toEqual({ a: 1, c: { e: 2 } }) - expect(Object.keys(out as object)).not.toContain('b') - expect(Object.keys((out as any).c)).not.toContain('d') - }) - - it('falls back to the original value when not serializable (cyclic)', () => { - const cyclic: any = { a: 1 } - cyclic.self = cyclic - expect(normalizeForCompare(cyclic)).toBe(cyclic) - }) -}) - -describe('localDraftDiffers', () => { - it('returns false when there is no local draft', () => { - expect(localDraftDiffers(undefined, { a: 1 })).toBe(false) - expect(localDraftDiffers(null, { a: 1 })).toBe(false) - }) - - it('treats a draft that round-trips equal to the config as NOT differing', () => { - // The Schedule bug: getXCfg() emits conditionally-undefined keys, but - // the persisted draft went through JSON.stringify which dropped them. - const freshCfg = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - const persisted = JSON.parse(JSON.stringify(freshCfg)) // { path, schedule } - expect(localDraftDiffers(persisted, freshCfg)).toBe(false) - }) - - it('returns true for a genuine difference', () => { - expect(localDraftDiffers({ a: 1 }, { a: 2 })).toBe(true) - expect(localDraftDiffers({ a: 1, extra: 'x' }, { a: 1 })).toBe(true) - }) -}) - -describe('UserDraft.saveIfChanged', () => { - const KEY = 'userdraft/w/test_ws/trigger_schedule/u/me/s' - - it('does not persist a draft equal to the deployed baseline', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - // value is the post-load reactive cfg — same shape, undefined keys present - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('treats a value that round-trips equal to deployed as unchanged', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *', on_failure: undefined } - const value = JSON.parse(JSON.stringify(deployed)) // { path, schedule } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('persists when the value differs from the deployed baseline', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } - const value = { path: 'u/me/s', schedule: '5 0 * * *' } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, deployed) - expect(storedShape(KEY)).toBe(wrapped(value)) - }) - - it('removes a pre-existing draft once the value reverts to deployed', () => { - const deployed = { path: 'u/me/s', schedule: '0 0 * * *' } - UserDraft.save('trigger_schedule', 'u/me/s', { path: 'u/me/s', schedule: '5 0 * * *' }) - expect(localStorage.getItem(KEY)).not.toBeNull() - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', { ...deployed }, deployed) - expect(localStorage.getItem(KEY)).toBeNull() - }) - - it('persists when there is no deployed baseline (undefined)', () => { - const value = { path: 'u/me/s', schedule: '0 0 * * *' } - UserDraft.saveIfChanged('trigger_schedule', 'u/me/s', value, undefined) - expect(storedShape(KEY)).toBe(wrapped(value)) - }) -}) - -describe('UserDraft.list / clear / setDraftAndMeta', () => { - it('enumerates persisted-only drafts for the requested workspace and kinds', () => { - UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' }) - UserDraft.setDraftAndMeta( - 'flow', - 'f/b', - { path: 'f/b', value: { modules: [] } }, - { remoteRev: 2 }, - { workspace: 'other_ws' } - ) - UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {}) - - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/a', - value: { path: 'f/a', content: 'a' }, - meta: { remoteRev: 'h1' }, - persisted: true, - live: false - } - ]) - expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([ - expect.objectContaining({ - workspace: 'other_ws', - itemKind: 'flow', - path: 'f/b', - persisted: true, - live: false - }) - ]) - }) - - it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => { - UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {}) - UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {}) - UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {}) - - const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] }) - - expect(entries).toHaveLength(3) - expect(entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - itemKind: 'script', - path: '', - value: { path: '', content: 'scratch' } - }), - expect.objectContaining({ - itemKind: 'script', - path: 'f/new-a', - value: { path: 'f/new-a', content: 'a' } - }), - expect.objectContaining({ - itemKind: 'script', - path: 'f/new-b', - value: { path: 'f/new-b', content: 'b' } - }) - ]) - ) - }) - - it('enumerates live-only drafts before the debounce persists them', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live') - handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' }) - - expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/live', - value: { path: 'f/live', content: 'live' }, - meta: { remoteRev: 'h1' }, - persisted: false, - live: true - } - ]) - }) - - it('dedupes entries that are both persisted and live', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/both', - { path: 'f/both', content: 'persisted' }, - { - remoteRev: 'h1' - } - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both') - handle.draft = { path: 'f/both', content: 'live' } - - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - { - workspace: 'test_ws', - itemKind: 'script', - path: 'f/both', - value: { path: 'f/both', content: 'live' }, - meta: { remoteRev: 'h1' }, - persisted: true, - live: true - } - ]) - }) - - it('clear removes persisted storage and live state without re-persisting', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/clear', - { path: 'f/clear', content: 'x' }, - { - remoteRev: 'h1' - } - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear') - expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' }) - - UserDraft.clear('script', 'f/clear') - flushPersist() - - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) - }) - - it('clear cancels pending debounced live writes', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear') - handle.draft = { path: 'f/pending-clear', content: 'initial' } - handle.draft = { path: 'f/pending-clear', content: 'pending' } - - UserDraft.clear('script', 'f/pending-clear') - expect(handle.draft).toBeUndefined() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() - - flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull() - }) - - it('clear does not let an old debounced remove delete a later direct write', () => { - const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear' - const handle = UserDraft.use<{ path: string; content: string }>( - 'script', - 'f/rewrite-after-clear' - ) - handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' } - handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' } - - UserDraft.clear('script', 'f/rewrite-after-clear') - flushDestroyCallbacks() - UserDraft.setDraftAndMeta( - 'script', - 'f/rewrite-after-clear', - { path: 'f/rewrite-after-clear', content: 'new' }, - {} - ) - - flushPersist() - expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' })) - }) - - it('list hides persisted drafts when a live handle has cleared the value', () => { - UserDraft.setDraftAndMeta( - 'script', - 'f/live-clear', - { path: 'f/live-clear', content: 'persisted' }, - {} - ) - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear') - handle.draft = { path: 'f/live-clear', content: 'edited' } - handle.draft = undefined - - expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([]) - }) - - it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta') - - UserDraft.setDraftAndMeta( - 'script', - 'f/meta', - { path: 'f/meta', content: 'first' }, - { - remoteRev: 'h1', - remoteDraftRev: 'd1' - } - ) - handle.draft = { path: 'f/meta', content: 'second' } - - expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' }) - expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' }) - expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual( - expect.objectContaining({ - value: { path: 'f/meta', content: 'second' }, - meta: { remoteRev: 'h1', remoteDraftRev: 'd1' } - }) - ) - }) - - it('static setDraftAndMeta persists first writes even when a live handle exists', () => { - const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live') - - UserDraft.setDraftAndMeta( - 'script', - 'f/static-live', - { path: 'f/static-live', content: 'first' }, - { remoteRev: 'h1' } - ) - - expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' }) - expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe( - JSON.stringify({ - value: { path: 'f/static-live', content: 'first' }, - remoteRev: 'h1' - }) - ) - }) - - it('lists live drafts with runtime-only values without throwing', () => { - const handle = UserDraft.use>('script', 'f/runtime') - handle.draft = { - path: 'f/runtime', - content: 'x', - callback: () => 'not serializable' - } - - expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow() - expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ - expect.objectContaining({ - itemKind: 'script', - path: 'f/runtime', - value: { path: 'f/runtime', content: 'x' } - }) - ]) - }) -}) diff --git a/frontend/src/lib/userDraftLegacyMigration.test.ts b/frontend/src/lib/userDraftLegacyMigration.test.ts deleted file mode 100644 index 7069929897..0000000000 --- a/frontend/src/lib/userDraftLegacyMigration.test.ts +++ /dev/null @@ -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(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 deleted file mode 100644 index d4ee04ee61..0000000000 --- a/frontend/src/lib/userDraftLegacyMigration.ts +++ /dev/null @@ -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: })`. - * - * 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 01461b78cb..b60fcf88bf 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -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()) })