Skip to content

Commit 64edf9d

Browse files
authored
Merge pull request #11 from junctor/fix/10-local-cache-firestore
Fix #10: Add data-layer caching to eliminate repeated Firestore reads
2 parents 04a10de + eae5a04 commit 64edf9d

5 files changed

Lines changed: 420 additions & 13 deletions

File tree

src/lib/db/cache.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
const CACHE_PREFIX = "htw:v1";
2+
3+
// Conference schedule data changes infrequently, so prefer fast navigation
4+
// over frequent Firestore revalidation.
5+
export const DEFAULT_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
6+
7+
type CacheEntry<T> = {
8+
storedAt: number;
9+
value: T;
10+
};
11+
12+
type CacheValidator<T> = (value: unknown) => value is T;
13+
14+
type CacheReadOptions<T> = {
15+
ttlMs?: number;
16+
validate?: CacheValidator<T>;
17+
};
18+
19+
const memoryCache = new Map<string, CacheEntry<unknown>>();
20+
let hasPrunedCache = false;
21+
22+
const namespacedKey = (key: string) => `${CACHE_PREFIX}:${key}`;
23+
24+
function getBrowserStorage(): Storage | null {
25+
if (typeof window === "undefined") return null;
26+
27+
try {
28+
return window.localStorage;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
34+
function isCacheEntry(value: unknown): value is CacheEntry<unknown> {
35+
if (value === null || typeof value !== "object") return false;
36+
37+
const candidate = value as { storedAt?: unknown; value?: unknown };
38+
return typeof candidate.storedAt === "number" && "value" in candidate;
39+
}
40+
41+
function isFresh(entry: CacheEntry<unknown>, ttlMs: number): boolean {
42+
if (ttlMs <= 0) return false;
43+
44+
const age = Date.now() - entry.storedAt;
45+
return Number.isFinite(entry.storedAt) && age >= 0 && age <= ttlMs;
46+
}
47+
48+
export function pruneCache(ttlMs = DEFAULT_CACHE_TTL_MS): void {
49+
const storage = getBrowserStorage();
50+
if (!storage) return;
51+
52+
const prefix = `${CACHE_PREFIX}:`;
53+
const keysToRemove: string[] = [];
54+
55+
try {
56+
for (let i = 0; i < storage.length; i += 1) {
57+
const key = storage.key(i);
58+
if (!key || !key.startsWith(prefix)) continue;
59+
60+
const raw = storage.getItem(key);
61+
if (!raw) {
62+
keysToRemove.push(key);
63+
continue;
64+
}
65+
66+
try {
67+
const parsed: unknown = JSON.parse(raw);
68+
69+
if (!isCacheEntry(parsed) || !isFresh(parsed, ttlMs)) {
70+
keysToRemove.push(key);
71+
}
72+
} catch {
73+
keysToRemove.push(key);
74+
}
75+
}
76+
77+
for (const key of keysToRemove) {
78+
try {
79+
storage.removeItem(key);
80+
memoryCache.delete(key);
81+
} catch {
82+
// Ignore cleanup failures.
83+
}
84+
}
85+
} catch {
86+
// Ignore pruning failures.
87+
}
88+
}
89+
90+
function pruneCacheOnce(): void {
91+
if (hasPrunedCache) return;
92+
93+
hasPrunedCache = true;
94+
pruneCache();
95+
}
96+
97+
function isValidValue<T>(value: unknown, validate?: CacheValidator<T>): value is T {
98+
try {
99+
return validate ? validate(value) : true;
100+
} catch {
101+
return false;
102+
}
103+
}
104+
105+
function readMemory<T>(
106+
storageKey: string,
107+
ttlMs: number,
108+
validate?: CacheValidator<T>,
109+
): T | undefined {
110+
try {
111+
const entry = memoryCache.get(storageKey);
112+
if (!entry) return undefined;
113+
114+
if (!isFresh(entry, ttlMs) || !isValidValue(entry.value, validate)) {
115+
memoryCache.delete(storageKey);
116+
return undefined;
117+
}
118+
119+
return entry.value;
120+
} catch {
121+
return undefined;
122+
}
123+
}
124+
125+
function readLocalStorage<T>(
126+
storageKey: string,
127+
ttlMs: number,
128+
validate?: CacheValidator<T>,
129+
): T | undefined {
130+
const storage = getBrowserStorage();
131+
if (!storage) return undefined;
132+
133+
try {
134+
const raw = storage.getItem(storageKey);
135+
if (!raw) return undefined;
136+
137+
const parsed: unknown = JSON.parse(raw);
138+
if (!isCacheEntry(parsed) || !isFresh(parsed, ttlMs)) {
139+
try {
140+
storage.removeItem(storageKey);
141+
} catch {
142+
// Ignore cache cleanup failures.
143+
}
144+
return undefined;
145+
}
146+
147+
if (!isValidValue(parsed.value, validate)) {
148+
try {
149+
storage.removeItem(storageKey);
150+
} catch {
151+
// Ignore cache cleanup failures.
152+
}
153+
return undefined;
154+
}
155+
156+
const entry: CacheEntry<unknown> = {
157+
storedAt: parsed.storedAt,
158+
value: parsed.value,
159+
};
160+
161+
try {
162+
memoryCache.set(storageKey, entry);
163+
} catch {
164+
// Ignore in-memory cache failures.
165+
}
166+
167+
return parsed.value;
168+
} catch {
169+
return undefined;
170+
}
171+
}
172+
173+
export function getCached<T>(key: string, options: CacheReadOptions<T> = {}): T | undefined {
174+
pruneCacheOnce();
175+
176+
const storageKey = namespacedKey(key);
177+
const ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
178+
const memoryValue = readMemory<T>(storageKey, ttlMs, options.validate);
179+
180+
if (memoryValue !== undefined) return memoryValue;
181+
182+
return readLocalStorage<T>(storageKey, ttlMs, options.validate);
183+
}
184+
185+
export function setCached<T>(key: string, value: T): void {
186+
pruneCacheOnce();
187+
188+
const storageKey = namespacedKey(key);
189+
const entry: CacheEntry<T> = {
190+
storedAt: Date.now(),
191+
value,
192+
};
193+
194+
try {
195+
memoryCache.set(storageKey, entry);
196+
} catch {
197+
// Ignore in-memory cache failures.
198+
}
199+
200+
const storage = getBrowserStorage();
201+
if (!storage) return;
202+
203+
try {
204+
storage.setItem(storageKey, JSON.stringify(entry));
205+
} catch {
206+
// Ignore quota, serialization, and security failures.
207+
}
208+
}

src/lib/db/conferences.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,71 @@ import {
1010
} from "firebase/firestore/lite";
1111
import { db } from "../firebase";
1212
import type { HTConference } from "@/types/db";
13+
import { getCached, setCached } from "./cache";
14+
15+
const conferencesKey = (count: number) => `conferences:list:${count}`;
16+
const conferenceKey = (code: string) => `conference:${code}`;
17+
const upcomingConferencesKey = "conferences:upcoming";
18+
19+
function isRecord(value: unknown): value is Record<string, unknown> {
20+
return value !== null && typeof value === "object";
21+
}
22+
23+
function isConference(value: unknown): value is HTConference {
24+
return isRecord(value) && typeof value.code === "string";
25+
}
26+
27+
function isConferenceList(value: unknown): value is HTConference[] {
28+
return Array.isArray(value) && value.every(isConference);
29+
}
30+
31+
function cacheConferences(conferences: HTConference[]) {
32+
for (const conf of conferences) {
33+
if (conf.code) setCached(conferenceKey(conf.code), conf);
34+
}
35+
}
1336

1437
export async function getConferences(count = 50): Promise<HTConference[]> {
38+
const cached = getCached<HTConference[]>(conferencesKey(count), {
39+
validate: isConferenceList,
40+
});
41+
if (cached) return cached;
42+
1543
const ref = collection(db, "conferences");
1644
const q = query(ref, orderBy("start_timestamp", "desc"), limit(count));
1745
const snap = await getDocs(q);
18-
return snap.docs.map((doc) => {
46+
const conferences = snap.docs.map((doc) => {
1947
const data = doc.data() as HTConference;
2048
return data;
2149
});
50+
51+
setCached(conferencesKey(count), conferences);
52+
cacheConferences(conferences);
53+
return conferences;
2254
}
2355

2456
export async function getConferenceByCode(
2557
code: string
2658
): Promise<HTConference | null> {
59+
const cached = getCached<HTConference>(conferenceKey(code), {
60+
validate: isConference,
61+
});
62+
if (cached) return cached;
63+
2764
const ref = doc(db, "conferences", code);
2865
const snap = await getDoc(ref);
2966
if (!snap.exists()) return null;
3067
const data = snap.data() as HTConference;
68+
setCached(conferenceKey(code), data);
3169
return data;
3270
}
3371

3472
export async function getUpcomingConferences(): Promise<HTConference[]> {
73+
const cached = getCached<HTConference[]>(upcomingConferencesKey, {
74+
validate: isConferenceList,
75+
});
76+
if (cached) return cached;
77+
3578
const ref = collection(db, "conferences");
3679
const q = query(
3780
ref,
@@ -40,8 +83,12 @@ export async function getUpcomingConferences(): Promise<HTConference[]> {
4083
limit(50)
4184
);
4285
const snap = await getDocs(q);
43-
return snap.docs.map((doc) => {
86+
const conferences = snap.docs.map((doc) => {
4487
const data = doc.data() as HTConference;
4588
return data;
4689
});
90+
91+
setCached(upcomingConferencesKey, conferences);
92+
cacheConferences(conferences);
93+
return conferences;
4794
}

0 commit comments

Comments
 (0)