From cc97b11730a4c741fbd2f88f3c12ad06b6949e81 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 18 Aug 2026 11:06:58 -0400 Subject: [PATCH] feat: let each cache be emptied whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dropAll` clears every scoped entry in both storage areas; `dropProbes` clears detection's well-known answers, which keep their own policy and are the only cache nothing else empties. Refresh bypasses caches on the path it walks; neither of these has had a caller until now. Both sweep by prefix rather than clearing an area, because both areas also hold things that are not caches — the account mirror, the reader choice, the per-tab detection states — and dropping those would break rather than refill. Both return a count so a caller can say what went. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/cache.test.ts | 33 +++++++++++++++++++++++++++++++++ src/lib/cache.ts | 30 ++++++++++++++++++++++++++++++ src/lib/detection.test.ts | 36 ++++++++++++++++++++++++++++++++++-- src/lib/detection.ts | 24 +++++++++++++++++++++++- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/lib/cache.test.ts b/src/lib/cache.test.ts index 84e3335..9bcf9dc 100644 --- a/src/lib/cache.test.ts +++ b/src/lib/cache.test.ts @@ -8,6 +8,7 @@ import { POLICIES, type Scope, cached, + dropAll, invalidate, invalidateAccount, invalidateSubject, @@ -185,3 +186,35 @@ describe('invalidateSubject', () => { expect(await peek(key('own', 'did:plc:meta', 'subs'))).toBe(2) }) }) + +describe('dropAll', () => { + it('empties every scope, in both of the areas they live in', async () => { + await put(key('own', 'did:plc:me', 'subs'), 1) + await put(key('graph', 'did:plc:me', 'follows'), ['did:plc:you']) + await put(key('world', 'at://pub', 'labels'), 2) + + expect(await dropAll()).toBe(3) + + expect(await peek(key('own', 'did:plc:me', 'subs'))).toBeUndefined() + // On disk, so a sweep that only walked storage.session would miss it. + expect(await peek(key('graph', 'did:plc:me', 'follows'))).toBeUndefined() + expect(await peek(key('world', 'at://pub', 'labels'))).toBeUndefined() + }) + + it('leaves what is not a cache, which is what stops it being a sign-out', async () => { + local.set('session', { did: 'did:plc:me', handle: 'me.example' }) + local.set('defaultReader', 'standard-reader') + store.set('tab:7', { url: 'https://pub.example' }) + await put(key('own', 'did:plc:me', 'subs'), 1) + + expect(await dropAll()).toBe(1) + + expect(local.get('session')).toEqual({ did: 'did:plc:me', handle: 'me.example' }) + expect(local.get('defaultReader')).toBe('standard-reader') + expect(store.get('tab:7')).toEqual({ url: 'https://pub.example' }) + }) + + it('counts nothing when there was nothing cached', async () => { + expect(await dropAll()).toBe(0) + }) +}) diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 290a9ec..6f00744 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -173,3 +173,33 @@ export async function invalidateSubject(scope: Scope, subject: string): Promise< const keys = Object.keys(all).filter((k) => k.startsWith(prefix)) if (keys.length > 0) await storage.remove(keys) } + +/** + * Every entry this module owns, in both areas, so the next read of anything + * runs cold. For the dev channel's Drop cache (src/popup/popup.ts) — nothing + * in normal operation wants this, since the whole point of the policies above + * is that the extension does not re-ask a stranger's PDS on every popup. + * + * By scope prefix rather than by clearing the areas, because both hold things + * that are not caches and that dropping would break rather than refill: the + * account mirror the worker reads (`session` in storage.local), the stored + * reader choice, and the per-tab detection states the badges are drawn from. + * The worker drops those last ones itself, where it can re-badge as it goes. + * + * Returns how many entries went, so the caller can say. + */ +export async function dropAll(): Promise { + const areas = ['session', 'local'] as const + const counts = await Promise.all( + areas.map(async (name) => { + const scopes = (Object.keys(POLICIES) as Scope[]).filter((s) => POLICIES[s].area === name) + if (scopes.length === 0) return 0 + const storage = chrome.storage[name] + const all = await storage.get(null) + const keys = Object.keys(all).filter((k) => scopes.some((s) => k.startsWith(`${s}:`))) + if (keys.length > 0) await storage.remove(keys) + return keys.length + }), + ) + return counts.reduce((a, b) => a + b, 0) +} diff --git a/src/lib/detection.test.ts b/src/lib/detection.test.ts index 2a9531a..d69d21b 100644 --- a/src/lib/detection.test.ts +++ b/src/lib/detection.test.ts @@ -5,7 +5,7 @@ // suggests, which is why they are pinned here rather than in a live capture. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { detectPage, wellKnownUrl } from './detection' +import { detectPage, dropProbes, wellKnownUrl } from './detection' const DID = 'did:plc:detectiontest0000000000' const URI = `at://${DID}/site.standard.publication/self` @@ -20,10 +20,18 @@ function stubChromeStorage() { vi.stubGlobal('chrome', { storage: { session: { - get: async (key: string) => (store.has(key) ? { [key]: store.get(key) } : {}), + get: async (key: string | null) => + key === null + ? Object.fromEntries(store) + : store.has(key) + ? { [key]: store.get(key) } + : {}, set: async (items: Record) => { for (const [k, v] of Object.entries(items)) store.set(k, v) }, + remove: async (keys: string | string[]) => { + for (const k of [keys].flat()) store.delete(k) + }, }, }, }) @@ -179,6 +187,30 @@ describe('well-known probe caching', () => { expect(sessionStore.get(keys[0]!)).toMatchObject({ uri: null }) }) + // The dev build's Drop cache: this cache is the one nothing else empties, + // since Refresh only bypasses it for the page it is walking. + it('re-asks every origin after the probe answers are dropped', async () => { + const fetchSpy = missingWellKnown() + await detectPage('https://example.test/one') + const afterFirst = fetchSpy.mock.calls.length + + expect(await dropProbes()).toBeGreaterThan(0) + expect([...sessionStore.keys()].filter((k) => k.startsWith('wk:'))).toEqual([]) + + await detectPage('https://example.test/one') + expect(fetchSpy.mock.calls.length).toBeGreaterThan(afterFirst) + }) + + it('leaves everything that is not a probe answer alone', async () => { + missingWellKnown() + // A per-tab detection state: the worker's, dropped by the worker, and not + // something a sweep of this cache may take with it. + sessionStore.set('tab:7', { url: 'https://example.test/one' }) + await detectPage('https://example.test/one') + await dropProbes() + expect(sessionStore.get('tab:7')).toEqual({ url: 'https://example.test/one' }) + }) + it('reports no publication rather than throwing when every probe misses', async () => { missingWellKnown() await expect(detectPage('https://example.test/one')).resolves.toEqual({ diff --git a/src/lib/detection.ts b/src/lib/detection.ts index 304c755..f0eb6c7 100644 --- a/src/lib/detection.ts +++ b/src/lib/detection.ts @@ -24,6 +24,9 @@ const WK_SUFFIX = '/.well-known/site.standard.publication' const CACHE_TTL_HIT = 5 * 60 * 1000 const CACHE_TTL_MISS = 60 * 60 * 1000 +/** What every stored probe answer is keyed under; see `dropProbes`. */ +const WK_CACHE_PREFIX = 'wk:' + /** * Probes already in flight, so two tabs opening on one origin ask once. Keyed * by well-known URL and cleared as soon as the probe settles; the storage cache @@ -46,7 +49,7 @@ export function wellKnownUrl(base: string): string { */ async function probeWellKnown(base: string, force = false): Promise { const wkUrl = wellKnownUrl(base) - const cacheKey = `wk:${wkUrl}` + const cacheKey = WK_CACHE_PREFIX + wkUrl if (!force) { const cached = (await chrome.storage.session.get(cacheKey))[cacheKey] as @@ -83,6 +86,25 @@ async function probeWellKnown(base: string, force = false): Promise { + const all = await chrome.storage.session.get(null) + const keys = Object.keys(all).filter((k) => k.startsWith(WK_CACHE_PREFIX)) + if (keys.length > 0) await chrome.storage.session.remove(keys) + return keys.length +} + /** * Load and resolve a publication record from its at-uri. Null for a uri that * is not a publication; throws when the record exists but cannot be fetched, -- 2.51.2