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:
committed by
gsxdsm
parent
ff7c2336cf
commit
7ad3449c8f
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user