release: WRNexusJS 0.8.0
This commit is contained in:
Vendored
+65
-1
@@ -3,7 +3,71 @@
|
||||
Bounded in-memory/tag caching and HTTP response caching for WRNexusJS. Supports request deduplication, tag invalidation, ETags, fresh/stale states, and optional detached stale revalidation.
|
||||
|
||||
```ts
|
||||
import { TagCache, responseCache } from "@wrnexus/cache";
|
||||
import { connectCacheInvalidation, TagCache, responseCache } from "@wrnexus/cache";
|
||||
const cache = new TagCache({ ttlMs: 60_000, staleWhileRevalidateMs: 300_000 });
|
||||
export default responseCache({ cache, tags: ["products"] });
|
||||
```
|
||||
|
||||
`TagCache` bounds entries with LRU-style eviction, deduplicates concurrent
|
||||
loaders, and prevents an invalidated in-flight loader from repopulating stale
|
||||
data. Use `lookup()` when fresh/stale state matters, or `getOrLoad()` for
|
||||
stampede-safe loading.
|
||||
|
||||
For multi-instance applications, connect the cache to any compatible pub/sub
|
||||
bus (including `@wrnexus/pubsub`). Namespaces isolate applications sharing the
|
||||
same broker. Local invalidation happens first and the returned promise confirms
|
||||
cross-instance publication; failures remain visible to the caller.
|
||||
|
||||
```ts
|
||||
import { connectCacheInvalidation, TagCache } from "@wrnexus/cache";
|
||||
import { createPubSub } from "@wrnexus/pubsub";
|
||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
|
||||
const cache = new TagCache({ maxEntries: 10_000 });
|
||||
const bus = createPubSub(redisDriver(process.env.REDIS_URL));
|
||||
const invalidation = connectCacheInvalidation(cache, bus, {
|
||||
namespace: "storefront-production",
|
||||
onError: (error) => logger.error("cache invalidation failed", { error }),
|
||||
});
|
||||
|
||||
await invalidation.invalidateTag("products");
|
||||
await invalidation.delete("product:42");
|
||||
|
||||
// Unsubscribes this cache only; the shared bus remains owned by the app.
|
||||
invalidation.close();
|
||||
await bus.close();
|
||||
```
|
||||
|
||||
## Framework cache layers
|
||||
|
||||
`CacheCoordinator` keeps the four cache lifetimes explicit:
|
||||
|
||||
- `coordinator.request()` creates request-only deduplication.
|
||||
- `coordinator.data` caches loader/query results.
|
||||
- `coordinator.component` caches reusable rendered fragments.
|
||||
- `coordinator.page` caches complete safe documents.
|
||||
|
||||
All cross-request layers are bounded, tag-aware, stale-while-revalidate capable,
|
||||
stampede-safe, and expose `withLock()` for exclusive per-key work. `inspect()`
|
||||
returns metadata without cached values. Development applications expose that
|
||||
inspection through the Cache panel and `GET /__wrnexus/cache`.
|
||||
|
||||
Pages and components can opt in declaratively:
|
||||
|
||||
```wrn
|
||||
cache {
|
||||
scope = "page"
|
||||
strategy = "stale-while-revalidate"
|
||||
ttl = "5m"
|
||||
stale = "10m"
|
||||
tags = ["catalog", "marketing"]
|
||||
vary = ["tenant", "language"]
|
||||
}
|
||||
```
|
||||
|
||||
Omit `scope` to cache named loader data. Use `scope = "page"` for full-page
|
||||
caching. Component policies cache their rendered fragment. Authenticated user
|
||||
and tenant identities are always included automatically; page caches also vary
|
||||
by language, theme, and accent. Add header names or `cookie:name` entries for
|
||||
other application-specific variation. Pages containing CSRF forms are never
|
||||
stored in the full-page cache.
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cache",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Vendored
+100
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+94
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+9
@@ -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";
|
||||
|
||||
Vendored
+95
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+104
-1
@@ -1,7 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { TagCache, responseCache } from "../src/index.ts";
|
||||
import {
|
||||
CacheCoordinator,
|
||||
connectCacheInvalidation,
|
||||
RequestCache,
|
||||
TagCache,
|
||||
responseCache,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/cache", () => {
|
||||
test("separates request, data, component, and page cache lifetimes", async () => {
|
||||
const coordinator = new CacheCoordinator({ ttlMs: 100, eventLimit: 10 });
|
||||
coordinator.data.set("user:1", { id: 1 }, { tags: ["users"] });
|
||||
coordinator.component.set("avatar:1", "html", { tags: ["users"] });
|
||||
coordinator.page.set("/users", "document", { tags: ["users"] });
|
||||
expect(coordinator.inspect().layers.data[0]?.key).toBe("user:1");
|
||||
expect(coordinator.invalidateTags(["users"])).toBe(3);
|
||||
expect(
|
||||
coordinator.inspect().recentEvents.some((event) => event.operation === "invalidate"),
|
||||
).toBe(true);
|
||||
|
||||
const request = new RequestCache();
|
||||
let calls = 0;
|
||||
const [first, second] = await Promise.all([
|
||||
request.getOrLoad("permissions", async () => ++calls),
|
||||
request.getOrLoad("permissions", async () => ++calls),
|
||||
]);
|
||||
expect([first, second, calls]).toEqual([1, 1, 1]);
|
||||
});
|
||||
test("supports fresh, stale, and tag invalidation", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<number>({ ttlMs: 10, staleWhileRevalidateMs: 10, clock: () => now });
|
||||
@@ -23,6 +48,84 @@ describe("@wrnexus/cache", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("provides exclusive per-key locks", async () => {
|
||||
const cache = new TagCache();
|
||||
const order: string[] = [];
|
||||
let release!: () => void;
|
||||
const first = cache.withLock("catalog", async () => {
|
||||
order.push("first:start");
|
||||
await new Promise<void>((resolve) => (release = resolve));
|
||||
order.push("first:end");
|
||||
});
|
||||
await Promise.resolve();
|
||||
const second = cache.withLock("catalog", () => order.push("second"));
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["first:start"]);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second"]);
|
||||
});
|
||||
|
||||
test("invalidation during a load prevents stale work from repopulating the cache", async () => {
|
||||
const cache = new TagCache<number>();
|
||||
let release!: (value: number) => void;
|
||||
const loading = cache.getOrLoad(
|
||||
"x",
|
||||
() => new Promise<number>((resolve) => (release = resolve)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
cache.clear();
|
||||
release(7);
|
||||
expect(await loading).toBe(7);
|
||||
expect(cache.lookup("x").state).toBe("miss");
|
||||
});
|
||||
|
||||
test("propagates tag, key, and clear invalidations across instances", async () => {
|
||||
const subscriptions = new Map<string, Set<(message: unknown) => void | Promise<void>>>();
|
||||
const bus = {
|
||||
async publish(topic: string, message: unknown) {
|
||||
await Promise.all(
|
||||
[...(subscriptions.get(topic) ?? [])].map((handler) => Promise.resolve(handler(message))),
|
||||
);
|
||||
},
|
||||
subscribe(topic: string, handler: (message: unknown) => void | Promise<void>) {
|
||||
const handlers = subscriptions.get(topic) ?? new Set();
|
||||
handlers.add(handler);
|
||||
subscriptions.set(topic, handlers);
|
||||
return () => handlers.delete(handler);
|
||||
},
|
||||
};
|
||||
const first = new TagCache<number>();
|
||||
const second = new TagCache<number>();
|
||||
const firstChannel = connectCacheInvalidation(first, bus, {
|
||||
namespace: "catalog",
|
||||
instanceId: "one",
|
||||
});
|
||||
const secondChannel = connectCacheInvalidation(second, bus, {
|
||||
namespace: "catalog",
|
||||
instanceId: "two",
|
||||
});
|
||||
for (const cache of [first, second]) cache.set("product:1", 1, { tags: ["products"] });
|
||||
|
||||
expect(await firstChannel.invalidateTag("products")).toBe(1);
|
||||
expect(second.lookup("product:1").state).toBe("miss");
|
||||
first.set("one", 1);
|
||||
second.set("one", 1);
|
||||
await secondChannel.delete("one");
|
||||
expect(first.lookup("one").state).toBe("miss");
|
||||
first.set("all", 1);
|
||||
second.set("all", 1);
|
||||
await firstChannel.clear();
|
||||
expect(second.size).toBe(0);
|
||||
|
||||
firstChannel.close();
|
||||
firstChannel.close();
|
||||
await expect(firstChannel.invalidateTag("products")).rejects.toThrow(
|
||||
"WRN-CACHE-INVALIDATION-CLOSED",
|
||||
);
|
||||
secondChannel.close();
|
||||
});
|
||||
|
||||
test("response middleware does not call next twice for stale entries", async () => {
|
||||
let now = 0;
|
||||
const cache = new TagCache<any>({ ttlMs: 1, staleWhileRevalidateMs: 100, clock: () => now });
|
||||
|
||||
Reference in New Issue
Block a user