UNPKG

alepha

Version:

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

778 lines (777 loc) 23.2 kB
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.workerd.ts const AlephaCache = $module({ name: "alepha.cache", primitives: [$cache], services: [CacheProvider], variants: [MemoryCacheProvider, CloudflareKVProvider], register: (alepha) => { alepha.with({ optional: true, provide: CacheProvider, use: CloudflareKVProvider }); } }); //#endregion export { $cache, AlephaCache, CachePrimitive, CacheProvider, CloudflareKVProvider, KV_DEFAULT_BINDING, MemoryCacheProvider, cacheOptions }; //# sourceMappingURL=index.workerd.js.map