UNPKG

alepha

Version:

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

351 lines (300 loc) 9.83 kB
import { $atom, $hook, $inject, $state, Alepha, type Static, z } from "alepha"; import { CacheProvider } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $repository, sql } from "alepha/orm"; import { cacheEntries } from "../entities/cacheEntries.ts"; // --------------------------------------------------------------------------------------------------------------------- /** * Configuration atom for {@link DatabaseCacheProvider}. */ export 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(0.01), sweepBatchSize: z .integer() .min(1) .describe( "Maximum number of expired rows deleted per opportunistic sweep.", ) .default(100), }), default: { sweepProbability: 0.01, sweepBatchSize: 100, }, }); export type DatabaseCacheOptions = Static<typeof databaseCacheOptions.schema>; declare module "alepha" { interface State { [databaseCacheOptions.key]: DatabaseCacheOptions; } } // --------------------------------------------------------------------------------------------------------------------- /** * 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} */ export class DatabaseCacheProvider extends CacheProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly repository = $repository(cacheEntries); protected readonly options = $state(databaseCacheOptions); /** * Total writes performed since startup. Useful for tests and metrics. */ public writes = 0; /** * Total opportunistic sweep cycles executed. Useful for tests. */ public sweeps = 0; /** * Last error caught while sweeping (sweeps must never throw). */ public lastSweepError?: unknown; // ------------------------------------------------------------------------------------------------------------------- protected readonly onStart = $hook({ on: "start", handler: () => { this.log.debug("DatabaseCacheProvider ready"); }, }); // ------------------------------------------------------------------------------------------------------------------- public async get(name: string, key: string): Promise<Uint8Array | undefined> { if (!this.alepha.isStarted()) { return undefined; } const row = await this.repository.findOne({ where: this.unexpiredWhere(name, key) as any, }); if (!row) { return undefined; } if (row.value != null) { return this.fromBase64(row.value); } if (row.count != null) { // The caller wrote the entry through `incr()`. Surface the count via // the same byte format that MemoryCacheProvider uses, so `getTyped` // returns the number transparently. return this.serialize(row.count); } return undefined; } public async set( name: string, key: string, value: Uint8Array, ttl?: number, ): Promise<Uint8Array> { 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, } as any, { target: ["container", "cacheKey"], set: { value: this.toBase64(value), count: null, expiresAt, }, }, ); this.writes++; this.maybeSweep(); return value; } public async del(name: string, ...keys: string[]): Promise<void> { if (keys.length === 0) { await this.repository.deleteMany({ container: { eq: name }, }); return; } await this.repository.deleteMany({ container: { eq: name }, cacheKey: { inArray: keys }, }); } public async has(name: string, key: string): Promise<boolean> { const row = await this.repository.findOne({ where: this.unexpiredWhere(name, key) as any, }); return row != null; } public async keys(name: string, filter?: string): Promise<string[]> { const baseAnd: any[] = [ { container: { eq: name } }, this.unexpiredOrClause(), ]; if (filter) { baseAnd.push({ cacheKey: { startsWith: filter } }); } const rows = await this.repository.findMany({ where: { and: baseAnd } as any, }); return rows.map((row) => row.cacheKey); } public async clear(): Promise<void> { await this.repository.deleteMany({}); } public async incr( name: string, key: string, amount: number, ): Promise<number> { if (!this.alepha.isStarted()) { return amount; } // Atomic upsert via `INSERT ... ON CONFLICT (container, cacheKey) DO UPDATE // SET count = COALESCE(cache_entries.count, 0) + excluded.count`. // Both Postgres and SQLite (incl. D1) support this in a single statement, // so concurrent callers can never observe an interleaved read/write. const table = this.repository.table; const updated = await this.repository.upsert( { container: name, cacheKey: key, count: amount, value: null, expiresAt: null, } as any, { target: ["container", "cacheKey"], set: { // `excluded.count` references the value being inserted on conflict. 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. */ public async sweepExpired(): Promise<number> { const nowIso = this.dateTimeProvider.nowISOString(); const ids = await this.repository.deleteMany({ expiresAt: { lt: nowIso }, }); return ids.length; } // ------------------------------------------------------------------------------------------------------------------- protected unexpiredOrClause(): Record<string, any> { const nowIso = this.dateTimeProvider.nowISOString(); return { or: [{ expiresAt: { isNull: true } }, { expiresAt: { gt: nowIso } }], }; } protected unexpiredWhere(name: string, key: string): Record<string, any> { return { and: [ { container: { eq: name } }, { cacheKey: { eq: key } }, this.unexpiredOrClause(), ], }; } protected computeExpiresAt(ttl?: number): string | null { if (!ttl || ttl <= 0) { return null; } return this.dateTimeProvider .of(this.dateTimeProvider.nowMillis() + ttl) .toISOString(); } protected toBase64(value: Uint8Array): string { return Buffer.from( value.buffer as ArrayBuffer, value.byteOffset, value.byteLength, ).toString("base64"); } protected fromBase64(value: string): Uint8Array { 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. */ protected maybeSweep(): void { 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); }); } protected async runSweep(): Promise<void> { this.sweeps++; const nowIso = this.dateTimeProvider.nowISOString(); // Drizzle does not expose a portable LIMIT on DELETE, so we select first // and then delete by ID. Two roundtrips, but only on the ~1% sweep path. 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) }, }); } }