import { isDid } from "@atcute/lexicons/syntax";
type Entry = { value: Promise<unknown>; expires: number; tags: Set<string> };
export interface LookupStats {
hits: number;
misses: number;
evictions: number;
clears: number;
}
const MAX_ENTRIES = 10_000;
export const didTag = (did: string) => `did:${did}`;
export const handleTag = (handle: string) => `handle:${handle.toLowerCase()}`;
export const identifierTag = (identifier: string) =>
isDid(identifier) ? didTag(identifier) : handleTag(identifier);
// shared by every request on the server. does nothing until configure() sets a ttl
export class LookupCache {
#ttlMs: number;
readonly #entries = new Map<string, Entry>();
readonly #tagged = new Map<string, Set<string>>();
stats: LookupStats = { hits: 0, misses: 0, evictions: 0, clears: 0 };
constructor(ttlMs = 0) {
this.#ttlMs = ttlMs;
}
get size(): number {
return this.#entries.size;
}
configure(ttlMs: number): void {
this.#ttlMs = ttlMs;
this.clear();
}
// tagsOf adds more tags once the value has loaded. undefined drops the entry
lookup<T>(
key: string,
tags: readonly string[],
load: () => Promise<T>,
tagsOf: (value: T) => readonly string[] | undefined
): Promise<T> {
if (this.#ttlMs <= 0) return load();
const now = Date.now();
const hit = this.#entries.get(key);
if (hit && hit.expires > now) {
this.stats.hits++;
return hit.value as Promise<T>;
}
if (hit) this.#drop(key);
this.stats.misses++;
const value = load();
const entry: Entry = { value, expires: now + this.#ttlMs, tags: new Set() };
this.#entries.set(key, entry);
for (const tag of tags) this.#tag(key, entry, tag);
if (this.#entries.size > MAX_ENTRIES) {
const oldest = this.#entries.keys().next().value;
if (oldest !== undefined) this.#drop(oldest);
}
value.then(
(loaded) => {
if (this.#entries.get(key) !== entry) return;
const more = tagsOf(loaded);
if (more === undefined) this.#drop(key);
else for (const tag of more) this.#tag(key, entry, tag);
},
() => {
if (this.#entries.get(key) === entry) this.#drop(key);
}
);
return value;
}
evict(tags: readonly string[]): number {
let evicted = 0;
for (const tag of tags) {
for (const key of [...(this.#tagged.get(tag) ?? [])]) {
this.#drop(key);
evicted++;
}
}
this.stats.evictions += evicted;
return evicted;
}
clear(): void {
this.#entries.clear();
this.#tagged.clear();
this.stats.clears++;
}
#tag(key: string, entry: Entry, tag: string): void {
entry.tags.add(tag);
let keys = this.#tagged.get(tag);
if (!keys) this.#tagged.set(tag, (keys = new Set()));
keys.add(key);
}
#drop(key: string): void {
const entry = this.#entries.get(key);
if (!entry) return;
this.#entries.delete(key);
for (const tag of entry.tags) {
const keys = this.#tagged.get(tag);
keys?.delete(key);
if (keys?.size === 0) this.#tagged.delete(tag);
}
}
}
export const lookups = new LookupCache();