80 lines
2.4 KiB
TypeScript
80 lines
2.4 KiB
TypeScript
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<string, CacheEntry> = 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;
|
|
}
|
|
}
|