alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
257 lines (256 loc) • 8.82 kB
JavaScript
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