import type { Clock } from "@suaveplan/types"; export interface PermissionCacheOptions { /** Entry TTL in milliseconds. */ readonly ttl: number; /** Maximum number of entries (default: 1 000). */ readonly maxSize?: number; readonly clock?: Clock; } interface CacheEntry { value: boolean; expiresAt: number; } /** * LRU-inspired TTL cache for permission check results. * * Stores `boolean` results keyed by a composite string (typically * `${userId}:${resource}:${action}`). Supports per-user and global * invalidation. Injectable clock for deterministic tests. */ export class PermissionCache { protected readonly store: Map = new Map(); protected readonly clock: Clock; protected readonly ttl: number; protected readonly maxSize: number; constructor(options: PermissionCacheOptions) { this.ttl = options.ttl; this.maxSize = options.maxSize ?? 1_000; this.clock = options.clock ?? (() => Date.now()); } /** Retrieve a cached result, or `undefined` if absent or expired. */ get(key: string): boolean | undefined { const entry = this.store.get(key); if (entry === undefined) return undefined; if (entry.expiresAt <= this.clock()) { this.store.delete(key); return undefined; } return entry.value; } /** Store a result with TTL. Evicts the oldest entry when full. */ set(key: string, value: boolean): void { if (this.store.size >= this.maxSize && !this.store.has(key)) { // Evict oldest entry const firstKey = this.store.keys().next().value; // Map is non-empty (size >= maxSize > 0) so firstKey is always defined this.store.delete(firstKey as string); } this.store.set(key, { value, expiresAt: this.clock() + this.ttl, }); } /** Invalidate all entries whose key starts with `userId:`. */ invalidate(userId: string): void { const prefix = `${userId}:`; for (const key of this.store.keys()) { if (key.startsWith(prefix)) { this.store.delete(key); } } } /** Remove all entries from the cache. */ invalidateAll(): void { this.store.clear(); } /** Current number of entries (including expired). */ get size(): number { return this.store.size; } }