From 2d432c6308a07b8d7d3ca3ea4ecc4eba56283c50 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 15 May 2026 15:01:20 +0200 Subject: [PATCH] feat(frontend): GC UserDraft entries older than 30 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a sweep, a heavy user accumulates one localStorage entry per (workspace, kind, path) they ever touched. The pre-PR single-key autosave self-capped at one entry per editor; this one needs an explicit GC pass. Mechanism: - Stamp every persist with `lastWrittenAt: Date.now()`. Added at four sites: `useLocalStorageValue`'s new `transformBeforePersist` option (covers both setter and deep-mutation persists), `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta writes), and the legacy migration. Done at persist time, not in `wrap()`, so deep mutations bump the clock too — `wrap()` runs only on `.draft =` assignments, which would leave the timestamp stale for bind-mutated editor sessions. - `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key, removes the ones older than the cutoff. Entries written before this field existed (pre-PR or pre-this-commit) get backfilled with the current time on first sweep so a 30-day clock starts fresh; the alternative — sweeping on sight — would wipe work that the legacy migration just rescued. - Wired into the logged-in layout: runs once on mount and every 30 min via `setInterval` (cleaned up in the effect's return). Tests use `vi.setSystemTime` to drive the clock; assertions on the stored payload now go through a `storedShape` helper that strips `lastWrittenAt` before string-comparing, so the existing `expect(...).toBe(wrapped(...))` style still reads cleanly. New tests cover the sweep, the backfill behaviour, the default 30d window, and a custom `maxAgeMs`. --- frontend/src/lib/svelte5Utils.svelte.ts | 14 ++- frontend/src/lib/userDraft.svelte.ts | 88 +++++++++++-- frontend/src/lib/userDraft.test.ts | 119 ++++++++++++++---- .../src/lib/userDraftLegacyMigration.test.ts | 18 ++- frontend/src/lib/userDraftLegacyMigration.ts | 7 +- .../src/routes/(root)/(logged)/+layout.svelte | 11 ++ 6 files changed, 219 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index dc8425231e..0bbc68cef9 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -590,10 +590,21 @@ export function useLocalStorageValue( * close drops it. */ debounce?: number + /** + * Transform applied to the value just before serialisation, on every + * persist (both setter-driven and deep-mutation-driven). The in-memory + * `$state` is left as-is. Useful for injecting per-write metadata + * (timestamps, counters) that must reflect the actual write time, not + * the last `.val =` assignment — deep mutations don't re-run the + * setter, so a timestamp set via `.val =` would otherwise grow stale + * across long editing sessions. + */ + transformBeforePersist?: (val: T) => T } ): { val: T } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 + const transformBeforePersist = options?.transformBeforePersist const serialize = (val: T) => typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val) const deserialize = (val: string): T => { @@ -607,7 +618,8 @@ export function useLocalStorageValue( if (val === undefined) { localStorage.removeItem(key) } else { - localStorage.setItem(key, serialize(val as T)) + const toStore = transformBeforePersist ? transformBeforePersist(val as T) : (val as T) + localStorage.setItem(key, serialize(toStore)) } } catch (e) { console.error('useLocalStorageValue: localStorage write failed', e) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 59da2fa475..8ccf6e63e2 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -73,8 +73,21 @@ export type UserDraftMeta = { * The shape of what we actually persist. Wrapping the value lets us add * metadata (timestamps, originating user, schema version, ...) later * without breaking existing entries. + * + * `lastWrittenAt` is the unix-ms timestamp of the most recent write + * (setter call or deep mutation flush). It's the GC signal — + * `gcUserDrafts` sweeps entries that haven't been touched in N days. + * Set at every persist via `useLocalStorageValue`'s `transformBeforePersist`, + * `UserDraft.save`'s direct-write fallback, and `persistDirect`. Missing + * (undefined) on entries written before this field was introduced; + * `gcUserDrafts` backfills them on first sighting. */ -type StoredDraft = { value: V } & UserDraftMeta +type StoredDraft = { value: V; lastWrittenAt?: number } & UserDraftMeta + +function stamp(stored: StoredDraft | undefined): StoredDraft | undefined { + if (stored === undefined) return undefined + return { ...stored, lastWrittenAt: Date.now() } +} type DraftState = { val: StoredDraft | undefined } @@ -162,7 +175,7 @@ export function checkStaleness( */ function persistDirect(key: string, value: V | undefined, meta: UserDraftMeta): void { try { - const next = wrap(value, meta) + const next = stamp(wrap(value, meta)) if (next === undefined) { localStorage.removeItem(key) } else { @@ -251,7 +264,7 @@ export const UserDraft = { try { localStorage.setItem( localStorageKey(ws, itemKind, path), - JSON.stringify(wrap(value, extractMeta(existing))) + JSON.stringify(stamp(wrap(value, extractMeta(existing)))) ) } catch (e) { console.error('UserDraft.save: localStorage write failed', e) @@ -444,17 +457,26 @@ function acquireEntry( // reconcile. Wrap the creation in `$effect.root` so the entry's // reactivity lives in its own scope and only the entry's release path // disposes of it. + const useLocalStorageOptions = { + // The first value to flow into the handle (e.g. a backend load in + // the editor route) is the baseline — only persist when the user + // actually changes it afterwards. Coalesce a typing storm into one + // localStorage write per 500 ms. + saveInitialValue: false, + debounce: 500, + // Stamp every write with `lastWrittenAt` so the periodic GC pass + // can sweep entries that haven't been touched in 30 days. Done at + // persist time (not via `wrap()`) so deep mutations — which never + // re-run the setter — still bump the clock on each flush. + transformBeforePersist: stamp + } as const let stateRef: DraftState | undefined const destroyRoot = $effect.root(() => { stateRef = useLocalStorageValue | undefined>( localStorageKey(workspace, itemKind, path), wrap(defaultValue), undefined, - // The first value to flow into the handle (e.g. a backend load in - // the editor route) is the baseline — only persist when the user - // actually changes it afterwards. Coalesce a typing storm into one - // localStorage write per 500 ms. - { saveInitialValue: false, debounce: 500 } + useLocalStorageOptions ) }) if (stateRef) { @@ -471,7 +493,7 @@ function acquireEntry( localStorageKey(workspace, itemKind, path), wrap(defaultValue), undefined, - { saveInitialValue: false, debounce: 500 } + useLocalStorageOptions ) entries.set(mk, { count: 1, state }) } @@ -533,6 +555,54 @@ function makeHandle( } } +/** + * Default GC retention window: 30 days. Entries that haven't been touched + * (no setter call, no deep-mutation persist) for this long are swept on + * the next `gcUserDrafts` invocation. + */ +export const USER_DRAFT_GC_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 + +/** + * Sweep stale UserDraft entries from localStorage. Walks every + * `userdraft/w/...` key, checks its `lastWrittenAt` stamp, and removes + * any entry older than `maxAgeMs`. + * + * Entries written before `lastWrittenAt` was introduced lack the field; + * we backfill them to `now()` on first sighting so they participate in + * the next sweep cycle rather than getting wiped immediately. + * + * Safe to call on every load and on a timer (e.g. every 30 min) — live + * entries get their stamp refreshed on every persist, so the sweep only + * touches truly stale records. + */ +export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void { + if (typeof localStorage === 'undefined') return + const now = Date.now() + const cutoff = now - maxAgeMs + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k != null && k.startsWith('userdraft/w/')) keys.push(k) + } + for (const key of keys) { + try { + const raw = localStorage.getItem(key) + if (raw == null) continue + const parsed = JSON.parse(raw) + if (parsed == null || typeof parsed !== 'object') continue + if (typeof parsed.lastWrittenAt !== 'number') { + // Pre-GC-feature entry. Backfill so the next sweep can decide. + parsed.lastWrittenAt = now + localStorage.setItem(key, JSON.stringify(parsed)) + continue + } + if (parsed.lastWrittenAt < cutoff) localStorage.removeItem(key) + } catch (e) { + console.error('UserDraft GC: failed to inspect', key, e) + } + } +} + /** 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 index 69a26ed95d..3ea97b05c2 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -37,6 +37,18 @@ 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 @@ -49,8 +61,7 @@ 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' }) - const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/myflow') - expect(raw).toBe(wrapped({ 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', () => { @@ -88,7 +99,7 @@ describe('UserDraft.save / get / remove (no observers)', () => { it('uses the workspace from opts when provided', () => { UserDraft.save('flow', 'u/me/f', 1, { workspace: 'other_ws' }) - expect(localStorage.getItem('userdraft/w/other_ws/flow/u/me/f')).toBe(wrapped(1)) + 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() }) @@ -96,8 +107,9 @@ describe('UserDraft.save / get / remove (no observers)', () => { it('supports trigger kinds as item kinds', () => { UserDraft.save('trigger_kafka', 'u/me/topic1', { brokers: ['localhost:9092'] }) - const raw = localStorage.getItem('userdraft/w/test_ws/trigger_kafka/u/me/topic1') - expect(raw).toBe(wrapped({ 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', () => { @@ -140,7 +152,7 @@ describe('UserDraft.use() — observer sync', () => { UserDraft.save('flow', 'u/me/observed', 9) expect(handle.draft).toBe(9) flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) + expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) }) it('remove() clears localStorage without touching the in-memory handle', () => { @@ -168,7 +180,7 @@ describe('UserDraft.use() — observer sync', () => { // Second (and onwards) persists. handle.draft = 'persisted' flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted')) + expect(storedShape('userdraft/w/test_ws/flow/u/me/setter')).toBe(wrapped('persisted')) }) it('setting handle.draft = undefined after edits removes the localStorage entry', () => { @@ -238,9 +250,7 @@ describe('UserDraft.use() — defaultValue', () => { handle.draft = 'modified' flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe( - wrapped('modified') - ) + expect(storedShape('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(wrapped('modified')) }) }) @@ -258,7 +268,7 @@ describe('UserDraft — empty path (new-item drafts persist across reloads)', () // 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(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(100)) + expect(storedShape('userdraft/w/test_ws/flow/')).toBe(wrapped(100)) }) it('two handles with empty path share state per workspace', () => { @@ -274,7 +284,7 @@ describe('UserDraft — empty path (new-item drafts persist across reloads)', () it('save() with empty path writes to localStorage when no handle is live', () => { UserDraft.save('flow', '', 5) - expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(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', () => { @@ -307,8 +317,7 @@ describe('UserDraft — rev metadata for staleness checks', () => { // A subsequent user edit persists *with* the rev metadata. handle.draft = 'userEdit' flushPersist() - const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic') - expect(raw).toBe( + expect(storedShape('userdraft/w/test_ws/flow/u/me/atomic')).toBe( JSON.stringify({ value: 'userEdit', remoteRev: 42, @@ -326,7 +335,7 @@ describe('UserDraft — rev metadata for staleness checks', () => { expect(handle.draft).toBe('edited') expect(handle.meta).toEqual({ remoteRev: 2 }) flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( + expect(storedShape('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( JSON.stringify({ value: 'edited', remoteRev: 2 }) ) }) @@ -339,7 +348,7 @@ describe('UserDraft — rev metadata for staleness checks', () => { expect(handle.meta).toEqual({ remoteRev: 'v1' }) flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/preserve')).toBe( + expect(storedShape('userdraft/w/test_ws/flow/u/me/preserve')).toBe( JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' }) ) }) @@ -366,7 +375,7 @@ describe('UserDraft — rev metadata for staleness checks', () => { ) UserDraft.save('flow', 'u/me/savepreserve', 'new') - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( + expect(storedShape('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( JSON.stringify({ value: 'new', remoteRev: 5 }) ) }) @@ -394,7 +403,7 @@ describe('UserDraft — rev metadata for staleness checks', () => { handle.setMeta({ remoteRev: 'v2' }, { force: true }) expect(handle.meta).toEqual({ remoteRev: 'v2' }) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/forceack')).toBe( + expect(storedShape('userdraft/w/test_ws/flow/u/me/forceack')).toBe( JSON.stringify({ value: 'edited', remoteRev: 'v2' }) ) }) @@ -465,7 +474,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => { expect(a.draft).toBe(2) // Now persisted (second write after the baseline). flushPersist() - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2)) + 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 @@ -475,7 +484,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => { UserDraft.save('flow', 'u/me/ref', 3) // UserDraft.save without a live entry writes synchronously. - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(3)) + 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', () => { @@ -509,7 +518,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => { // After the window elapses, only the latest value lands. vi.advanceTimersByTime(500) - expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three')) + expect(storedShape('userdraft/w/test_ws/flow/u/me/debounce')).toBe(wrapped('three')) }) }) @@ -530,10 +539,74 @@ describe('UserDraft.useMany()', () => { handles[1].draft = 0 handles[1].draft = 9 flushPersist() - expect(localStorage.getItem('userdraft/w/a/flow/u/me/many')).toBe(wrapped(1)) - expect(localStorage.getItem('userdraft/w/b/flow/u/me/many')).toBe(wrapped(9)) + 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() + }) +}) diff --git a/frontend/src/lib/userDraftLegacyMigration.test.ts b/frontend/src/lib/userDraftLegacyMigration.test.ts index 45b03e26cf..9f001cd304 100644 --- a/frontend/src/lib/userDraftLegacyMigration.test.ts +++ b/frontend/src/lib/userDraftLegacyMigration.test.ts @@ -12,6 +12,16 @@ 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() @@ -30,7 +40,7 @@ describe('migrateLegacyUserDrafts', () => { migrateLegacyUserDrafts('main') expect(localStorage.getItem('app-u/me/dashboard')).toBeNull() - expect(localStorage.getItem('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp)) + expect(storedShape('userdraft/w/main/app/u/me/dashboard')).toBe(wrapped(legacyApp)) }) it('migrates a legacy empty-path app draft (the `app` literal key)', () => { @@ -40,7 +50,7 @@ describe('migrateLegacyUserDrafts', () => { migrateLegacyUserDrafts('main') expect(localStorage.getItem('app')).toBeNull() - expect(localStorage.getItem('userdraft/w/main/app/')).toBe(wrapped(legacyApp)) + expect(storedShape('userdraft/w/main/app/')).toBe(wrapped(legacyApp)) }) it('migrates a legacy flow draft and strips the view-state envelope', () => { @@ -59,7 +69,7 @@ describe('migrateLegacyUserDrafts', () => { 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)) + expect(storedShape('userdraft/w/main/flow/u/me/myflow')).toBe(wrapped(flow)) }) it('migrates a legacy raw-app draft, defaulting the new `summary` field', () => { @@ -73,7 +83,7 @@ describe('migrateLegacyUserDrafts', () => { migrateLegacyUserDrafts('main') expect(localStorage.getItem('rawapp-u/me/site')).toBeNull() - expect(localStorage.getItem('userdraft/w/main/raw_app/u/me/site')).toBe( + expect(storedShape('userdraft/w/main/raw_app/u/me/site')).toBe( wrapped({ ...legacy, summary: '' }) ) }) diff --git a/frontend/src/lib/userDraftLegacyMigration.ts b/frontend/src/lib/userDraftLegacyMigration.ts index 1efb5b432b..b0a4ce9fa4 100644 --- a/frontend/src/lib/userDraftLegacyMigration.ts +++ b/frontend/src/lib/userDraftLegacyMigration.ts @@ -155,7 +155,12 @@ export function migrateLegacyUserDrafts(workspace: string): void { 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 })) + // `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) { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 646d602434..56a341237b 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -59,6 +59,7 @@ 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' @@ -422,6 +423,16 @@ $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()) })