forked from EduCraft/curriculum-project-hub
4479f88f4f
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern - resolveSenderName: API call with cache integration - Trigger uses cache for approval card resolutions and audit metadata - Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
83 lines
1.9 KiB
TypeScript
83 lines
1.9 KiB
TypeScript
export interface SenderCacheOptions {
|
|
readonly ttlMs?: number;
|
|
readonly maxSize?: number;
|
|
}
|
|
|
|
interface SenderCacheEntry {
|
|
readonly name: string;
|
|
readonly expiresAt: number;
|
|
}
|
|
|
|
const DEFAULT_TTL_MS = 600_000;
|
|
const DEFAULT_MAX_SIZE = 2_048;
|
|
|
|
export class SenderNameCache {
|
|
private readonly ttlMs: number;
|
|
private readonly maxSize: number;
|
|
private readonly entries = new Map<string, SenderCacheEntry>();
|
|
|
|
constructor(options: SenderCacheOptions = {}) {
|
|
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
this.maxSize = Math.max(0, options.maxSize ?? DEFAULT_MAX_SIZE);
|
|
}
|
|
|
|
get(openId: string): string | null {
|
|
const entry = this.entries.get(openId);
|
|
if (entry === undefined) return null;
|
|
|
|
if (entry.expiresAt <= Date.now()) {
|
|
this.entries.delete(openId);
|
|
return null;
|
|
}
|
|
|
|
this.entries.delete(openId);
|
|
this.entries.set(openId, entry);
|
|
return entry.name;
|
|
}
|
|
|
|
set(openId: string, name: string): void {
|
|
if (this.maxSize === 0) return;
|
|
|
|
this.entries.delete(openId);
|
|
this.entries.set(openId, { name, expiresAt: Date.now() + this.ttlMs });
|
|
|
|
while (this.entries.size > this.maxSize) {
|
|
const oldest = this.entries.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
this.entries.delete(oldest);
|
|
}
|
|
}
|
|
|
|
async getOrFetch(
|
|
openId: string,
|
|
fetchFn: (openId: string) => Promise<string | null>,
|
|
): Promise<string | null> {
|
|
const cached = this.get(openId);
|
|
if (cached !== null) return cached;
|
|
|
|
const fetched = await fetchFn(openId);
|
|
if (fetched !== null) {
|
|
this.set(openId, fetched);
|
|
}
|
|
return fetched;
|
|
}
|
|
|
|
clear(): void {
|
|
this.entries.clear();
|
|
}
|
|
|
|
size(): number {
|
|
this.pruneExpired();
|
|
return this.entries.size;
|
|
}
|
|
|
|
private pruneExpired(): void {
|
|
const now = Date.now();
|
|
for (const [openId, entry] of this.entries) {
|
|
if (entry.expiresAt <= now) {
|
|
this.entries.delete(openId);
|
|
}
|
|
}
|
|
}
|
|
}
|