Skip to content

Commit e7b5515

Browse files
committed
refactor(client): extract cache tiers from static-data-provider (1,335 → 562 lines)
Extract four cache tier implementations into focused modules: - MemoryCacheTier: In-memory LRU cache with automatic eviction - LocalDiskCacheTier: Node.js filesystem cache with browser fallback - IndexedDBCacheTier: Dexie-based persistent browser storage - GitHubPagesCacheTier: HTTP fetching with retry logic and exponential backoff Additional changes: - Created cache-tiers-types.ts to break circular dependency - Renamed files to remove redundant 'cache-tier' suffix (folder name is sufficient) - Updated barrel exports in cache/tiers/index.ts Benefits: - Each tier is independently testable and maintainable - Clear separation of concerns with CacheTierInterface - Reduced main file complexity by 58% - Preserved identical public API (no breaking changes) - Eliminated circular dependency between static-data-provider and cache tiers
1 parent 6116510 commit e7b5515

File tree

8 files changed

+2756
-1298
lines changed

8 files changed

+2756
-1298
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Shared types for cache tier implementations
3+
* Extracted to avoid circular dependencies
4+
*/
5+
6+
import type { StaticEntityType } from "./static-data-utils";
7+
import type { StaticDataResult } from "./static-data-provider";
8+
import { CacheTier } from "./static-data-provider";
9+
10+
/**
11+
* Interface for all cache tier implementations
12+
* Each tier provides get/has/set/clear operations and statistics
13+
*/
14+
export interface CacheTierInterface {
15+
get(entityType: StaticEntityType, id: string): Promise<StaticDataResult>;
16+
has(entityType: StaticEntityType, id: string): Promise<boolean>;
17+
set?(entityType: StaticEntityType, id: string, data: unknown): Promise<void>;
18+
clear?(): Promise<void>;
19+
getStats(): Promise<{
20+
requests: number;
21+
hits: number;
22+
averageLoadTime: number;
23+
}>;
24+
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/**
2+
* GitHub Pages cache tier for static data
3+
* Fetches pre-cached entities from GitHub Pages or local static JSON files
4+
*/
5+
6+
import type { StaticEntityType } from "../../static-data-utils";
7+
import { logger } from "@bibgraph/utils";
8+
import { CacheTier } from "../../static-data-provider";
9+
import type { StaticDataResult } from "../../static-data-provider";
10+
import type { CacheTierInterface } from "../../cache-tiers-types";
11+
12+
interface CacheStats {
13+
requests: number;
14+
hits: number;
15+
totalLoadTime: number;
16+
}
17+
18+
interface FailureState {
19+
lastFailure: number;
20+
attempts: number;
21+
cooldownUntil?: number;
22+
}
23+
24+
interface RetryConfig {
25+
maxAttempts: number;
26+
baseDelayMs: number;
27+
maxDelayMs: number;
28+
jitterMs: number;
29+
cooldownMs: number;
30+
}
31+
32+
interface HttpError {
33+
message: string;
34+
status: number;
35+
retryAfter?: string;
36+
}
37+
38+
/**
39+
* Calculate cache statistics from raw stats
40+
*/
41+
function calculateCacheStats(stats: CacheStats): {
42+
requests: number;
43+
hits: number;
44+
averageLoadTime: number;
45+
} {
46+
return {
47+
requests: stats.requests,
48+
hits: stats.hits,
49+
averageLoadTime:
50+
stats.requests > 0 ? stats.totalLoadTime / stats.requests : 0,
51+
};
52+
}
53+
54+
/**
55+
* GitHub Pages cache implementation for static data
56+
*/
57+
export class GitHubPagesCacheTier implements CacheTierInterface {
58+
private stats: CacheStats = { requests: 0, hits: 0, totalLoadTime: 0 };
59+
private readonly LOG_PREFIX = "github-pages-cache";
60+
private baseUrl: string;
61+
62+
// Track recent failures per URL to avoid repeated bursts against remote
63+
private recentFailures: Map<string, FailureState> = new Map();
64+
65+
// Configurable retry policy for remote tier
66+
private retryConfig: RetryConfig = (() => {
67+
const isTest = Boolean(
68+
globalThis.process?.env?.VITEST ??
69+
globalThis.process?.env?.NODE_ENV === "test",
70+
);
71+
return {
72+
maxAttempts: 3,
73+
baseDelayMs: isTest ? 50 : 1000,
74+
maxDelayMs: isTest ? 200 : 10_000,
75+
jitterMs: isTest ? 0 : 500,
76+
cooldownMs: isTest ? 1000 : 30_000, // shorter cooldown in tests
77+
};
78+
})();
79+
80+
constructor(baseUrl?: string) {
81+
// Don't set a default URL - require explicit configuration
82+
// This prevents attempting to fetch from non-existent placeholder URLs
83+
this.baseUrl = baseUrl ?? "";
84+
}
85+
86+
private getUrl(entityType: StaticEntityType, id: string): string {
87+
// Sanitize ID for URL
88+
const sanitizedId = encodeURIComponent(id);
89+
return `${this.baseUrl}${entityType}/${sanitizedId}.json`;
90+
}
91+
92+
/**
93+
* Create a typed HTTP error object
94+
*/
95+
private createHttpError(response: Response): HttpError {
96+
return {
97+
message: `HTTP ${response.status}: ${response.statusText}`,
98+
status: response.status,
99+
retryAfter: response.headers.get("Retry-After") ?? undefined,
100+
};
101+
}
102+
103+
/**
104+
* Calculate retry delay with exponential backoff and jitter
105+
*/
106+
private calculateRetryDelay(attempt: number, retryAfterSec?: number): number {
107+
const base = this.retryConfig.baseDelayMs * Math.pow(2, attempt - 1);
108+
const jitter = Math.random() * this.retryConfig.jitterMs;
109+
return Math.min(
110+
(retryAfterSec ? retryAfterSec * 1000 : base) + jitter,
111+
this.retryConfig.maxDelayMs,
112+
);
113+
}
114+
115+
/**
116+
* Update failure state for a URL
117+
*/
118+
private updateFailureState(url: string, error: unknown): void {
119+
const prev = this.recentFailures.get(url) ?? {
120+
lastFailure: 0,
121+
attempts: 0,
122+
cooldownUntil: undefined,
123+
};
124+
const newState: FailureState = {
125+
lastFailure: Date.now(),
126+
attempts: prev.attempts + 1,
127+
cooldownUntil: prev.cooldownUntil,
128+
};
129+
130+
// If it's a 404, don't set cooldown
131+
const is404 =
132+
typeof error === "object" &&
133+
error !== null &&
134+
"status" in error &&
135+
typeof (error as Record<string, unknown>).status === "number" &&
136+
(error as Record<string, unknown>).status === 404;
137+
138+
if (!is404 && newState.attempts >= this.retryConfig.maxAttempts) {
139+
newState.cooldownUntil = Date.now() + this.retryConfig.cooldownMs;
140+
}
141+
142+
this.recentFailures.set(url, newState);
143+
}
144+
145+
async get(
146+
entityType: StaticEntityType,
147+
id: string,
148+
): Promise<StaticDataResult> {
149+
const startTime = Date.now();
150+
this.stats.requests++;
151+
152+
// Skip if no base URL configured
153+
if (!this.baseUrl) {
154+
return { found: false };
155+
}
156+
157+
const url = this.getUrl(entityType, id);
158+
159+
// If we recently hit repeated failures for this URL, respect cooldown
160+
const failureState = this.recentFailures.get(url);
161+
if (
162+
failureState?.cooldownUntil &&
163+
Date.now() < failureState.cooldownUntil
164+
) {
165+
logger.debug(
166+
this.LOG_PREFIX,
167+
"Skipping GitHub Pages fetch due to recent failures",
168+
{ url, entityType, id, failureState },
169+
);
170+
return { found: false };
171+
}
172+
173+
const attemptFetch = async (attempt: number): Promise<StaticDataResult> => {
174+
try {
175+
const controller = new AbortController();
176+
const timeoutId = setTimeout(() => {
177+
controller.abort();
178+
}, 10_000); // 10 second timeout
179+
180+
const response = await fetch(url, {
181+
method: "GET",
182+
headers: {
183+
Accept: "application/json",
184+
"Cache-Control": "max-age=3600", // 1 hour cache
185+
},
186+
signal: controller.signal,
187+
});
188+
189+
clearTimeout(timeoutId);
190+
191+
if (!response.ok) {
192+
if (response.status === 404) {
193+
return { found: false };
194+
}
195+
throw this.createHttpError(response);
196+
}
197+
198+
const data: unknown = await response.json();
199+
this.recentFailures.delete(url);
200+
201+
this.stats.hits++;
202+
const loadTime = Date.now() - startTime;
203+
this.stats.totalLoadTime += loadTime;
204+
205+
return {
206+
found: true,
207+
data,
208+
cacheHit: true,
209+
tier: CacheTier.GITHUB_PAGES,
210+
loadTime,
211+
};
212+
} catch (error: unknown) {
213+
logger.debug(this.LOG_PREFIX, "GitHub Pages fetch attempt failed", {
214+
url,
215+
entityType,
216+
id,
217+
attempt,
218+
error,
219+
});
220+
221+
this.updateFailureState(url, error);
222+
223+
// Check if it's a 404 error
224+
const is404 =
225+
typeof error === "object" &&
226+
error !== null &&
227+
"status" in error &&
228+
typeof (error as Record<string, unknown>).status === "number" &&
229+
(error as Record<string, unknown>).status === 404;
230+
if (is404) {
231+
return { found: false };
232+
}
233+
234+
// Retry if attempts remain
235+
if (attempt < this.retryConfig.maxAttempts) {
236+
let retryAfterSec: number | undefined;
237+
if (
238+
typeof error === "object" &&
239+
error !== null &&
240+
"retryAfter" in error
241+
) {
242+
const errorObj = error as Record<string, unknown>;
243+
const retryAfter = errorObj.retryAfter;
244+
if (typeof retryAfter === "string") {
245+
retryAfterSec = Number.parseInt(retryAfter);
246+
}
247+
}
248+
249+
const delay = this.calculateRetryDelay(attempt, retryAfterSec);
250+
await new Promise((resolve) => setTimeout(resolve, delay));
251+
return attemptFetch(attempt + 1);
252+
}
253+
254+
return { found: false };
255+
}
256+
};
257+
258+
try {
259+
return await attemptFetch(1);
260+
} catch (finalErr) {
261+
logger.debug(this.LOG_PREFIX, "GitHub Pages fetch final failure", {
262+
url,
263+
entityType,
264+
id,
265+
error: String(finalErr),
266+
});
267+
return { found: false };
268+
}
269+
}
270+
271+
async has(entityType: StaticEntityType, id: string): Promise<boolean> {
272+
// Skip if no base URL configured
273+
if (!this.baseUrl) {
274+
return false;
275+
}
276+
277+
try {
278+
const url = this.getUrl(entityType, id);
279+
const controller = new AbortController();
280+
const timeoutId = setTimeout(() => {
281+
controller.abort();
282+
}, 5000); // 5 second timeout for HEAD request
283+
284+
const response = await fetch(url, {
285+
method: "HEAD",
286+
signal: controller.signal,
287+
});
288+
289+
clearTimeout(timeoutId);
290+
return response.ok;
291+
} catch {
292+
return false;
293+
}
294+
}
295+
296+
async getStats(): Promise<{
297+
requests: number;
298+
hits: number;
299+
averageLoadTime: number;
300+
}> {
301+
return calculateCacheStats(this.stats);
302+
}
303+
304+
/**
305+
* Get the configured base URL for the GitHub Pages cache
306+
*/
307+
getBaseUrl(): string {
308+
return this.baseUrl;
309+
}
310+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { MemoryCacheTier } from "./memory";
2+
export { LocalDiskCacheTier } from "./local-disk";
3+
export { IndexedDBCacheTier } from "./indexeddb";
4+
export { GitHubPagesCacheTier } from "./githubpages";

0 commit comments

Comments
 (0)