alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
351 lines (300 loc) • 9.83 kB
text/typescript
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) },
});
}
}