import type { TagCache } from "./memory.ts"; export interface CacheInvalidationBus { publish(topic: string, message: unknown): void | Promise; subscribe(pattern: string, handler: (message: unknown) => void | Promise): () => void; } export interface DistributedInvalidationOptions { namespace?: string; instanceId?: string; onError?: (error: unknown) => void; } type InvalidationMessage = | { source: string; operation: "tags"; tags: string[] } | { source: string; operation: "key"; key: string } | { source: string; operation: "clear" }; export interface DistributedInvalidation { invalidateTag(tag: string): Promise; invalidateTags(tags: Iterable): Promise; delete(key: string): Promise; clear(): Promise; close(): void; } /** * Propagate cache invalidations over any structurally compatible pub/sub bus. * The bus is intentionally not closed because applications commonly share it. */ export function connectCacheInvalidation( cache: TagCache, bus: CacheInvalidationBus, options: DistributedInvalidationOptions = {}, ): DistributedInvalidation { const namespace = options.namespace?.trim() || "default"; if (!/^[a-zA-Z0-9._-]+$/.test(namespace)) { throw new TypeError("cache invalidation namespace contains unsupported characters"); } const source = options.instanceId?.trim() || crypto.randomUUID(); const topic = `wrnexus:cache:${namespace}:invalidate`; let closed = false; const publish = async (message: InvalidationMessage): Promise => { if (closed) throw new Error("WRN-CACHE-INVALIDATION-CLOSED: invalidation channel is closed"); try { await bus.publish(topic, message); } catch (error) { options.onError?.(error); throw error; } }; const unsubscribe = bus.subscribe(topic, (value) => { if (!value || typeof value !== "object") return; const message = value as Partial; if (message.source === source) return; if (message.operation === "tags" && Array.isArray(message.tags)) { cache.invalidateTags(message.tags.filter((tag): tag is string => typeof tag === "string")); } else if (message.operation === "key" && typeof message.key === "string") { cache.delete(message.key); } else if (message.operation === "clear") { cache.clear(); } }); const invalidateTags = async (input: Iterable): Promise => { const tags = [...new Set(input)].filter(Boolean); const removed = cache.invalidateTags(tags); await publish({ source, operation: "tags", tags }); return removed; }; return { async invalidateTag(tag) { return invalidateTags([tag]); }, invalidateTags, async delete(key) { const removed = cache.delete(key); await publish({ source, operation: "key", key }); return removed; }, async clear() { cache.clear(); await publish({ source, operation: "clear" }); }, close() { if (closed) return; closed = true; unsubscribe(); }, }; }