// How long anything the extension reads stays good for. // // Before this, every feature answered that question for itself: eight // hand-rolled `storage.session` caches with eight TTLs between five minutes // and an hour, each with its own key format, its own staleness check and its // own refresh bypass. The TTLs did not disagree because anyone had decided // they should — they disagreed because they were written on different days. // // The answer does not actually depend on which feature is asking. It depends // on whose data it is: // // - `own` — the signed-in account's own repo, where this extension also // writes: its subscriptions, blocks, labeler subscriptions. It changes // when the person changes it, and when they change it *here* we know, so // this can be cached for an hour and dropped on the write. What it must // never do is show somebody their own action not having happened. // - `graph` — the signed-in account's own repo, which this extension only // ever reads: the follow list. See below; it is the one scope on disk. // - `world` — somebody else's publication, profile or reputation: the owner // card, subscriber counts, labels. Nothing the reader does changes it, and // it changes slowly at the source, so a day is fine and a stale day costs // a slightly out-of-date number. // // `graph` is `own` data with the write taken away, and both halves of that // matter. Nothing in this extension creates or deletes an `app.bsky.graph.follow` // record, so a stale follow set cannot show somebody their own action not // having happened — the rule the hour exists to keep. It can only lag what // they did in another client, and it costs a face missing from a decorative // row until tomorrow. That buys the day. // // The day is also why it is the one thing written to disk. The walk is one // request per hundred follows against the account's own PDS, and an account // following fifty thousand people is five hundred of them; paying that again // every time the browser restarts is not a cache. It is allowed on disk // because of what it is not: a follow list is the account's own public social // graph, and unlike everything under `world` it records nothing about which // sites were visited to produce it. // // Detection's well-known probe cache is deliberately not here. Its TTL // depends on the answer rather than on the scope — five minutes for a hit, // an hour for the miss nearly every origin gives — and it also dedups probes // already in flight, which is a different concern from freshness. It keeps // its own policy in src/lib/detection.ts, where that reasoning is written // down. // // `own` and `world` live in `chrome.storage.session`, which the browser clears // when it closes. So a day-long TTL there means "up to a day, within this // browsing session" — nothing about which publications were visited is written // to disk. Moving `world` to `storage.local` would make its day literal at // that price; it is one word here, and deliberately not taken. /** Whose data it is, which is what decides how long it keeps. */ export type Scope = 'own' | 'graph' | 'world' export interface Policy { ttlMs: number /** `local` only where the value carries no record of where the user has been. */ area: 'session' | 'local' } export const POLICIES: Record = { own: { ttlMs: 60 * 60 * 1000, area: 'session' }, graph: { ttlMs: 24 * 60 * 60 * 1000, area: 'local' }, world: { ttlMs: 24 * 60 * 60 * 1000, area: 'session' }, } /** * What a cached value is about. `subject` is the thing it describes — an * account DID for `own` and `graph`, a publication at-uri or origin for * `world` — and it * is a separate field because dropping everything cached about one subject * (signing out, switching account) has to be one call, not a list of names * kept in sync by hand. */ export interface CacheKey { scope: Scope subject: string name: string } export interface CacheOptions { /** The user asking again: read through, and refill. */ refresh?: boolean /** Override the scope's TTL. For a caller with a reason, not for taste. */ ttlMs?: number } interface Entry { at: number value: T } export function storageKey(key: CacheKey): string { return `${key.scope}:${key.subject}/${key.name}` } function area(scope: Scope) { return chrome.storage[POLICIES[scope].area] } /** * A cached read. `load` runs on a miss, on an expiry, and on `refresh`; its * result is stored and returned. * * A `load` that throws is not cached and the error reaches the caller, which * is what lets a failed lookup stay a hidden row rather than a remembered * one. A stored value is not returned once it is older than the policy, even * if `load` would fail — nothing here serves stale data to paper over an * outage, because the features using it would rather show nothing. */ export async function cached( key: CacheKey, load: () => Promise, opts: CacheOptions = {}, ): Promise { const storage = area(key.scope) const k = storageKey(key) const ttl = opts.ttlMs ?? POLICIES[key.scope].ttlMs if (!opts.refresh) { const entry = (await storage.get(k))[k] as Entry | undefined if (entry && Date.now() - entry.at < ttl) return entry.value } const value = await load() await storage.set({ [k]: { at: Date.now(), value } satisfies Entry }) return value } /** The stored value if it is still good, without running a loader. */ export async function peek(key: CacheKey, opts: CacheOptions = {}): Promise { const k = storageKey(key) const entry = (await area(key.scope).get(k))[k] as Entry | undefined const ttl = opts.ttlMs ?? POLICIES[key.scope].ttlMs return entry && Date.now() - entry.at < ttl ? entry.value : undefined } export async function put(key: CacheKey, value: T): Promise { await area(key.scope).set({ [storageKey(key)]: { at: Date.now(), value } satisfies Entry }) } /** * Drop one entry. This is what a write from this extension owes the cache: * whoever writes to a collection invalidates what was cached from it, in the * same place, so subscribing never leaves the popup insisting you have not. */ export async function invalidate(key: CacheKey): Promise { await area(key.scope).remove(storageKey(key)) } /** * Drop everything cached about one subject — every name under it. For signing * out and for switching account, where the next answer must not be the * previous account's. */ /** * Everything cached from one account's own repo, across every scope keyed by * a DID. For signing out and for switching account. * * A list of scopes rather than one call, and in here rather than at the two * call sites, because the cost of getting it wrong is the new account being * shown the old one's follows — and the way to get it wrong is to add an * account scope and not find every place that had to be told about it. */ export const ACCOUNT_SCOPES: Scope[] = ['own', 'graph'] export async function invalidateAccount(did: string): Promise { await Promise.all(ACCOUNT_SCOPES.map((scope) => invalidateSubject(scope, did))) } export async function invalidateSubject(scope: Scope, subject: string): Promise { const storage = area(scope) const prefix = `${scope}:${subject}/` const all = await storage.get(null) 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) }