mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat(frontend): GC UserDraft entries older than 30 days
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`.
This commit is contained in:
@@ -590,10 +590,21 @@ export function useLocalStorageValue<T>(
|
||||
* 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<T>(
|
||||
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)
|
||||
|
||||
@@ -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<V> = { value: V } & UserDraftMeta
|
||||
type StoredDraft<V> = { value: V; lastWrittenAt?: number } & UserDraftMeta
|
||||
|
||||
function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefined {
|
||||
if (stored === undefined) return undefined
|
||||
return { ...stored, lastWrittenAt: Date.now() }
|
||||
}
|
||||
|
||||
type DraftState<V> = { val: StoredDraft<V> | undefined }
|
||||
|
||||
@@ -162,7 +175,7 @@ export function checkStaleness(
|
||||
*/
|
||||
function persistDirect<V>(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<unknown>
|
||||
} as const
|
||||
let stateRef: DraftState<unknown> | undefined
|
||||
const destroyRoot = $effect.root(() => {
|
||||
stateRef = useLocalStorageValue<StoredDraft<unknown> | 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<V>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -37,6 +37,18 @@ function wrapped<V>(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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,16 @@ 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()
|
||||
@@ -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: '' })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user