UNPKG

alepha

Version:

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

451 lines (383 loc) 13.1 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 the in-process L1 memory tier (`memory` option). * * The L1 tier sits in front of the configured `CacheProvider`. Reads check * memory first; writes are write-through to both tiers. The L1 store is * per-primitive and uses lazy expiry + LRU eviction. * * In these tests, the L2 provider is also a fresh MemoryCacheProvider — * but it's a *separate* instance from the inline L1 Map inside the * primitive, so we can distinguish hits clearly via `provider.getCalls`. */ describe("$cache memory tier (L1)", () => { it("serves repeated reads from L1 without hitting the provider", async () => { let calls = 0; class App { cache = $cache({ ttl: [5, "minutes"], memory: true, handler: async (id: string) => { calls++; return `v:${id}`; }, }); } 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("a")).toBe("v:a"); const providerGetsAfterFirst = provider.getCalls.length; expect(calls).toBe(1); // Subsequent reads should hit L1 — no new provider gets. expect(await app.cache("a")).toBe("v:a"); expect(await app.cache("a")).toBe("v:a"); expect(await app.cache("a")).toBe("v:a"); expect(provider.getCalls.length).toBe(providerGetsAfterFirst); expect(calls).toBe(1); }); it("write-through populates both L1 and provider", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; await alepha.start(); await app.cache.set("k", "hello"); // Provider got the write expect(provider.wasSet("App:cache", "k")).toBe(true); // Subsequent read serves from L1 (no provider get call). const getsBefore = provider.getCalls.length; expect(await app.cache.get("k")).toBe("hello"); expect(provider.getCalls.length).toBe(getsBefore); }); it("falls back to provider when L1 expires", async () => { let calls = 0; class App { cache = $cache({ ttl: [10, "minutes"], 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; const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache()).toBe("v:1"); expect(calls).toBe(1); // L1 still valid await time.travel([3, "seconds"]); const getsBeforeExpire = provider.getCalls.length; expect(await app.cache()).toBe("v:1"); expect(provider.getCalls.length).toBe(getsBeforeExpire); // L1 expired but L2 still valid — fallback to provider, no handler call await time.travel([3, "seconds"]); expect(await app.cache()).toBe("v:1"); expect(provider.getCalls.length).toBeGreaterThan(getsBeforeExpire); expect(calls).toBe(1); }); it("uses default L1 TTL = min(ttl, 30s) when memory: true", async () => { class App { shortTtl = $cache<string>({ ttl: [5, "seconds"], memory: true, }); longTtl = $cache<string>({ ttl: [10, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); expect((app.shortTtl as any).memoryTtlMs).toBe(5_000); expect((app.longTtl as any).memoryTtlMs).toBe(30_000); }); it("evicts oldest entries when L1 exceeds `max`", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: { max: 3 }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; await alepha.start(); await app.cache.set("a", "A"); await app.cache.set("b", "B"); await app.cache.set("c", "C"); await app.cache.set("d", "D"); // should evict "a" from L1 // "a" reads should now miss L1 and hit provider const getsBefore = provider.getCalls.length; expect(await app.cache.get("a")).toBe("A"); // hits provider expect(provider.getCalls.length).toBe(getsBefore + 1); // "d" reads hit L1 const getsAfterA = provider.getCalls.length; expect(await app.cache.get("d")).toBe("D"); expect(provider.getCalls.length).toBe(getsAfterA); }); it("LRU touch on read keeps recent entries", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: { max: 3 }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; await alepha.start(); await app.cache.set("a", "A"); await app.cache.set("b", "B"); await app.cache.set("c", "C"); // touch "a" -> moves it to end expect(await app.cache.get("a")).toBe("A"); // now insert "d" -> should evict "b" (least recently used), not "a" await app.cache.set("d", "D"); const getsBefore = provider.getCalls.length; expect(await app.cache.get("a")).toBe("A"); // still in L1 expect(provider.getCalls.length).toBe(getsBefore); expect(await app.cache.get("b")).toBe("B"); // miss L1, hit provider expect(provider.getCalls.length).toBe(getsBefore + 1); }); it("caches provider misses with `negative` option", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: { negative: [2, "seconds"] }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; const time = alepha.inject(DateTimeProvider); await alepha.start(); expect(await app.cache.get("nope")).toBeUndefined(); const getsAfterFirst = provider.getCalls.length; // Second miss read short-circuited via negative cache expect(await app.cache.get("nope")).toBeUndefined(); expect(provider.getCalls.length).toBe(getsAfterFirst); // After negative TTL expires, provider is consulted again await time.travel([3, "seconds"]); expect(await app.cache.get("nope")).toBeUndefined(); expect(provider.getCalls.length).toBeGreaterThan(getsAfterFirst); }); it("set() replaces a negative cache entry", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: { negative: [10, "seconds"] }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); expect(await app.cache.get("k")).toBeUndefined(); await app.cache.set("k", "value"); expect(await app.cache.get("k")).toBe("value"); }); it("invalidate(key) clears both L1 and provider", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); const provider = alepha.inject(CacheProvider) as MemoryCacheProvider; await alepha.start(); await app.cache.set("k", "v"); await app.cache.invalidate("k"); expect(await provider.has("App:cache", "k")).toBe(false); expect(await app.cache.get("k")).toBeUndefined(); }); it("invalidate() with no keys clears all of L1", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); await app.cache.set("a", "A"); await app.cache.set("b", "B"); await app.cache.invalidate(); expect(await app.cache.get("a")).toBeUndefined(); expect(await app.cache.get("b")).toBeUndefined(); }); it("invalidate(prefix*) wildcard clears matching L1 entries", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); await app.cache.set("user:1", "A"); await app.cache.set("user:2", "B"); await app.cache.set("post:1", "C"); await app.cache.invalidate("user:*"); expect(await app.cache.get("user:1")).toBeUndefined(); expect(await app.cache.get("user:2")).toBeUndefined(); expect(await app.cache.get("post:1")).toBe("C"); }); it("incr() invalidates the L1 entry", async () => { class App { counter = $cache<number>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); expect(await app.counter.incr("hits")).toBe(1); // After incr, the provider has 1; L1 should be empty so the next get // pulls the fresh value rather than an outdated L1 entry. expect(await app.counter.get("hits")).toBe(1); expect(await app.counter.incr("hits", 5)).toBe(6); expect(await app.counter.get("hits")).toBe(6); }); it("works in middleware mode (pipeline use:[cache])", async () => { class App { calls = 0; cache = $cache<string>({ name: "mw-mem", ttl: [5, "minutes"], memory: true, }); fn = async (id: string) => { const wrapped = this.cache(async (i: string) => { this.calls++; return `v:${i}`; }); return wrapped(id); }; } 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.fn("a")).toBe("v:a"); const getsAfterFirst = provider.getCalls.length; expect(await app.fn("a")).toBe("v:a"); expect(await app.fn("a")).toBe("v:a"); // L1 takes over — no new provider gets expect(provider.getCalls.length).toBe(getsAfterFirst); expect(app.calls).toBe(1); }); it("emits cache:hit on L1 read", async () => { class App { cache = $cache<string>({ ttl: [5, "minutes"], memory: true, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); const hits: string[] = []; alepha.events.on("cache:hit", (e) => { hits.push(e.key); }); await app.cache.set("k", "v"); await app.cache.get("k"); await app.cache.get("k"); expect(hits).toContain("k"); expect(hits.length).toBeGreaterThanOrEqual(2); }); it("memory option off (undefined) does not allocate an L1 store", () => { class App { cache = $cache<string>({ ttl: [5, "minutes"] }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); expect((app.cache as any).memoryStore).toBeUndefined(); }); it("respects disabled option even with memory L1", async () => { let calls = 0; class App { cache = $cache({ disabled: true, memory: true, handler: async () => { calls++; return `v:${calls}`; }, }); } const alepha = Alepha.create().with({ provide: CacheProvider, use: MemoryCacheProvider, }); const app = alepha.inject(App); await alepha.start(); expect(await app.cache()).toBe("v:1"); expect(await app.cache()).toBe("v:2"); expect(await app.cache()).toBe("v:3"); }); });