alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
451 lines (383 loc) • 13.1 kB
text/typescript
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");
});
});