UNPKG

alepha

Version:

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

257 lines (256 loc) 8.82 kB
import { $atom, $hook, $inject, $module, $state, Alepha, z } from "alepha"; import { AlephaCache, CacheProvider } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $entity, $repository, db, sql } from "alepha/orm"; //#region ../../src/cache/database/entities/cacheEntries.ts /** * Storage table for the {@link DatabaseCacheProvider}. * * Each row represents one cache entry: * - `(container, cacheKey)` is the logical key (uniqueness enforced by index). * - `value` holds base64-encoded bytes for `set/get/setTyped/getTyped`. * - `count` holds an integer counter for atomic `incr` operations. * - `expiresAt` is null for entries that never expire, or a timestamp after * which the entry is considered gone (filtered out at read time). */ const cacheEntries = $entity({ name: "cache_entries", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), container: z.text({ description: "Cache container name, set by the $cache primitive." }), cacheKey: z.text({ description: "Per-container key chosen by the caller." }), value: z.string().describe("Base64-encoded bytes. Used by set/get.").optional(), count: z.integer().describe("Counter value. Used by atomic incr().").optional(), expiresAt: z.datetime().describe("Null means no expiration.").optional() }), indexes: [{ columns: ["container", "cacheKey"], unique: true }, "expiresAt"] }); //#endregion //#region ../../src/cache/database/providers/DatabaseCacheProvider.ts /** * Configuration atom for {@link DatabaseCacheProvider}. */ const databaseCacheOptions = $atom({ name: "alepha.cache.database.options", schema: z.object({ sweepProbability: z.number().min(0).max(1).describe("Probability (0..1) that a write operation triggers a sweep of expired rows. Set to 0 to disable opportunistic sweeping.").default(.01), sweepBatchSize: z.integer().min(1).describe("Maximum number of expired rows deleted per opportunistic sweep.").default(100) }), default: { sweepProbability: .01, sweepBatchSize: 100 } }); /** * Cache provider backed by the application's SQL database. * * Uses the `cache_entries` table as a generic key/value store with optional * TTL. Works on every database supported by Alepha's ORM (Postgres, SQLite, * Cloudflare D1, Bun SQLite). * * **Why use this over Cloudflare KV / Redis ?** * * - You already have a SQL database, no extra resource to provision/secure. * - `incr()` is **atomic** through `INSERT ... ON CONFLICT DO UPDATE`. * - Reads/writes are **strongly consistent** (KV is eventually consistent). * - Phase-2 flows (registration, password reset) become transactional with * the user-creation INSERT. * * **When to prefer Cloudflare KV / Redis instead ?** * * - Hot read paths where DB latency matters. * - Very high write rates where DB pressure becomes a concern. * * **Storage layout** * * - `value` column: base64-encoded bytes for `set/get/setTyped/getTyped`. * - `count` column: integer counter for atomic `incr()`. * - `expiresAt` column: nullable timestamp; expired rows are filtered at read * time and reaped opportunistically on writes. * * @see {@link CacheProvider} */ var DatabaseCacheProvider = class extends CacheProvider { log = $logger(); alepha = $inject(Alepha); dateTimeProvider = $inject(DateTimeProvider); repository = $repository(cacheEntries); options = $state(databaseCacheOptions); /** * Total writes performed since startup. Useful for tests and metrics. */ writes = 0; /** * Total opportunistic sweep cycles executed. Useful for tests. */ sweeps = 0; /** * Last error caught while sweeping (sweeps must never throw). */ lastSweepError; onStart = $hook({ on: "start", handler: () => { this.log.debug("DatabaseCacheProvider ready"); } }); async get(name, key) { if (!this.alepha.isStarted()) return; const row = await this.repository.findOne({ where: this.unexpiredWhere(name, key) }); if (!row) return; if (row.value != null) return this.fromBase64(row.value); if (row.count != null) return this.serialize(row.count); } async set(name, key, value, ttl) { if (!this.alepha.isStarted()) return value; const expiresAt = this.computeExpiresAt(ttl); await this.repository.upsert({ container: name, cacheKey: key, value: this.toBase64(value), count: null, expiresAt }, { target: ["container", "cacheKey"], set: { value: this.toBase64(value), count: null, expiresAt } }); this.writes++; this.maybeSweep(); return value; } async del(name, ...keys) { if (keys.length === 0) { await this.repository.deleteMany({ container: { eq: name } }); return; } await this.repository.deleteMany({ container: { eq: name }, cacheKey: { inArray: keys } }); } async has(name, key) { return await this.repository.findOne({ where: this.unexpiredWhere(name, key) }) != null; } async keys(name, filter) { const baseAnd = [{ container: { eq: name } }, this.unexpiredOrClause()]; if (filter) baseAnd.push({ cacheKey: { startsWith: filter } }); return (await this.repository.findMany({ where: { and: baseAnd } })).map((row) => row.cacheKey); } async clear() { await this.repository.deleteMany({}); } async incr(name, key, amount) { if (!this.alepha.isStarted()) return amount; const table = this.repository.table; const updated = await this.repository.upsert({ container: name, cacheKey: key, count: amount, value: null, expiresAt: null }, { target: ["container", "cacheKey"], set: { count: sql`coalesce(${table.count}, 0) + ${amount}`, value: null } }); this.writes++; this.maybeSweep(); return Number(updated.count ?? amount); } /** * Sweep all expired rows in one shot. Useful for tests and for users who * want to schedule their own cleanup job. */ async sweepExpired() { const nowIso = this.dateTimeProvider.nowISOString(); return (await this.repository.deleteMany({ expiresAt: { lt: nowIso } })).length; } unexpiredOrClause() { return { or: [{ expiresAt: { isNull: true } }, { expiresAt: { gt: this.dateTimeProvider.nowISOString() } }] }; } unexpiredWhere(name, key) { return { and: [ { container: { eq: name } }, { cacheKey: { eq: key } }, this.unexpiredOrClause() ] }; } computeExpiresAt(ttl) { if (!ttl || ttl <= 0) return null; return this.dateTimeProvider.of(this.dateTimeProvider.nowMillis() + ttl).toISOString(); } toBase64(value) { return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64"); } fromBase64(value) { return new Uint8Array(Buffer.from(value, "base64")); } /** * Run an opportunistic sweep with `sweepProbability` chance per write. * * Sweep failures are swallowed: cleanup is best-effort, lazy expiration on * read keeps correctness regardless. Errors are logged once on `lastSweepError` * so tests can assert on them. */ maybeSweep() { const probability = this.options.sweepProbability; if (probability <= 0) return; if (Math.random() >= probability) return; this.runSweep().catch((err) => { this.lastSweepError = err; this.log.warn("DatabaseCacheProvider sweep failed", err); }); } async runSweep() { this.sweeps++; const nowIso = this.dateTimeProvider.nowISOString(); const expired = await this.repository.findMany({ where: { expiresAt: { lt: nowIso } }, columns: ["id"], limit: this.options.sweepBatchSize }); if (expired.length === 0) return; await this.repository.deleteMany({ id: { inArray: expired.map((it) => it.id) } }); } }; //#endregion //#region ../../src/cache/database/index.ts /** * Plugin for Alepha Cache that stores entries in the application's SQL database. * * Adds a `cache_entries` table and a {@link DatabaseCacheProvider} * implementation of {@link CacheProvider} that: * * - reads/writes through the framework's ORM (Postgres, SQLite, D1, Bun); * - exposes an **atomic** `incr()` via `INSERT ... ON CONFLICT DO UPDATE`; * - filters expired rows on every read (lazy expiration); * - opportunistically sweeps a small batch of expired rows on writes * (configurable via {@link databaseCacheOptions}). * * **Module is opt-in.** Importing this module does not change the default * `CacheProvider` binding — pass `provider: DatabaseCacheProvider` explicitly * to the relevant `$cache(...)` calls, or rebind globally via * `alepha.with({ provide: CacheProvider, use: DatabaseCacheProvider })`. * * @see {@link DatabaseCacheProvider} * @module alepha.cache.database */ const AlephaCacheDatabase = $module({ name: "alepha.cache.database", services: [DatabaseCacheProvider], register: (alepha) => alepha.with(AlephaCache) }); //#endregion export { AlephaCacheDatabase, DatabaseCacheProvider, cacheEntries, databaseCacheOptions }; //# sourceMappingURL=index.js.map