Files
shahikitchen-prod/infrastructure/geocoding/geoapify-request-cache.ts
T

130 lines
3.3 KiB
TypeScript

import type { GeoapifyFeature } from './geoapify-types';
const CACHE_TTL_MS = 30 * 60 * 1000;
const MAX_MEMORY_ENTRIES = 80;
const MAX_SESSION_ENTRIES = 40;
const MIN_REQUEST_INTERVAL_MS = 750;
const SESSION_CACHE_KEY = 'shahi-geoapify-cache';
interface CacheEntry {
features: GeoapifyFeature[];
cachedAt: number;
}
const memoryCache = new Map<string, CacheEntry>();
const inFlight = new Map<string, Promise<GeoapifyFeature[]>>();
let lastNetworkRequestAt = 0;
let sessionHydrated = false;
export function buildGeoapifyCacheKey(query: string, lang: string): string {
return `${lang}:${query.trim().toLowerCase()}`;
}
function isFresh(entry: CacheEntry): boolean {
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
}
function trimMemoryCache(): void {
while (memoryCache.size > MAX_MEMORY_ENTRIES) {
const oldestKey = memoryCache.keys().next().value;
if (!oldestKey) break;
memoryCache.delete(oldestKey);
}
}
function hydrateFromSessionStorage(): void {
if (sessionHydrated || typeof window === 'undefined') return;
sessionHydrated = true;
try {
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, CacheEntry>;
for (const [key, entry] of Object.entries(parsed)) {
if (isFresh(entry)) {
memoryCache.set(key, entry);
}
}
} catch {
// Ignore corrupt cache payloads
}
}
function persistToSessionStorage(): void {
if (typeof window === 'undefined') return;
try {
const payload: Record<string, CacheEntry> = {};
const entries = [...memoryCache.entries()]
.filter(([, entry]) => isFresh(entry))
.slice(-MAX_SESSION_ENTRIES);
for (const [key, entry] of entries) {
payload[key] = entry;
}
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(payload));
} catch {
// sessionStorage may be full or unavailable
}
}
export function getCachedGeoapifyFeatures(
query: string,
lang: string,
): GeoapifyFeature[] | null {
hydrateFromSessionStorage();
const key = buildGeoapifyCacheKey(query, lang);
const entry = memoryCache.get(key);
if (!entry || !isFresh(entry)) {
if (entry) memoryCache.delete(key);
return null;
}
return entry.features;
}
export function setCachedGeoapifyFeatures(
query: string,
lang: string,
features: GeoapifyFeature[],
): void {
hydrateFromSessionStorage();
const key = buildGeoapifyCacheKey(query, lang);
memoryCache.set(key, { features, cachedAt: Date.now() });
trimMemoryCache();
persistToSessionStorage();
}
export function getInFlightGeoapifyRequest(
query: string,
lang: string,
): Promise<GeoapifyFeature[]> | null {
return inFlight.get(buildGeoapifyCacheKey(query, lang)) ?? null;
}
export function trackInFlightGeoapifyRequest(
query: string,
lang: string,
request: Promise<GeoapifyFeature[]>,
): Promise<GeoapifyFeature[]> {
const key = buildGeoapifyCacheKey(query, lang);
inFlight.set(key, request);
return request.finally(() => {
if (inFlight.get(key) === request) {
inFlight.delete(key);
}
});
}
export async function waitForGeoapifyRateLimit(): Promise<void> {
const elapsed = Date.now() - lastNetworkRequestAt;
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
lastNetworkRequestAt = Date.now();
}