151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
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 });
|
|
cache.set("a", 1, { tags: ["users"] });
|
|
expect(cache.lookup("a").state).toBe("fresh");
|
|
now = 11;
|
|
expect(cache.lookup("a").state).toBe("stale");
|
|
expect(cache.invalidateTag("users")).toBe(1);
|
|
expect(cache.lookup("a").state).toBe("miss");
|
|
});
|
|
|
|
test("deduplicates concurrent loaders", async () => {
|
|
const cache = new TagCache<number>();
|
|
let calls = 0;
|
|
const loader = async () => ++calls;
|
|
const [a, b] = await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)]);
|
|
expect(a).toBe(1);
|
|
expect(b).toBe(1);
|
|
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 });
|
|
const middleware = responseCache({ cache, ttlMs: 1, staleWhileRevalidateMs: 100 });
|
|
let calls = 0;
|
|
const ctx = {
|
|
req: new Request("https://example.com/a"),
|
|
url: new URL("https://example.com/a"),
|
|
} as any;
|
|
await middleware(ctx, async () => {
|
|
calls++;
|
|
return new Response("one");
|
|
});
|
|
now = 2;
|
|
const stale = await middleware(ctx, async () => {
|
|
calls++;
|
|
return new Response("two");
|
|
});
|
|
expect(await stale.text()).toBe("one");
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|