feat(02-03): purge every cached resource on logout
Demo images / Build & push demo images (push) Successful in 3m34s

- resources.ts: clearAll() drops memory entries, in-flight/revalidator/
  invalidate-timer bookkeeping, and every resource:-prefixed sessionStorage
  key; a generation counter stops an in-flight fetch that resolves after
  clearAll from repopulating memory or sessionStorage (T-02-02)
- auth.ts: logout() calls clearAll() in the finally path so a failed
  server-side logout still leaves no cached payload behind locally
- resourcesClear.test.ts: covers all five required behaviors plus the
  generation-guard fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-30 08:01:01 -04:00
co-authored by Claude Fable 5
parent a9a20039eb
commit f44b8ac78c
3 changed files with 179 additions and 1 deletions
+35 -1
View File
@@ -56,6 +56,11 @@ export const useResourcesStore = defineStore('resources', () => {
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Bumped by clearAll() so an in-flight fetch from a just-ended session that
// resolves afterward can detect it and skip writing its result — without
// this guard the resolving promise would still call writeSnapshot() and
// repopulate sessionStorage even though the cache was just purged.
let generation = 0
/** Get (or create) the reactive entry for a key, hydrating from the
* sessionStorage snapshot on first sight so revisits after a reload paint
@@ -84,17 +89,22 @@ export const useResourcesStore = defineStore('resources', () => {
): Promise<void> {
const existing = inflight.get(key)
if (existing) return existing
const startGeneration = generation
const e = entry<T>(key, opts.persist ?? true)
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
const p = (async () => {
try {
const data = await fetcher()
// A clearAll() (logout) ran while this fetch was in flight — drop
// the result rather than repopulate a cache that was just purged.
if (generation !== startGeneration) return
e.data = data
e.error = null
e.fetchedAt = Date.now()
e.loadState = 'ready'
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
} catch (err) {
if (generation !== startGeneration) return
e.error = err instanceof Error ? err.message : String(err)
// Keep last-known data visible; only 'error' when we have nothing.
e.loadState = e.data !== null ? 'ready' : 'error'
@@ -162,5 +172,29 @@ export const useResourcesStore = defineStore('resources', () => {
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
/** Purge every cached resource — memory and sessionStorage — so no payload
* from this session outlives a logout or identity switch (T-02-02). Cancels
* pending invalidate timers and drops in-flight/revalidator bookkeeping
* first, so a fetch that resolves after this call cannot repopulate the
* cache with data from the ending session. */
function clearAll(): void {
generation++
for (const timer of invalidateTimers.values()) clearTimeout(timer)
invalidateTimers.clear()
inflight.clear()
revalidators.clear()
entries.clear()
try {
const keys: string[] = []
for (let i = 0; i < sessionStorage.length; i++) {
const k = sessionStorage.key(i)
if (k && k.startsWith(SNAPSHOT_PREFIX)) keys.push(k)
}
for (const k of keys) sessionStorage.removeItem(k)
} catch {
/* sessionStorage unavailable or inaccessible — memory is already clear */
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict, clearAll }
})