@beignet/core
Version:
Core framework primitives for Beignet
136 lines (122 loc) • 3.36 kB
text/typescript
/**
* Options for storing a cache value.
*/
export interface CacheSetOptions {
/**
* Time-to-live in seconds. Omit this for a value that does not expire.
*/
ttlSeconds?: number;
}
/**
* App-facing string cache port.
*
* Values are intentionally strings so adapters can map cleanly to Redis,
* Upstash, and other key/value stores. Serialize structured values at the app
* boundary.
*/
export interface CachePort {
/**
* Return a fresh value for `key`, or `null` when missing or expired.
*/
get(key: string): Promise<string | null>;
/**
* Store a string value.
*/
set(key: string, value: string, options?: CacheSetOptions): Promise<void>;
/**
* Delete a cache key.
*
* @returns `true` when the key existed.
*/
delete(key: string): Promise<boolean>;
/**
* Return whether a fresh value exists for `key`.
*/
has(key: string): Promise<boolean>;
/**
* Return a cached value or compute, store, and return a new value.
*
* Implementations are not required to provide single-flight behavior. If
* concurrent cache fills matter, choose an adapter that documents that
* guarantee or protect the factory at the application layer.
*/
remember(
key: string,
factory: () => Promise<string>,
options?: CacheSetOptions,
): Promise<string>;
}
type MemoryCacheEntry = {
value: string;
expiresAt: number | null;
};
function resolveExpiresAt(options: CacheSetOptions | undefined): number | null {
if (options?.ttlSeconds == null) {
return null;
}
if (options.ttlSeconds <= 0) {
return Date.now();
}
return Date.now() + options.ttlSeconds * 1000;
}
function isExpired(entry: MemoryCacheEntry): boolean {
return entry.expiresAt != null && entry.expiresAt <= Date.now();
}
/**
* Create an in-memory cache for tests, examples, and single-process
* development.
*
* This adapter is not durable or distributed. Values are lost when the process
* exits and are not shared across workers, regions, or serverless invocations.
*
* @param initialValues - Optional initial string values without TTL.
* @returns A cache port backed by a local `Map`.
*/
export function createMemoryCache(
initialValues: Record<string, string> = {},
): CachePort {
const values = new Map<string, MemoryCacheEntry>(
Object.entries(initialValues).map(([key, value]) => [
key,
{ value, expiresAt: null },
]),
);
async function getFreshEntry(key: string): Promise<MemoryCacheEntry | null> {
const entry = values.get(key);
if (!entry) {
return null;
}
if (isExpired(entry)) {
values.delete(key);
return null;
}
return entry;
}
const cache: CachePort = {
async get(key) {
return (await getFreshEntry(key))?.value ?? null;
},
async set(key, value, options) {
values.set(key, {
value,
expiresAt: resolveExpiresAt(options),
});
},
async delete(key) {
return values.delete(key);
},
async has(key) {
return (await getFreshEntry(key)) != null;
},
async remember(key, factory, options) {
const cached = await cache.get(key);
if (cached != null) {
return cached;
}
const value = await factory();
await cache.set(key, value, options);
return value;
},
};
return cache;
}