alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
788 lines (787 loc) • 23.4 kB
JavaScript
import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, OPTIONS, Primitive, createPrimitive, z } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
//#region ../../src/cache/core/errors/CacheError.ts
var CacheError = class extends AlephaError {};
//#endregion
//#region ../../src/cache/core/providers/CacheProvider.ts
/**
* Cache provider interface.
*
* All methods are asynchronous and return promises.
* Values are stored as Uint8Array.
*/
var CacheProvider = class {
encoder = new TextEncoder();
decoder = new TextDecoder();
codes = {
BINARY: 1,
JSON: 2,
STRING: 3,
COMPRESSED: 4
};
/**
* Set a typed value with automatic serialization and optional compression.
*/
async setTyped(name, key, value, options) {
let data = this.serialize(value);
if (options?.compress) data = await this.compress(data);
await this.set(name, key, data, options?.ttl);
}
/**
* Get a typed value with automatic deserialization and optional decompression.
*/
async getTyped(name, key) {
const data = await this.get(name, key);
if (data) {
if (data[0] === this.codes.COMPRESSED) {
const decompressed = await this.decompress(data.subarray(1));
return this.deserialize(decompressed);
}
return this.deserialize(data);
}
}
/**
* Invalidate cache keys with wildcard support.
*
* Keys ending in `*` are expanded via `this.keys()`.
* Called with no keys, delegates to `this.del(name)` which clears the entire container.
*/
async invalidateKeys(name, keys) {
const keysToDelete = [];
for (const key of keys) if (key.endsWith("*")) {
const result = await this.keys(name, key.slice(0, -1));
keysToDelete.push(...result);
} else keysToDelete.push(key);
await this.del(name, ...keysToDelete);
}
/**
* Serialize a value to a typed Uint8Array with a leading type marker byte.
*/
serialize(value) {
if (value instanceof Uint8Array) {
const result = new Uint8Array(1 + value.length);
result[0] = this.codes.BINARY;
result.set(value, 1);
return result;
}
const encoded = this.encoder.encode(typeof value === "string" ? value : JSON.stringify(value));
const code = typeof value === "string" ? this.codes.STRING : this.codes.JSON;
const result = new Uint8Array(1 + encoded.length);
result[0] = code;
result.set(encoded, 1);
return result;
}
/**
* Deserialize a typed Uint8Array back to the original value.
*/
deserialize(uint8Array) {
const type = uint8Array[0];
const payload = uint8Array.slice(1);
if (type === this.codes.BINARY) return payload;
if (type === this.codes.JSON) return JSON.parse(this.decoder.decode(payload));
if (type === this.codes.STRING) return this.decoder.decode(payload);
throw new CacheError(`Unknown serialization type: ${type}`);
}
/**
* Compress data with gzip, prepending a COMPRESSED marker byte.
*/
async compress(data) {
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const compressed = new Uint8Array(await new Response(new Blob([buf]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer());
const result = new Uint8Array(1 + compressed.length);
result[0] = this.codes.COMPRESSED;
result.set(compressed, 1);
return result;
}
/**
* Decompress gzipped data.
*/
async decompress(data) {
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
return new Uint8Array(await new Response(new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer());
}
};
//#endregion
//#region ../../src/cache/core/providers/MemoryCacheProvider.ts
/**
* In-memory implementation of CacheProvider for testing.
*
* This provider stores all cache entries in memory, making it ideal for
* unit tests that need to verify cache operations without touching Redis or other backends.
*
* @example
* ```typescript
* // In tests, substitute the real CacheProvider with MemoryCacheProvider
* const alepha = Alepha.create().with({
* provide: CacheProvider,
* use: MemoryCacheProvider,
* });
*
* // Run code that uses caching
* const service = alepha.inject(MyService);
* await service.fetchWithCache("key");
*
* // Verify cache behavior
* const cache = alepha.inject(MemoryCacheProvider);
* expect(cache.stats().misses).toBe(1);
* await service.fetchWithCache("key");
* expect(cache.stats().hits).toBe(1);
* ```
*/
var MemoryCacheProvider = class extends CacheProvider {
dateTimeProvider = $inject(DateTimeProvider);
log = $logger();
store = {};
/**
* All recorded get calls.
*/
getCalls = [];
/**
* All recorded set calls.
*/
setCalls = [];
/**
* All recorded del calls.
*/
delCalls = [];
/**
* Cache statistics.
*/
_stats = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0
};
/**
* Error to throw on get (for testing error handling)
*/
getError = null;
/**
* Error to throw on set (for testing error handling)
*/
setError = null;
/**
* Error to throw on del (for testing error handling)
*/
delError = null;
constructor(options = {}) {
super();
this.getError = options.getError ?? null;
this.setError = options.setError ?? null;
this.delError = options.delError ?? null;
}
async get(name, key) {
this.getCalls.push({
name,
key,
timestamp: this.dateTimeProvider.nowMillis()
});
if (this.getError) throw this.getError;
const data = this.store[name]?.[key]?.data;
if (data !== void 0) this._stats.hits++;
else this._stats.misses++;
return data;
}
async set(name, key, value, ttl) {
this.setCalls.push({
name,
key,
value,
ttl,
timestamp: this.dateTimeProvider.nowMillis()
});
this._stats.sets++;
if (this.setError) throw this.setError;
if (this.store[name] == null) this.store[name] = {};
this.store[name][key] ??= {};
this.store[name][key].data = value;
this.log.debug(`Setting cache for name`, {
name,
key,
ttl
});
if (this.store[name][key].timeout) {
this.dateTimeProvider.clearTimeout(this.store[name][key].timeout);
this.store[name][key].timeout = void 0;
}
if (ttl) this.store[name][key].timeout = this.dateTimeProvider.createTimeout(() => this.del(name, key), ttl);
return this.store[name][key].data;
}
async del(name, ...keys) {
this.delCalls.push({
name,
keys,
timestamp: this.dateTimeProvider.nowMillis()
});
this._stats.deletes++;
if (this.delError) throw this.delError;
if (keys.length === 0) {
this.log.debug(`Deleting all cache for name`, { name });
if (this.store[name]) for (const key of Object.keys(this.store[name])) {
const timeout = this.store[name][key]?.timeout;
if (timeout) this.dateTimeProvider.clearTimeout(timeout);
}
delete this.store[name];
return;
}
this.log.debug(`Deleting cache for name`, {
name,
keys
});
for (const key of keys) {
if (this.store[name] == null) break;
const timeout = this.store[name][key]?.timeout;
if (timeout) this.dateTimeProvider.clearTimeout(timeout);
delete this.store[name][key];
}
if (Object.keys(this.store[name] ?? {}).length === 0) delete this.store[name];
}
async has(name, key) {
return this.store[name]?.[key]?.data != null;
}
async keys(name, filter) {
const store = this.store[name] ?? {};
const keys = Object.keys(store);
if (filter) return keys.filter((key) => key.startsWith(filter));
return keys;
}
async clear() {
this.log.debug("Clearing all cache");
for (const name of Object.keys(this.store)) for (const key of Object.keys(this.store[name])) {
const timeout = this.store[name][key]?.timeout;
if (timeout) this.dateTimeProvider.clearTimeout(timeout);
}
this.store = {};
}
async incr(name, key, amount) {
if (this.store[name] == null) this.store[name] = {};
const existing = this.store[name][key]?.data;
let current = 0;
if (existing) try {
current = this.deserialize(existing);
} catch {
const str = new TextDecoder().decode(existing);
current = Number.parseInt(str, 10) || 0;
}
const newValue = current + amount;
this.store[name][key] ??= {};
this.store[name][key].data = this.serialize(newValue);
return newValue;
}
/**
* Get cache statistics (hits, misses, sets, deletes).
*
* @example
* ```typescript
* expect(cache.stats().hits).toBe(1);
* expect(cache.stats().misses).toBe(0);
* ```
*/
stats() {
return { ...this._stats };
}
/**
* Check if a key was set during the test.
*
* @example
* ```typescript
* expect(cache.wasSet("my-cache", "user:123")).toBe(true);
* ```
*/
wasSet(name, key) {
if (key === void 0) return this.setCalls.some((call) => call.name === name);
return this.setCalls.some((call) => call.name === name && call.key === key);
}
/**
* Check if a key was retrieved during the test.
*
* @example
* ```typescript
* expect(cache.wasGet("my-cache", "user:123")).toBe(true);
* ```
*/
wasGet(name, key) {
if (key === void 0) return this.getCalls.some((call) => call.name === name);
return this.getCalls.some((call) => call.name === name && call.key === key);
}
/**
* Check if a key was deleted during the test.
*
* @example
* ```typescript
* expect(cache.wasDeleted("my-cache", "user:123")).toBe(true);
* ```
*/
wasDeleted(name, key) {
if (key === void 0) return this.delCalls.some((call) => call.name === name);
return this.delCalls.some((call) => call.name === name && call.keys.includes(key));
}
/**
* Get the number of cached entries for a specific cache name.
*
* @example
* ```typescript
* expect(cache.size("my-cache")).toBe(5);
* ```
*/
size(name) {
if (name === void 0) return Object.values(this.store).reduce((total, entries) => total + Object.keys(entries).length, 0);
return Object.keys(this.store[name] ?? {}).length;
}
/**
* Get all cache names.
*
* @example
* ```typescript
* expect(cache.names()).toContain("my-cache");
* ```
*/
names() {
return Object.keys(this.store);
}
/**
* Reset all in-memory state (useful between tests).
*
* @example
* ```typescript
* beforeEach(() => {
* cache.reset();
* });
* ```
*/
reset() {
for (const name of Object.keys(this.store)) for (const key of Object.keys(this.store[name])) {
const timeout = this.store[name][key]?.timeout;
if (timeout) this.dateTimeProvider.clearTimeout(timeout);
}
this.store = {};
this.getCalls = [];
this.setCalls = [];
this.delCalls = [];
this._stats = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0
};
this.getError = null;
this.setError = null;
this.delError = null;
}
};
//#endregion
//#region ../../src/cache/core/primitives/$cache.ts
function $cache(options = {}) {
const instance = createPrimitive(CachePrimitive, options);
if (options.handler) {
const fn = (...args) => instance.run(...args);
return Object.setPrototypeOf(fn, instance);
}
const mw = (handler) => {
return (async (...args) => {
const key = instance.key(...args);
const read = await instance.read(key);
if (read.value !== void 0) {
if (read.stale) instance.scheduleRefresh(key, () => handler(...args));
return read.value;
}
const result = await instance.runSingleFlight(key, () => handler(...args));
instance.set(key, result).catch(() => {});
return result;
});
};
mw[OPTIONS] = {
name: "$cache",
options
};
return Object.setPrototypeOf(mw, instance);
}
/**
* Cache configuration atom.
*/
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
}
});
const DEFAULT_MEMORY_MAX = 500;
const DEFAULT_MEMORY_TTL_MS = 3e4;
const SWR_MARKER = "__swr";
var CachePrimitive = class extends Primitive {
settings = $state(cacheOptions);
dateTimeProvider = $inject(DateTimeProvider);
provider = this.$provider();
memoryStore;
memoryMax = DEFAULT_MEMORY_MAX;
memoryTtlMs = 0;
negativeTtlMs = 0;
inflightRefreshes = /* @__PURE__ */ new Map();
constructor(args) {
super(args);
const mem = this.options.memory;
if (mem) {
this.memoryStore = /* @__PURE__ */ new Map();
const memOpts = mem === true ? {} : mem;
this.memoryMax = memOpts.max ?? DEFAULT_MEMORY_MAX;
if (memOpts.ttl !== void 0) 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 !== void 0) this.negativeTtlMs = this.dateTimeProvider.duration(memOpts.negative).as("milliseconds");
}
}
get container() {
return this.options.name ?? `${this.config.service.name}:${this.config.propertyKey}`;
}
async run(...args) {
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 !== void 0) {
if (read.stale) this.scheduleRefresh(key, () => handler(...args));
return read.value;
}
const result = await this.runSingleFlight(key, () => handler(...args));
await this.set(key, result);
return result;
}
key(...args) {
return this.options.key ? this.options.key(...args) : JSON.stringify(args);
}
async incr(key, amount = 1) {
const result = await this.provider.incr(this.container, key, amount);
this.delL1(key);
return result;
}
async invalidate(...keys) {
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);
}
async set(key, value, ttl) {
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
} : value;
await this.provider.setTyped(this.container, key, payload, {
ttl: providerTtlMs > 0 ? providerTtlMs : void 0,
compress: this.options.compress
});
this.setL1(key, {
value,
expiresAt: this.memoryTtlMs > 0 ? now + this.memoryTtlMs : Number.POSITIVE_INFINITY,
negative: false
});
this.inflightRefreshes.delete(key);
await this.alepha.events.emit("cache:set", {
container: this.container,
key,
ttlMs: providerTtlMs > 0 ? providerTtlMs : void 0
});
}
async get(key) {
return (await this.read(key)).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.
*/
async read(key) {
if (!this.alepha.isStarted() || this.options.disabled || !this.settings.enabled) return {
value: void 0,
stale: false
};
const now = this.dateTimeProvider.nowMillis();
if (this.memoryStore) {
const entry = this.memoryStore.get(key);
if (entry !== void 0) {
if (entry.expiresAt > now) {
this.memoryStore.delete(key);
this.memoryStore.set(key, entry);
await this.alepha.events.emit("cache:hit", {
container: this.container,
key
});
return {
value: entry.negative ? void 0 : entry.value,
stale: false
};
}
this.memoryStore.delete(key);
}
}
const raw = await this.provider.getTyped(this.container, key);
if (raw === void 0) {
if (this.memoryStore && this.negativeTtlMs > 0) this.setL1(key, {
value: void 0,
expiresAt: now + this.negativeTtlMs,
negative: true
});
await this.alepha.events.emit("cache:miss", {
container: this.container,
key
});
return {
value: void 0,
stale: false
};
}
let value;
let stale = false;
if (this.isSwrEnvelope(raw)) {
value = raw.v;
stale = raw.f > 0 && raw.f <= now;
} else value = raw;
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.
*/
async runSingleFlight(key, handler) {
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).
*/
scheduleRefresh(key, handler) {
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(() => {});
this.inflightRefreshes.set(key, promise);
}
$provider() {
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);
}
isSwrEnvelope(value) {
return value !== null && typeof value === "object" && value[SWR_MARKER] === 1 && "f" in value && "v" in value;
}
setL1(key, entry) {
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 === void 0) break;
this.memoryStore.delete(first);
}
}
delL1(key) {
this.memoryStore?.delete(key);
}
};
$cache[KIND] = CachePrimitive;
//#endregion
//#region ../../src/cache/core/providers/CloudflareKVProvider.ts
/**
* Default KV binding name used in wrangler configuration.
*/
const KV_DEFAULT_BINDING = "KV_CACHE";
/**
* Cloudflare KV cache provider.
*
* Uses a KV namespace binding for all cache operations.
* Keys are stored as: `cache:{name}:{key}`
*
* **Required Cloudflare binding:**
* - `KV_CACHE` - A KV namespace binding in wrangler configuration
*
* @example
* ```toml
* # wrangler.toml - automatically generated by alepha build
* [[kv_namespaces]]
* binding = "KV_CACHE"
* id = "abc123"
* ```
*/
var CloudflareKVProvider = class extends CacheProvider {
alepha = $inject(Alepha);
log = $logger();
kv;
onStart = $hook({
on: "start",
handler: async () => {
if (this.alepha.primitives("cache").filter((it) => it.provider === this).length === 0) {
this.log.info("CloudflareKVProvider is registered but no cache primitives are using it. Skipping KV initialization.");
return;
}
const cloudflareEnv = this.alepha.get("cloudflare.env");
if (!cloudflareEnv) throw new AlephaError("Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'.");
const binding = cloudflareEnv[KV_DEFAULT_BINDING];
if (!binding) throw new AlephaError(`KV binding '${KV_DEFAULT_BINDING}' not found in Cloudflare Workers environment.`);
this.kv = binding;
this.log.info("Cloudflare KV cache OK");
}
});
async get(name, key) {
if (!this.alepha.isStarted()) return;
const kvKey = this.prefix(name, key);
const buffer = await this.getKV().get(kvKey, "arrayBuffer");
if (!buffer) return;
this.log.debug("Cache hit", {
size: buffer.byteLength,
key: kvKey
});
return new Uint8Array(buffer);
}
async set(name, key, value, ttl) {
if (!this.alepha.isReady()) return new Uint8Array(value);
const kvKey = this.prefix(name, key);
const options = {};
if (ttl) options.expirationTtl = Math.max(60, Math.ceil(ttl / 1e3));
await this.getKV().put(kvKey, value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength), options);
return value;
}
async del(name, ...keys) {
const kv = this.getKV();
if (keys.length === 0) {
const prefix = this.prefix(name);
const allKeys = await this.listAllKeys(`${prefix}:`);
for (const k of allKeys) await kv.delete(k);
return;
}
const nameKey = this.prefix(name);
for (const key of keys) {
const fullKey = key.startsWith(nameKey) ? key : this.prefix(name, key);
await kv.delete(fullKey);
}
}
async has(name, key) {
const kvKey = this.prefix(name, key);
return await this.getKV().get(kvKey, "text") !== null;
}
async keys(name, filter) {
const prefix = filter ? `${this.prefix(name)}:${filter}` : `${this.prefix(name)}:`;
return this.listAllKeys(prefix);
}
async clear() {
this.log.debug("Clearing all cache");
const kv = this.getKV();
const allKeys = await this.listAllKeys("cache:");
for (const key of allKeys) await kv.delete(key);
}
async incr(name, key, amount) {
const kvKey = this.prefix(name, key);
const kv = this.getKV();
const existing = await kv.get(kvKey, "text");
let current = 0;
if (existing !== null) current = Number.parseInt(existing, 10) || 0;
const newValue = current + amount;
await kv.put(kvKey, String(newValue));
return newValue;
}
/**
* Build the full KV key: `cache:{name}:{key}`
*/
prefix(...path) {
return ["cache", ...path].join(":");
}
getKV() {
if (!this.kv) throw new AlephaError("KV namespace not initialized. Call start() first.");
return this.kv;
}
/**
* List all keys matching a prefix, handling pagination.
*/
async listAllKeys(prefix) {
const kv = this.getKV();
const allKeys = [];
let cursor;
do {
const result = await kv.list({
prefix,
cursor
});
for (const key of result.keys) allKeys.push(key.name);
cursor = result.list_complete ? void 0 : result.cursor;
} while (cursor);
return allKeys;
}
};
//#endregion
//#region ../../src/cache/core/index.ts
/**
* Type-safe caching with TTL support.
*
* **Features:**
* - Cached computations with type-safe keys and values
* - Configurable TTL
* - Cache invalidation
* - Automatic cache population
* - Providers: Memory (default), Redis
*
* @module alepha.cache
*/
const AlephaCache = $module({
name: "alepha.cache",
primitives: [$cache],
services: [CacheProvider],
variants: [MemoryCacheProvider],
register: (alepha) => alepha.with({
optional: true,
provide: CacheProvider,
use: MemoryCacheProvider
})
});
//#endregion
export { $cache, AlephaCache, CachePrimitive, CacheProvider, CloudflareKVProvider, KV_DEFAULT_BINDING, MemoryCacheProvider, cacheOptions };
//# sourceMappingURL=index.js.map