UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

395 lines (340 loc) 10.8 kB
import { Alepha } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { describe, expect, it } from "vitest"; import { $cache, CacheProvider, MemoryCacheProvider } from "../index.ts"; /** * Tests for stale-while-revalidate (`stale` option). * * After `ttl` expires, the cached value remains servable for `stale` * longer; reads in the grace window return the stale value immediately * and trigger ONE background refresh (single-flight per key). */ describe("$cache stale-while-revalidate (SWR)", () => { it("returns fresh value without scheduling a refresh", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([3, "seconds"]); // still fresh expect(await app.cache()).toBe("v:1"); expect(await app.cache()).toBe("v:1"); expect(calls).toBe(1); }); it("returns stale value and schedules a background refresh", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([10, "seconds"]); // past ttl, within stale // Stale value served immediately expect(await app.cache()).toBe("v:1"); // Wait for background refresh to complete await new Promise((r) => setTimeout(r, 20)); expect(calls).toBe(2); // Next read returns the refreshed value expect(await app.cache()).toBe("v:2"); }); it("single-flight: concurrent stale reads trigger one refresh", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; await new Promise((r) => setTimeout(r, 10)); return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([10, "seconds"]); // 10 concurrent reads const results = await Promise.all( Array.from({ length: 10 }, () => app.cache()), ); expect(results.every((r) => r === "v:1")).toBe(true); await new Promise((r) => setTimeout(r, 30)); expect(calls).toBe(2); // 1 original + 1 background refresh }); it("single-flight: concurrent cold reads trigger one handler call", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; await new Promise((r) => setTimeout(r, 10)); return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); const results = await Promise.all( Array.from({ length: 10 }, () => app.cache()), ); expect(results.every((r) => r === "v:1")).toBe(true); expect(calls).toBe(1); }); it("preserves stale value when background refresh fails", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; if (calls > 1) { throw new Error("upstream down"); } return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([10, "seconds"]); // Stale read should succeed even though refresh fails in background. expect(await app.cache()).toBe("v:1"); // Wait for failed refresh await new Promise((r) => setTimeout(r, 30)); // Next read returns stale value again, attempts another refresh. expect(await app.cache()).toBe("v:1"); }); it("treats values past ttl + stale as miss (synchronous handler call)", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [10, "seconds"], handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); // Past ttl + stale -> provider expires the entry entirely await time.travel([20, "seconds"]); expect(await app.cache()).toBe("v:2"); expect(calls).toBe(2); }); it("set() resets the fresh deadline and clears pending refresh", async () => { let calls = 0; class App { cache = $cache<string>({ ttl: [5, "seconds"], stale: [1, "hour"], handler: () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([10, "seconds"]); // stale await app.cache.set(app.cache.key(), "manual"); // After manual set, fresh again -> no refresh needed expect(await app.cache()).toBe("manual"); expect(await app.cache()).toBe("manual"); expect(calls).toBe(1); // handler never called for refresh }); it("composes with L1 memory tier", async () => { let calls = 0; class App { cache = $cache({ ttl: [10, "minutes"], stale: [1, "hour"], memory: { ttl: [5, "seconds"] }, handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; await alepha.start(); expect(await app.cache()).toBe("v:1"); const getsAfterFirst = provider.getCalls.length; // L1 serves the next reads. expect(await app.cache()).toBe("v:1"); expect(await app.cache()).toBe("v:1"); expect(provider.getCalls.length).toBe(getsAfterFirst); expect(calls).toBe(1); }); it("works in middleware mode (handler from the pipeline)", async () => { let calls = 0; class App { cache = $cache<string>({ name: "mw-swr", ttl: [5, "seconds"], stale: [1, "hour"], }); fn = this.cache(async (id: string) => { calls++; return `v:${id}:${calls}`; }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.fn("a")).toBe("v:a:1"); await time.travel([10, "seconds"]); // Stale served + background refresh expect(await app.fn("a")).toBe("v:a:1"); await new Promise((r) => setTimeout(r, 20)); expect(calls).toBe(2); expect(await app.fn("a")).toBe("v:a:2"); }); it("emits cache:stale and cache:revalidate events", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], stale: [1, "hour"], handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); const staleEvents: string[] = []; const revalidateEvents: string[] = []; alepha.events.on("cache:stale", (e) => { staleEvents.push(e.key); }); alepha.events.on("cache:revalidate", (e) => { revalidateEvents.push(e.key); }); await app.cache(); await time.travel([10, "seconds"]); await app.cache(); await new Promise((r) => setTimeout(r, 30)); expect(staleEvents.length).toBeGreaterThanOrEqual(1); expect(revalidateEvents.length).toBeGreaterThanOrEqual(1); }); it("manual mode (cache.get without handler) returns stale value but does not refresh", async () => { class App { cache = $cache<string>({ ttl: [5, "seconds"], stale: [1, "hour"], }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); await app.cache.set("k", "value"); await time.travel([10, "seconds"]); // Stale served. No refresh possible (no handler). expect(await app.cache.get("k")).toBe("value"); expect(await app.cache.get("k")).toBe("value"); }); it("stale option off behaves like a regular cache", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "seconds"], handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); await time.travel([3, "seconds"]); expect(await app.cache()).toBe("v:1"); // After ttl, no stale window -> immediate handler call await time.travel([3, "seconds"]); expect(await app.cache()).toBe("v:2"); }); });