Files
immich-folio/lib/cache.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

119 lines
3.4 KiB
TypeScript

/**
* Simple in-memory LRU cache for Immich API responses.
* Avoids hammering the Immich server with identical requests.
*
* Entries carry two deadlines. `staleAt` is the normal TTL — past it, get()
* reports a miss and callers refetch. `hardExpiresAt` is the point past which
* the data is too old to show at all. Between the two, the entry survives as a
* fallback that getStale() hands out when Immich is unreachable, so a
* restarting server does not turn the gallery into an error page.
*/
interface CacheEntry<T> {
data: T;
staleAt: number;
hardExpiresAt: number;
}
const DEFAULT_STALE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* The entries live on globalThis, not in this module.
*
* Next bundles each route separately, so every route handler gets its own
* instance of this module. A module-level Map meant the admin panel's
* "Clear cache", a save's invalidation and the Immich webhook each cleared a
* cache no page ever read from, and the diagnostics page reported the size
* of its own, always empty, copy ("0 items"). The process is shared, so one
* store on globalThis is the cache every instance reads and clears. The key is
* versioned so a dev server's hot reload never hands a new build entries in
* an older shape.
*/
const STORE_KEY = Symbol.for('immich-folio.immich-cache.v2');
function sharedStore(): Map<string, CacheEntry<unknown>> {
const holder = globalThis as unknown as Record<symbol, Map<string, CacheEntry<unknown>>>;
holder[STORE_KEY] ??= new Map();
return holder[STORE_KEY];
}
class MemoryCache {
private store = sharedStore();
private maxEntries = 200;
get size(): number {
return this.store.size;
}
/** Move an entry to the newest end of the LRU order. */
private touch(key: string, entry: CacheEntry<unknown>): void {
this.store.delete(key);
this.store.set(key, entry);
}
/** Fresh data only. Returns null once the TTL has elapsed. */
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
const now = Date.now();
if (now > entry.hardExpiresAt) {
this.store.delete(key);
return null;
}
// Past the TTL the entry is no longer fresh, but it is deliberately kept
// so getStale() can still use it.
if (now > entry.staleAt) return null;
this.touch(key, entry);
return entry.data as T;
}
/**
* Fresh *or* stale data. Only for the upstream-unreachable path — prefer
* get() everywhere else.
*/
getStale<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.hardExpiresAt) {
this.store.delete(key);
return null;
}
this.touch(key, entry);
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs: number, staleMaxAgeMs = DEFAULT_STALE_MAX_AGE_MS): void {
if (this.store.has(key)) {
this.store.delete(key);
} else if (this.store.size >= this.maxEntries) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
const now = Date.now();
this.store.set(key, {
data,
staleAt: now + ttlMs,
// Measured from write time, and never shorter than the TTL itself.
hardExpiresAt: now + Math.max(ttlMs, staleMaxAgeMs),
});
}
delete(key: string): void {
this.store.delete(key);
}
clear(): void {
this.store.clear();
}
}
export const cache = new MemoryCache();
export { DEFAULT_STALE_MAX_AGE_MS };