feat(FN-4924): complete Step 1 — add swr cache envelope ttl

Fusion-Task-Id: FN-4924
Fusion-Task-Lineage: a5f882e1-8348-4f9d-89be-15994adc148c
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 10:52:00 -07:00
committed by gsxdsm
parent ff7c2336cf
commit 7ad3449c8f
2 changed files with 94 additions and 6 deletions

View File

@@ -1,9 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SWR_CACHE_KEYS, clearCache, readCache, writeCache } from "../swrCache";
import {
SWR_CACHE_KEYS,
SWR_DEFAULT_MAX_AGE_MS,
SWR_LONG_MAX_AGE_MS,
clearCache,
readCache,
writeCache,
} from "../swrCache";
describe("swrCache", () => {
beforeEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
localStorage.clear();
vi.restoreAllMocks();
@@ -13,11 +21,55 @@ describe("swrCache", () => {
expect(readCache("missing")).toBeNull();
});
it("writes and reads cache payload", () => {
it("writes and reads enveloped cache payload", () => {
const payload = { value: "ok", count: 2 };
writeCache("demo", payload);
expect(readCache<typeof payload>("demo")).toEqual(payload);
const raw = JSON.parse(localStorage.getItem("demo") ?? "null") as {
savedAt?: number;
data?: typeof payload;
};
expect(typeof raw.savedAt).toBe("number");
expect(raw.data).toEqual(payload);
});
it("respects maxAgeMs for enveloped payloads", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
writeCache("ttl", { value: "fresh" });
vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
expect(readCache<{ value: string }>("ttl", { maxAgeMs: 1_000 })).toBeNull();
expect(readCache<{ value: string }>("ttl")).toEqual({ value: "fresh" });
vi.useRealTimers();
});
it("supports legacy un-enveloped payloads even with maxAgeMs", () => {
const payload = [{ id: "x" }];
localStorage.setItem("legacy", JSON.stringify(payload));
expect(readCache<typeof payload>("legacy")).toEqual(payload);
expect(readCache<typeof payload>("legacy", { maxAgeMs: 1_000 })).toEqual(payload);
});
it("treats invalid savedAt envelope values as legacy payloads", () => {
localStorage.setItem("bad-savedAt", JSON.stringify({ savedAt: "abc", data: [1, 2] }));
localStorage.setItem("bad-savedAt-nan", JSON.stringify({ savedAt: Number.NaN, data: { ok: true } }));
localStorage.setItem("bad-savedAt-no-data", JSON.stringify({ savedAt: "abc" }));
expect(readCache<number[]>("bad-savedAt")).toEqual([1, 2]);
expect(readCache<{ ok: boolean }>("bad-savedAt-nan")).toEqual({ ok: true });
expect(readCache("bad-savedAt-no-data")).toBeNull();
});
it("treats clock-skew future savedAt as fresh", () => {
const skewedSavedAt = Date.now() + 60_000;
localStorage.setItem("clock-skew", JSON.stringify({ savedAt: skewedSavedAt, data: { ok: true } }));
expect(readCache<{ ok: boolean }>("clock-skew", { maxAgeMs: 1_000 })).toEqual({ ok: true });
});
it("does not write when payload exceeds maxBytes", () => {
@@ -58,7 +110,7 @@ describe("swrCache", () => {
expect(readCache(SWR_CACHE_KEYS.PROJECTS)).toEqual([{ id: "p" }]);
});
it("exports expected extended cache keys", () => {
it("exports expected cache keys and TTL constants", () => {
expect(SWR_CACHE_KEYS.INSIGHTS_PREFIX).toBe("kb-dashboard-insights-cache:");
expect(SWR_CACHE_KEYS.INSIGHT_LATEST_RUN_PREFIX).toBe("kb-dashboard-insight-latest-run-cache:");
expect(SWR_CACHE_KEYS.RESEARCH_RUNS_PREFIX).toBe("kb-dashboard-research-runs-cache:");
@@ -70,6 +122,8 @@ describe("swrCache", () => {
expect(SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX).toBe("kb-dashboard-mailbox-inbox-cache:");
expect(SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX).toBe("kb-dashboard-mailbox-outbox-cache:");
expect(SWR_CACHE_KEYS.MAILBOX_UNREAD_COUNT_PREFIX).toBe("kb-dashboard-mailbox-unread-cache:");
expect(SWR_DEFAULT_MAX_AGE_MS).toBe(10 * 60 * 1000);
expect(SWR_LONG_MAX_AGE_MS).toBe(24 * 60 * 60 * 1000);
});
it("swallows quota errors", () => {

View File

@@ -30,6 +30,14 @@ export const SWR_CACHE_KEYS = {
const DEFAULT_MAX_BYTES = 500_000;
interface CacheEnvelope<T> {
savedAt: number;
data: T;
}
export const SWR_DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
export const SWR_LONG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getLocalStorage(): Storage | null {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
@@ -40,7 +48,7 @@ function getLocalStorage(): Storage | null {
return null;
}
export function readCache<T>(key: string): T | null {
export function readCache<T>(key: string, options?: { maxAgeMs?: number }): T | null {
const storage = getLocalStorage();
if (!storage) {
return null;
@@ -52,7 +60,30 @@ export function readCache<T>(key: string): T | null {
return null;
}
return JSON.parse(raw) as T;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return parsed as T;
}
const hasEnvelopeMarkers = "savedAt" in parsed || "data" in parsed;
if (!hasEnvelopeMarkers) {
return parsed as T;
}
const envelope = parsed as Partial<CacheEnvelope<T>>;
if (typeof envelope.savedAt !== "number" || Number.isNaN(envelope.savedAt)) {
return envelope.data ?? null;
}
const maxAgeMs = options?.maxAgeMs;
if (typeof maxAgeMs === "number") {
const ageMs = Date.now() - envelope.savedAt;
if (ageMs > maxAgeMs) {
return null;
}
}
return envelope.data ?? null;
} catch {
return null;
}
@@ -65,7 +96,10 @@ export function writeCache<T>(key: string, value: T, options?: { maxBytes?: numb
}
try {
const serialized = JSON.stringify(value);
const serialized = JSON.stringify({
savedAt: Date.now(),
data: value,
} satisfies CacheEnvelope<T>);
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
if (new TextEncoder().encode(serialized).length > maxBytes) {
return;