release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-02 23:18:51 +05:30
parent 87507edf59
commit 586a6db8ff
625 changed files with 243608 additions and 11210 deletions
+100
View File
@@ -0,0 +1,100 @@
import { TagCache, type CacheEvent, type CacheSetOptions, type TagCacheOptions } from "./memory.ts";
export type CacheLayerName = "data" | "component" | "page";
export interface CacheInspection {
layers: Record<CacheLayerName, ReturnType<TagCache<unknown>["snapshot"]>>;
recentEvents: Array<CacheEvent & { layer: CacheLayerName }>;
}
export interface CacheCoordinatorOptions extends Omit<TagCacheOptions, "onEvent"> {
eventLimit?: number;
onEvent?: (event: CacheEvent & { layer: CacheLayerName }) => void;
}
/** A request-lifetime cache: deduplicates work without leaking values between requests. */
export class RequestCache {
private pending = new Map<string, Promise<unknown>>();
getOrLoad<V>(key: string, loader: () => V | Promise<V>): Promise<V> {
const existing = this.pending.get(key);
if (existing) return existing as Promise<V>;
const value = Promise.resolve().then(loader);
this.pending.set(key, value);
return value as Promise<V>;
}
clear(): void {
this.pending.clear();
}
}
/** Owns the three cross-request cache layers and creates isolated request caches. */
export class CacheCoordinator {
readonly data: TagCache<unknown>;
readonly component: TagCache<unknown>;
readonly page: TagCache<unknown>;
private readonly events: Array<CacheEvent & { layer: CacheLayerName }> = [];
private readonly eventLimit: number;
constructor(options: CacheCoordinatorOptions = {}) {
const { eventLimit = 200, onEvent, ...cacheOptions } = options;
this.eventLimit = Math.max(1, eventLimit);
const create = (layer: CacheLayerName) =>
new TagCache<unknown>({
...cacheOptions,
onEvent: (event) => {
const item = { ...event, layer };
this.events.push(item);
if (this.events.length > this.eventLimit)
this.events.splice(0, this.events.length - this.eventLimit);
onEvent?.(item);
},
});
this.data = create("data");
this.component = create("component");
this.page = create("page");
}
request(): RequestCache {
return new RequestCache();
}
layer(name: CacheLayerName): TagCache<unknown> {
return this[name];
}
getOrLoad<V>(
layer: CacheLayerName,
key: string,
loader: () => V | Promise<V>,
options?: CacheSetOptions,
): Promise<V> {
return this.layer(layer).getOrLoad(key, loader, options) as Promise<V>;
}
invalidateTags(tags: Iterable<string>): number {
return (
this.data.invalidateTags(tags) +
this.component.invalidateTags(tags) +
this.page.invalidateTags(tags)
);
}
inspect(): CacheInspection {
return {
layers: {
data: this.data.snapshot(),
component: this.component.snapshot(),
page: this.page.snapshot(),
},
recentEvents: this.events.map((event) => ({ ...event, tags: event.tags && [...event.tags] })),
};
}
clear(): void {
this.data.clear();
this.component.clear();
this.page.clear();
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { TagCache } from "./memory.ts";
export interface CacheInvalidationBus {
publish(topic: string, message: unknown): void | Promise<void>;
subscribe(pattern: string, handler: (message: unknown) => void | Promise<void>): () => 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<number>;
invalidateTags(tags: Iterable<string>): Promise<number>;
delete(key: string): Promise<boolean>;
clear(): Promise<void>;
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<V>(
cache: TagCache<V>,
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<void> => {
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<InvalidationMessage>;
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<string>): Promise<number> => {
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();
},
};
}
+9
View File
@@ -1,4 +1,13 @@
export { TagCache } from "./memory.ts";
export type { CacheEntry, CacheLookup, CacheSetOptions, TagCacheOptions } from "./memory.ts";
export type { CacheEvent, CacheSnapshotEntry } from "./memory.ts";
export { CacheCoordinator, RequestCache } from "./coordinator.ts";
export type { CacheCoordinatorOptions, CacheInspection, CacheLayerName } from "./coordinator.ts";
export { responseCache } from "./response.ts";
export type { CachedResponse, ResponseCacheOptions } from "./response.ts";
export { connectCacheInvalidation } from "./distributed.ts";
export type {
CacheInvalidationBus,
DistributedInvalidation,
DistributedInvalidationOptions,
} from "./distributed.ts";
+95 -7
View File
@@ -19,35 +19,66 @@ export interface TagCacheOptions {
staleWhileRevalidateMs?: number;
maxEntries?: number;
clock?: () => number;
onEvent?: (event: CacheEvent) => void;
}
export interface CacheEvent {
operation: "hit" | "stale" | "miss" | "set" | "delete" | "invalidate" | "clear" | "load";
key?: string;
tags?: string[];
at: number;
}
export interface CacheSnapshotEntry {
key: string;
state: "fresh" | "stale";
createdAt: number;
expiresAt: number;
staleUntil: number;
tags: string[];
}
export class TagCache<V = unknown> {
private entries = new Map<string, CacheEntry<V>>();
private tagIndex = new Map<string, Set<string>>();
private pending = new Map<string, Promise<V>>();
private locks = new Map<string, Promise<void>>();
private revisions = new Map<string, number>();
private readonly ttlMs: number;
private readonly staleMs: number;
private readonly maxEntries: number;
private readonly clock: () => number;
private readonly onEvent?: (event: CacheEvent) => void;
constructor(options: TagCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? 60_000;
this.staleMs = options.staleWhileRevalidateMs ?? 0;
this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
this.clock = options.clock ?? Date.now;
this.onEvent = options.onEvent;
}
private emit(event: Omit<CacheEvent, "at">): void {
this.onEvent?.({ ...event, at: this.clock() });
}
lookup(key: string): CacheLookup<V> {
const entry = this.entries.get(key);
if (!entry) return { state: "miss" };
if (!entry) {
this.emit({ operation: "miss", key });
return { state: "miss" };
}
const now = this.clock();
if (entry.staleUntil <= now) {
this.delete(key);
this.emit({ operation: "miss", key });
return { state: "miss" };
}
this.entries.delete(key);
this.entries.set(key, entry);
return { state: entry.expiresAt > now ? "fresh" : "stale", entry };
const state = entry.expiresAt > now ? "fresh" : "stale";
this.emit({ operation: state === "fresh" ? "hit" : "stale", key, tags: entry.tags });
return { state, entry };
}
get(key: string): V | undefined {
@@ -56,7 +87,12 @@ export class TagCache<V = unknown> {
}
set(key: string, value: V, options: CacheSetOptions = {}): void {
this.delete(key);
this.bump(key);
this.store(key, value, options);
}
private store(key: string, value: V, options: CacheSetOptions): void {
this.removeEntry(key);
const now = this.clock();
const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs);
const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs);
@@ -69,6 +105,7 @@ export class TagCache<V = unknown> {
tags,
};
this.entries.set(key, entry);
this.emit({ operation: "set", key, tags });
for (const tag of tags) {
const keys = this.tagIndex.get(tag) ?? new Set<string>();
keys.add(key);
@@ -90,10 +127,12 @@ export class TagCache<V = unknown> {
if (hit.state === "fresh") return hit.entry.value;
if (hit.state === "stale") {
if (!this.pending.has(key)) {
const revision = this.revision(key);
this.emit({ operation: "load", key, tags: options.tags });
const refresh = Promise.resolve()
.then(loader)
.then((value) => {
this.set(key, value, options);
if (this.revision(key) === revision) this.store(key, value, options);
return value;
})
.finally(() => this.pending.delete(key));
@@ -103,10 +142,12 @@ export class TagCache<V = unknown> {
}
const existing = this.pending.get(key);
if (existing) return existing;
const revision = this.revision(key);
this.emit({ operation: "load", key, tags: options.tags });
const pending = Promise.resolve()
.then(loader)
.then((value) => {
this.set(key, value, options);
if (this.revision(key) === revision) this.store(key, value, options);
return value;
})
.finally(() => this.pending.delete(key));
@@ -114,7 +155,30 @@ export class TagCache<V = unknown> {
return pending;
}
/** Serialize arbitrary cache-adjacent work for a key without storing its result. */
async withLock<T>(key: string, task: () => T | Promise<T>): Promise<T> {
const previous = this.locks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => (release = resolve));
const queued = previous.then(() => current);
this.locks.set(key, queued);
await previous;
try {
return await task();
} finally {
release();
if (this.locks.get(key) === queued) this.locks.delete(key);
}
}
delete(key: string): boolean {
const removed = this.removeEntry(key);
if (removed || this.pending.has(key)) this.bump(key);
if (removed) this.emit({ operation: "delete", key });
return removed;
}
private removeEntry(key: string): boolean {
const entry = this.entries.get(key);
if (!entry) return false;
this.entries.delete(key);
@@ -129,23 +193,47 @@ export class TagCache<V = unknown> {
invalidateTag(tag: string): number {
const keys = [...(this.tagIndex.get(tag) ?? [])];
for (const key of keys) this.delete(key);
this.emit({ operation: "invalidate", tags: [tag] });
return keys.length;
}
invalidateTags(tags: Iterable<string>): number {
const requested = [...tags];
const keys = new Set<string>();
for (const tag of tags) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
for (const tag of requested) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key);
for (const key of keys) this.delete(key);
this.emit({ operation: "invalidate", tags: requested });
return keys.size;
}
clear(): void {
for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.bump(key);
this.entries.clear();
this.tagIndex.clear();
this.pending.clear();
this.emit({ operation: "clear" });
}
get size(): number {
return this.entries.size;
}
snapshot(): CacheSnapshotEntry[] {
const now = this.clock();
return [...this.entries.entries()].map(([key, entry]) => ({
key,
state: entry.expiresAt > now ? "fresh" : "stale",
createdAt: entry.createdAt,
expiresAt: entry.expiresAt,
staleUntil: entry.staleUntil,
tags: [...entry.tags],
}));
}
private revision(key: string): number {
return this.revisions.get(key) ?? 0;
}
private bump(key: string): void {
this.revisions.set(key, this.revision(key) + 1);
}
}