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