UNPKG

alepha

Version:

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

643 lines (570 loc) 18.1 kB
import { $atom, $inject, $state, AlephaError, createPrimitive, KIND, type MiddlewareMetadata, OPTIONS, Primitive, type Service, type Static, z, } from "alepha"; import { DateTimeProvider, type DurationLike } from "alepha/datetime"; import { CacheProvider } from "../providers/CacheProvider.ts"; import { MemoryCacheProvider } from "../providers/MemoryCacheProvider.ts"; /** * Creates a cache primitive for caching with automatic management. * * **Middleware mode** (no `handler`) — usable in `use` arrays AND as a store: * ```ts * class UserService { * userCache = $cache({ name: "users", ttl: [10, "minutes"] }); * * fetchUser = $pipeline({ * use: [this.userCache], * handler: async (userId: string) => this.repo.getById(userId), * }); * * async invalidateUser(userId: string) { * await this.userCache.invalidate(userId); * } * } * ``` * * **Primitive mode** (with `handler`) — standalone callable: * ```ts * getUserData = $cache({ * name: "user-data", * ttl: [10, "minutes"], * handler: async (userId: string) => { * return await database.users.findById(userId); * } * }); * ``` */ export function $cache<TReturn = string, TParameter extends any[] = any[]>( options: CachePrimitiveOptions<TReturn, TParameter> & { handler: (...args: TParameter) => TReturn; }, ): CachePrimitiveFn<TReturn, TParameter>; export function $cache<TReturn = any, TParameter extends any[] = any[]>( options?: CachePrimitiveOptions<TReturn, TParameter>, ): CacheMiddlewareFn<TReturn>; export function $cache(options: any = {}): any { const instance = createPrimitive(CachePrimitive, options); if (options.handler) { const fn = (...args: any[]): Promise<any> => instance.run(...args); return Object.setPrototypeOf(fn, instance); } // Middleware mode: callable as (handler) => wrappedHandler, with store methods const mw: any = <T extends (...args: any[]) => any>(handler: T): T => { return (async (...args: any[]) => { const key = instance.key(...args); const read = await instance.read(key); if (read.value !== undefined) { if (read.stale) { instance.scheduleRefresh(key, () => handler(...args)); } return read.value; } const result = await instance.runSingleFlight(key, () => handler(...args), ); // Fire-and-forget — cache write failures must not break the handler result instance.set(key, result).catch(() => {}); return result; }) as any as T; }; mw[OPTIONS] = { name: "$cache", options: options as unknown as Record<string, unknown>, } satisfies MiddlewareMetadata; return Object.setPrototypeOf(mw, instance); } // --------------------------------------------------------------------------------------------------------------------- /** * Options for the in-memory L1 tier. */ export interface CacheMemoryTierOptions { /** * TTL for the in-memory tier. Should be ≤ the remote `ttl`. * Bounds the cross-isolate staleness window after invalidation. * * @default min(ttl, 30s) */ ttl?: DurationLike; /** * LRU bound — max entries kept in memory before eviction. * * @default 500 */ max?: number; /** * Also cache provider misses (`undefined`) in memory for this duration. * Prevents hammering the remote tier on cold/unknown keys. * * @default off */ negative?: DurationLike; } export interface CachePrimitiveOptions< TReturn = any, TParameter extends any[] = any[], > { /** * The cache name. This is useful for invalidating multiple caches at once. * * Store key as `cache:$name:$key`. * * @default Name of the key of the class. */ name?: string; /** * Function which returns cached data. */ handler?: (...args: TParameter) => TReturn; /** * The key generator for the cache. * If not provided, the arguments will be json.stringify(). */ key?: (...args: TParameter) => string; /** * The store provider for the cache. * * Accepts: * - `"memory"` — short-circuits to {@link MemoryCacheProvider} regardless * of the default `CacheProvider` binding. Useful for caches that must * stay process-local (e.g. ETag, HTTP client). * - A {@link CacheProvider} class (concrete OR abstract) — resolved via * `alepha.inject(...)` at primitive construction. Use this to opt a * specific cache into a non-default backend (e.g. * `provider: DatabaseCacheProvider` to keep one cache in SQL while the * rest of the app uses Cloudflare KV). * - `undefined` — falls back to whatever is bound to `CacheProvider` in * the container. On Cloudflare workers this is * {@link CloudflareKVProvider} by default; on Node it's * {@link MemoryCacheProvider}. * * Note: passing an *abstract* class works because Alepha's DI resolves * through substitutions, e.g. `alepha.with({ provide: CacheProvider, use: * MyCacheProvider })`. */ provider?: Service<CacheProvider> | "memory"; /** * The time-to-live for the cache in seconds. * Set 0 to skip expiration. * * @default 300 (5 minutes). */ ttl?: DurationLike; /** * If the cache is disabled. */ disabled?: boolean; /** * Enable gzip compression for cached values. * Reduces storage size by 60-80% for JSON payloads at the cost of CPU. */ compress?: boolean; /** * Add an in-process L1 memory tier in front of `provider`. * * Reads check memory first, fall back to the provider on miss. Writes go * to both tiers (write-through), so own-writes are immediately visible. * * Caveats: * - Per-process only. Each Worker isolate / Node process has its own L1. * `invalidate()` clears the local L1 + the remote provider; other * processes keep their L1 until its TTL expires. * - Use a short L1 TTL to bound the cross-isolate staleness window. * * @default off */ memory?: true | CacheMemoryTierOptions; /** * Stale-while-revalidate window. After `ttl` expires, the cached value * remains servable for `stale` longer; reads in this window return the * stale value immediately and trigger ONE background refresh * (single-flight per key). * * Requires a `handler` (primitive mode) OR middleware mode wrapping a * handler — the cache needs to know how to recompute. * * @default off */ stale?: DurationLike; } // --------------------------------------------------------------------------------------------------------------------- /** * Cache configuration atom. */ export const cacheOptions = $atom({ name: "alepha.cache.options", schema: z.object({ enabled: z.boolean().describe("Whether caching is enabled.").default(true), defaultTtl: z .number() .describe("Default time to live for cache entries in seconds.") .default(300), }), default: { enabled: true, defaultTtl: 300, }, }); export type CacheAtomOptions = Static<typeof cacheOptions.schema>; declare module "alepha" { interface State { [cacheOptions.key]: CacheAtomOptions; } } // --------------------------------------------------------------------------------------------------------------------- const DEFAULT_MEMORY_MAX = 500; const DEFAULT_MEMORY_TTL_MS = 30_000; const SWR_MARKER = "__swr" as const; type SwrEnvelope = { [SWR_MARKER]: 1; v: unknown; f: number; }; type L1Entry<T> = { value: T | undefined; expiresAt: number; negative: boolean; }; type ReadResult<T> = { value: T | undefined; stale: boolean; }; export class CachePrimitive< TReturn = any, TParameter extends any[] = any[], > extends Primitive<CachePrimitiveOptions<TReturn, TParameter>> { protected readonly settings = $state(cacheOptions); protected readonly dateTimeProvider = $inject(DateTimeProvider); public readonly provider = this.$provider(); protected readonly memoryStore?: Map<string, L1Entry<TReturn>>; protected readonly memoryMax: number = DEFAULT_MEMORY_MAX; protected readonly memoryTtlMs: number = 0; protected readonly negativeTtlMs: number = 0; protected readonly inflightRefreshes = new Map<string, Promise<TReturn>>(); constructor( args: ConstructorParameters< typeof Primitive<CachePrimitiveOptions<TReturn, TParameter>> >[0], ) { super(args); const mem = this.options.memory; if (mem) { this.memoryStore = new Map(); const memOpts: CacheMemoryTierOptions = mem === true ? {} : mem; this.memoryMax = memOpts.max ?? DEFAULT_MEMORY_MAX; // Default L1 TTL: min(remote ttl, 30s). If remote ttl is 0/infinite, use 30s. if (memOpts.ttl !== undefined) { this.memoryTtlMs = this.dateTimeProvider .duration(memOpts.ttl) .as("milliseconds"); } else { const remoteTtlMs = this.options.ttl ? this.dateTimeProvider.duration(this.options.ttl).as("milliseconds") : 0; this.memoryTtlMs = remoteTtlMs > 0 ? Math.min(remoteTtlMs, DEFAULT_MEMORY_TTL_MS) : DEFAULT_MEMORY_TTL_MS; } if (memOpts.negative !== undefined) { this.negativeTtlMs = this.dateTimeProvider .duration(memOpts.negative) .as("milliseconds"); } } } public get container(): string { return ( this.options.name ?? `${this.config.service.name}:${this.config.propertyKey}` ); } public async run(...args: TParameter): Promise<TReturn> { const handler = this.options.handler; if (!handler) { throw new AlephaError("Cache handler is not defined."); } const key = this.key(...args); const read = await this.read(key); if (read.value !== undefined) { if (read.stale) { this.scheduleRefresh(key, () => handler(...args)); } return read.value; } const result = await this.runSingleFlight(key, () => handler(...args)); // note: when exception occurs, don't cache the result await this.set(key, result); return result; } public key(...args: TParameter): string { return this.options.key ? this.options.key(...args) : JSON.stringify(args); } public async incr(key: string, amount = 1): Promise<number> { const result = await this.provider.incr(this.container, key, amount); // L1 is no longer authoritative after atomic incr on remote. this.delL1(key); return result; } public async invalidate(...keys: string[]): Promise<void> { if (this.memoryStore) { if (keys.length === 0) { this.memoryStore.clear(); } else { for (const key of keys) { if (key.endsWith("*")) { const prefix = key.slice(0, -1); for (const k of this.memoryStore.keys()) { if (k.startsWith(prefix)) this.memoryStore.delete(k); } } else { this.memoryStore.delete(key); } } } } await this.provider.invalidateKeys(this.container, keys); } public async set( key: string, value: TReturn, ttl?: DurationLike, ): Promise<void> { if ( !this.alepha.isStarted() || this.options.disabled || !this.settings.enabled ) { return; } const freshTtlMs = this.dateTimeProvider .duration( ttl ?? this.options.ttl ?? [this.settings.defaultTtl, "seconds"], ) .as("milliseconds"); const staleMs = this.options.stale ? this.dateTimeProvider.duration(this.options.stale).as("milliseconds") : 0; const providerTtlMs = freshTtlMs > 0 ? freshTtlMs + staleMs : 0; const now = this.dateTimeProvider.nowMillis(); const freshUntil = freshTtlMs > 0 ? now + freshTtlMs : 0; const payload = this.options.stale && freshTtlMs > 0 ? ({ [SWR_MARKER]: 1, v: value, f: freshUntil } satisfies SwrEnvelope) : value; await this.provider.setTyped(this.container, key, payload, { ttl: providerTtlMs > 0 ? providerTtlMs : undefined, compress: this.options.compress, }); // Write-through to L1 (raw value, not wrapped). this.setL1(key, { value, expiresAt: this.memoryTtlMs > 0 ? now + this.memoryTtlMs : Number.POSITIVE_INFINITY, negative: false, }); // A fresh write supersedes any pending refresh for this key. this.inflightRefreshes.delete(key); await this.alepha.events.emit("cache:set", { container: this.container, key, ttlMs: providerTtlMs > 0 ? providerTtlMs : undefined, }); } public async get(key: string): Promise<TReturn | undefined> { const read = await this.read(key); return read.value; } /** * Internal read that also reports whether the value is stale (SWR * grace window). Middleware and `run()` use this to decide whether to * schedule a background refresh. */ public async read(key: string): Promise<ReadResult<TReturn>> { if ( !this.alepha.isStarted() || this.options.disabled || !this.settings.enabled ) { return { value: undefined, stale: false }; } const now = this.dateTimeProvider.nowMillis(); // L1 check if (this.memoryStore) { const entry = this.memoryStore.get(key); if (entry !== undefined) { if (entry.expiresAt > now) { // LRU touch this.memoryStore.delete(key); this.memoryStore.set(key, entry); await this.alepha.events.emit("cache:hit", { container: this.container, key, }); return { value: entry.negative ? undefined : entry.value, stale: false, }; } this.memoryStore.delete(key); } } // L2 check const raw = await this.provider.getTyped<TReturn | SwrEnvelope>( this.container, key, ); if (raw === undefined) { // Negative caching if (this.memoryStore && this.negativeTtlMs > 0) { this.setL1(key, { value: undefined, expiresAt: now + this.negativeTtlMs, negative: true, }); } await this.alepha.events.emit("cache:miss", { container: this.container, key, }); return { value: undefined, stale: false }; } let value: TReturn; let stale = false; if (this.isSwrEnvelope(raw)) { value = raw.v as TReturn; stale = raw.f > 0 && raw.f <= now; } else { value = raw as TReturn; } // Populate L1 (write-back from L2 read) if (this.memoryStore && this.memoryTtlMs > 0) { this.setL1(key, { value, expiresAt: now + this.memoryTtlMs, negative: false, }); } await this.alepha.events.emit(stale ? "cache:stale" : "cache:hit", { container: this.container, key, }); return { value, stale }; } /** * Run a handler under single-flight: concurrent callers for the same * key share one in-flight promise. */ public async runSingleFlight( key: string, handler: () => Promise<TReturn> | TReturn, ): Promise<TReturn> { const existing = this.inflightRefreshes.get(key); if (existing) { return existing; } const promise = (async () => { try { return await handler(); } finally { this.inflightRefreshes.delete(key); } })(); this.inflightRefreshes.set(key, promise); return promise; } /** * Schedule a background refresh for a stale key. At most one refresh * per key is in-flight at any time; failures are swallowed (the stale * value keeps being served until expiry). */ public scheduleRefresh( key: string, handler: () => Promise<TReturn> | TReturn, ): void { if (this.inflightRefreshes.has(key)) { return; } const promise = (async () => { try { const result = await handler(); await this.set(key, result); await this.alepha.events.emit("cache:revalidate", { container: this.container, key, }); return result; } finally { this.inflightRefreshes.delete(key); } })(); promise.catch(() => { // swallow: stale value keeps serving until expiry }); this.inflightRefreshes.set(key, promise); } protected $provider(): CacheProvider { if (!this.options.provider) { return this.alepha.inject(CacheProvider); } if (this.options.provider === "memory") { return this.alepha.inject(MemoryCacheProvider); } return this.alepha.inject(this.options.provider); } protected isSwrEnvelope(value: unknown): value is SwrEnvelope { return ( value !== null && typeof value === "object" && (value as Record<string, unknown>)[SWR_MARKER] === 1 && "f" in (value as Record<string, unknown>) && "v" in (value as Record<string, unknown>) ); } protected setL1(key: string, entry: L1Entry<TReturn>): void { if (!this.memoryStore) return; if (this.memoryStore.has(key)) { this.memoryStore.delete(key); } this.memoryStore.set(key, entry); while (this.memoryStore.size > this.memoryMax) { const first = this.memoryStore.keys().next().value; if (first === undefined) break; this.memoryStore.delete(first); } } protected delL1(key: string): void { this.memoryStore?.delete(key); } } export interface CachePrimitiveFn< TReturn = any, TParameter extends any[] = any[], > extends CachePrimitive<TReturn, TParameter> { /** * Run the cache primitive with the provided arguments. */ (...args: TParameter): Promise<TReturn>; } /** * Cache middleware + store. Callable as middleware `(handler) => wrappedHandler` * AND exposes store methods (`.get()`, `.set()`, `.invalidate()`, `.incr()`). */ export interface CacheMiddlewareFn<TReturn = any> extends CachePrimitive<TReturn, any[]> { <T extends (...args: any[]) => any>(handler: T): T; [OPTIONS]?: MiddlewareMetadata; } $cache[KIND] = CachePrimitive;