feat(desktop): time-based sweep for stale localStorage caches (#5453)

Part of #5418 (Phase 1, lane B).

## What

Adds a periodic, whitelist-driven TTL sweep for disposable localStorage
caches so a desktop session left open for days converges to the same
storage state as one restarted nightly.

- New `desktop/src/shared/lib/localStorageSweep.ts`: declarative
`LOCAL_STORAGE_SWEEP_RULES` table — six repaintable pure-cache prefixes
(matching `PURE_CACHE_KEY_PREFIXES` in `localStorageQuota.ts`), all
14-day TTL, keyed on each payload's `updatedAt` (user-label buckets use
their newest nested per-profile timestamp).
- Entries with no trustworthy timestamp are retained, never guessed
stale. `buzz-self-profile.v1:` is deliberately excluded — it is the
load-bearing offline identity fallback (guard comment in the table).
- Scheduler: first sweep deferred off the boot critical path via
`requestIdleCallback` (1.5s timeout) with a 250ms timer fallback, then
hourly and on return-to-visible, debounced to 5 minutes. Throw-safe
throughout (failures `console.warn`, never crash — per `safeStorage.ts`
conventions / #5078).
- Wired in `desktop/src/main.tsx` beside
`recoverLocalStorageQuotaOnStartup()`.

## Validation

- Focused node test 7/7 at HEAD; pre-push gate green (desktop-check,
desktop-typecheck, full desktop-test 4542/4542).
- Manual Playwright (not covered by push hooks):
`relay-connectivity.spec.ts -g "04"` (offline cached identity) passes
1/1 at HEAD — this spec caught and now guards the v1 regression.
- Independent adversarial review: FULL REVIEW (REQUEST CHANGES) then
VERIFIED — PASS at exactly this commit, including whitelist containment
against the 58-site inventory, scheduler tracing, and smoke E2E.

Authored by Summer (agent), reviewed by Beth (agent), integrated by Rick
(agent). Discussion: Buzz channel time-based-localstorage-eviction,
thread 0d85a73ca43e54748128f89c3512a4726131bf5473253395d46bf8f3a7b58bd4.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Summer <1fdd3cc104e2911eb3b2da6f97d1b25f4a7f3550ded4492b24ff1d95acd66766@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-10 09:47:04 -07:00
committed by GitHub
co-authored by Summer
parent 43573d114b
commit bb9aae1065
3 changed files with 475 additions and 0 deletions
+2
View File
@@ -18,6 +18,7 @@ import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota";
import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
type E2eWindow = Window & {
__BUZZ_E2E__?: unknown;
@@ -122,6 +123,7 @@ async function bootstrap() {
resetDevWebviewStateFromUrl();
configureDevE2eBridgeFromUrl();
recoverLocalStorageQuotaOnStartup();
startLocalStorageSweep();
await installE2eBridgeIfConfigured();
await migrateLegacyCommunityStorageBeforeRender();
renderApp();
@@ -0,0 +1,316 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LOCAL_STORAGE_SWEEP_RULES,
startLocalStorageSweep,
sweepStaleLocalStorage,
} from "./localStorageSweep.ts";
const DAY_MS = 24 * 60 * 60 * 1_000;
function makeLocalStorage(entries = []) {
const store = new Map(entries);
return {
store,
get length() {
return store.size;
},
key: (index) => [...store.keys()][index] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}
function installWindow(localStorage, overrides = {}) {
globalThis.window = { localStorage, ...overrides };
}
const snapshot = (updatedAt) => JSON.stringify({ updatedAt, payload: "cache" });
test("sweeps stale whitelisted caches and keeps fresh or durable state", () => {
const now = 100 * DAY_MS;
const entries = LOCAL_STORAGE_SWEEP_RULES.flatMap(
({ keyPrefix, maxAgeMs }, index) => [
[`${keyPrefix}stale-a-${index}`, snapshot(now - maxAgeMs)],
[`${keyPrefix}stale-b-${index}`, snapshot(now - maxAgeMs - DAY_MS)],
[`${keyPrefix}fresh-${index}`, snapshot(now - maxAgeMs + 1)],
],
);
entries.push(["buzz-communities", snapshot(0)]);
entries.push(["buzz-theme", snapshot(0)]);
entries.push(["buzz-self-profile.v1:offline", snapshot(0)]);
const localStorage = makeLocalStorage(entries);
installWindow(localStorage);
assert.equal(
sweepStaleLocalStorage(now),
LOCAL_STORAGE_SWEEP_RULES.length * 2,
);
for (const { keyPrefix } of LOCAL_STORAGE_SWEEP_RULES) {
assert.equal(
[...localStorage.store.keys()].some((key) =>
key.startsWith(`${keyPrefix}stale-`),
),
false,
);
assert.equal(
[...localStorage.store.keys()].some((key) =>
key.startsWith(`${keyPrefix}fresh-`),
),
true,
);
}
assert.equal(localStorage.getItem("buzz-communities"), snapshot(0));
assert.equal(localStorage.getItem("buzz-theme"), snapshot(0));
assert.equal(
localStorage.getItem("buzz-self-profile.v1:offline"),
snapshot(0),
);
});
test("uses the newest per-profile timestamp for user-label cache buckets", () => {
const now = 100 * DAY_MS;
const localStorage = makeLocalStorage([
[
"buzz-user-labels.v1:all-stale",
JSON.stringify({
profiles: {
first: { updatedAt: now - 20 * DAY_MS },
second: { updatedAt: now - 14 * DAY_MS },
},
}),
],
[
"buzz-user-labels.v1:one-fresh",
JSON.stringify({
profiles: {
stale: { updatedAt: now - 20 * DAY_MS },
fresh: { updatedAt: now - DAY_MS },
},
}),
],
]);
installWindow(localStorage);
assert.equal(sweepStaleLocalStorage(now), 1);
assert.equal(localStorage.getItem("buzz-user-labels.v1:all-stale"), null);
assert.notEqual(localStorage.getItem("buzz-user-labels.v1:one-fresh"), null);
});
test("leaves malformed and timestamp-free cache entries untouched", () => {
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:malformed", "not json"],
["buzz-channels.v1:no-timestamp", JSON.stringify({ payload: "cache" })],
["buzz-observed-unread.v1:bad-timestamp", snapshot(Number.NaN)],
]);
installWindow(localStorage);
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
assert.equal(localStorage.store.size, 3);
});
test("storage access failures warn and never escape", () => {
const originalWarn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis.window, "localStorage", {
configurable: true,
get() {
throw new Error("SecurityError");
},
});
try {
assert.doesNotThrow(() => sweepStaleLocalStorage(100 * DAY_MS));
assert.equal(sweepStaleLocalStorage(100 * DAY_MS), 0);
assert.equal(warnings.length, 2);
} finally {
console.warn = originalWarn;
}
});
test("scheduler setup failures warn and never escape startup", () => {
const originalDocument = globalThis.document;
const originalWarn = console.warn;
const warnings = [];
console.warn = (...args) => warnings.push(args);
globalThis.document = {
addEventListener() {
throw new Error("listener unavailable");
},
removeEventListener() {},
visibilityState: "visible",
};
installWindow(makeLocalStorage(), {
clearInterval() {},
setInterval() {
throw new Error("timer unavailable");
},
});
try {
let stop;
assert.doesNotThrow(() => {
stop = startLocalStorageSweep();
});
assert.doesNotThrow(() => stop());
assert.equal(warnings.length, 1);
} finally {
console.warn = originalWarn;
globalThis.document = originalDocument;
}
});
test("scheduler uses a deferred timer when requestIdleCallback is unavailable", () => {
const originalDocument = globalThis.document;
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const documentTarget = new EventTarget();
Object.defineProperty(documentTarget, "visibilityState", {
value: "visible",
});
globalThis.document = documentTarget;
const timeouts = new Map();
let nextTimeoutId = 50;
globalThis.setTimeout = (callback, delay) => {
const id = nextTimeoutId++;
timeouts.set(id, { callback, delay });
return id;
};
globalThis.clearTimeout = (id) => timeouts.delete(id);
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:startup", snapshot(0)],
]);
installWindow(localStorage, {
setInterval: () => 1,
clearInterval() {},
});
const stop = startLocalStorageSweep();
try {
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
);
const [{ callback, delay }] = timeouts.values();
assert.equal(delay, 250);
callback();
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
);
} finally {
stop();
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
globalThis.document = originalDocument;
}
assert.equal(timeouts.size, 0);
});
test("scheduler sweeps after idle, on debounced visibility, and hourly", () => {
const originalNow = Date.now;
const originalDocument = globalThis.document;
let now = 100 * DAY_MS;
Date.now = () => now;
const documentTarget = new EventTarget();
Object.defineProperty(documentTarget, "visibilityState", {
configurable: true,
value: "visible",
writable: true,
});
globalThis.document = documentTarget;
const intervals = new Map();
const idleCallbacks = new Map();
const cancelledIdleCallbacks = [];
let nextIntervalId = 1;
let nextIdleId = 100;
const localStorage = makeLocalStorage([
["buzz-channel-messages.v1:startup", snapshot(now - 14 * DAY_MS)],
]);
installWindow(localStorage, {
requestIdleCallback(callback, options) {
const id = nextIdleId++;
idleCallbacks.set(id, { callback, options });
return id;
},
cancelIdleCallback(id) {
cancelledIdleCallbacks.push(id);
idleCallbacks.delete(id);
},
setInterval(callback, delay) {
const id = nextIntervalId++;
intervals.set(id, { callback, delay });
return id;
},
clearInterval: (id) => intervals.delete(id),
});
let idleId;
const stop = startLocalStorageSweep();
try {
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
"initial sweep must not run synchronously on the boot path",
);
assert.equal(idleCallbacks.size, 1);
const idleEntry = idleCallbacks.entries().next().value;
idleId = idleEntry[0];
const { callback: idleCallback, options } = idleEntry[1];
assert.equal(options.timeout, 1_500);
idleCallback();
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:startup"),
null,
);
assert.equal(intervals.size, 1);
const [{ callback, delay }] = intervals.values();
assert.equal(delay, 60 * 60 * 1_000);
localStorage.setItem(
"buzz-channel-messages.v1:visibility",
snapshot(now - 14 * DAY_MS),
);
now += 60 * 1_000;
documentTarget.dispatchEvent(new Event("visibilitychange"));
assert.notEqual(
localStorage.getItem("buzz-channel-messages.v1:visibility"),
null,
);
now += 5 * 60 * 1_000;
documentTarget.dispatchEvent(new Event("visibilitychange"));
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:visibility"),
null,
);
localStorage.setItem(
"buzz-channel-messages.v1:interval",
snapshot(now - 14 * DAY_MS),
);
now += 60 * 60 * 1_000;
callback();
assert.equal(
localStorage.getItem("buzz-channel-messages.v1:interval"),
null,
);
} finally {
stop();
Date.now = originalNow;
globalThis.document = originalDocument;
}
assert.equal(intervals.size, 0);
assert.deepEqual(cancelledIdleCallbacks, [idleId]);
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Best-effort time-based cleanup for disposable localStorage caches.
*
* Only explicitly whitelisted cache namespaces are eligible. Durable state
* such as identities, communities, read positions, onboarding, and preferences
* must never be added here.
*/
const DAY_MS = 24 * 60 * 60 * 1_000;
const SWEEP_INTERVAL_MS = 60 * 60 * 1_000;
const SWEEP_DEBOUNCE_MS = 5 * 60 * 1_000;
const INITIAL_SWEEP_FALLBACK_MS = 250;
const INITIAL_SWEEP_IDLE_TIMEOUT_MS = 1_500;
type LocalStorageSweepRule = {
keyPrefix: string;
maxAgeMs: number;
};
/** Disposable cache namespaces and their maximum idle age. */
export const LOCAL_STORAGE_SWEEP_RULES: readonly LocalStorageSweepRule[] = [
{ keyPrefix: "buzz-channel-messages.v1:", maxAgeMs: 14 * DAY_MS },
{ keyPrefix: "buzz-channels.v1:", maxAgeMs: 14 * DAY_MS },
{ keyPrefix: "buzz-observed-unread.v1:", maxAgeMs: 14 * DAY_MS },
{ keyPrefix: "buzz-sidebar-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
{ keyPrefix: "buzz-timeline-skeleton-shape.v1:", maxAgeMs: 14 * DAY_MS },
{ keyPrefix: "buzz-user-labels.v1:", maxAgeMs: 14 * DAY_MS },
// Do not add buzz-self-profile.v1: here. It is the load-bearing offline
// identity fallback when the relay is unreachable, not a repaintable cache.
];
function updatedAtFromJson(value: string): number | null {
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null) return null;
const record = parsed as Record<string, unknown>;
if (
typeof record.updatedAt === "number" &&
Number.isFinite(record.updatedAt)
) {
return record.updatedAt;
}
// User-label cache buckets carry freshness per profile instead of at the
// payload root. Use the newest valid label timestamp so the bucket is only
// removed once every label in it is stale.
if (typeof record.profiles !== "object" || record.profiles === null) {
return null;
}
let newestUpdatedAt: number | null = null;
for (const profile of Object.values(record.profiles)) {
if (typeof profile !== "object" || profile === null) continue;
const updatedAt = (profile as Record<string, unknown>).updatedAt;
if (
typeof updatedAt === "number" &&
Number.isFinite(updatedAt) &&
(newestUpdatedAt === null || updatedAt > newestUpdatedAt)
) {
newestUpdatedAt = updatedAt;
}
}
return newestUpdatedAt;
} catch {
return null;
}
}
/**
* Removes whitelisted cache entries older than their configured TTL.
* Entries without a trustworthy `updatedAt` are left alone rather than guessed
* stale. Storage and parse failures never escape into app startup.
*/
export function sweepStaleLocalStorage(now = Date.now()): number {
let removed = 0;
try {
const storage = window.localStorage;
const staleKeys: string[] = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key === null) continue;
const rule = LOCAL_STORAGE_SWEEP_RULES.find(({ keyPrefix }) =>
key.startsWith(keyPrefix),
);
if (!rule) continue;
const value = storage.getItem(key);
if (value === null) continue;
const updatedAt = updatedAtFromJson(value);
if (updatedAt !== null && updatedAt <= now - rule.maxAgeMs) {
staleKeys.push(key);
}
}
// Collect before mutating because localStorage indexes shift on removal.
for (const key of staleKeys) {
storage.removeItem(key);
removed++;
}
} catch (error) {
console.warn("[localStorageSweep] stale cache cleanup failed:", error);
}
return removed;
}
/**
* Defers the first sweep until the browser is idle (or a short timer fallback),
* then sweeps hourly while the app remains open and when a hidden app becomes
* visible. Visibility sweeps are debounced to avoid repeated work from rapid
* focus changes. Returns a cleanup function for tests or future teardown.
*/
export function startLocalStorageSweep(): () => void {
let lastSweepAt = Number.NEGATIVE_INFINITY;
let listening = false;
let intervalId: ReturnType<typeof window.setInterval> | null = null;
let idleCallbackId: number | null = null;
let timeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;
const runIfDue = () => {
const now = Date.now();
if (now - lastSweepAt < SWEEP_DEBOUNCE_MS) return;
lastSweepAt = now;
sweepStaleLocalStorage(now);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") runIfDue();
};
try {
document.addEventListener("visibilitychange", onVisibilityChange);
listening = true;
intervalId = window.setInterval(runIfDue, SWEEP_INTERVAL_MS);
if ("requestIdleCallback" in window) {
idleCallbackId = window.requestIdleCallback(runIfDue, {
timeout: INITIAL_SWEEP_IDLE_TIMEOUT_MS,
});
} else {
timeoutId = globalThis.setTimeout(runIfDue, INITIAL_SWEEP_FALLBACK_MS);
}
} catch (error) {
console.warn("[localStorageSweep] scheduler setup failed:", error);
}
return () => {
try {
if (listening) {
document.removeEventListener("visibilitychange", onVisibilityChange);
}
if (intervalId !== null) window.clearInterval(intervalId);
if (idleCallbackId !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(idleCallbackId);
}
if (timeoutId !== null) globalThis.clearTimeout(timeoutId);
} catch (error) {
console.warn("[localStorageSweep] scheduler cleanup failed:", error);
}
};
}