@daiso-tech/core
Version:
The library offers flexible, framework-agnostic solutions for modern web applications, built on adaptable components that integrate seamlessly with popular frameworks like Next Js.
273 lines • 9.99 kB
JavaScript
/**
* @module Cache
*/
import { TypeCacheError, } from "../../../../cache/contracts/_module-exports.js";
import { MysqlAdapter, Transaction } from "kysely";
import { TimeSpan } from "../../../../utilities/_module-exports.js";
/**
* To utilize the `KyselyCacheAdapter`, you must install the [`"kysely"`](https://www.npmjs.com/package/kysely) package and configure a `Kysely` class instance.
* The adapter have been tested with `sqlite`, `postgres` and `mysql` databases.
*
* IMPORT_PATH: `"@daiso-tech/core/cache/adapters"`
* @group Adapters
*/
export class KyselyCacheAdapter {
static filterUnexpiredKeys(keys) {
return (eb) => {
const hasNoExpiration = eb("cache.expiration", "is", null);
const hasExpiration = eb("cache.expiration", "is not", null);
const hasNotExpired = eb("cache.expiration", ">", Date.now());
const keysMatch = eb("cache.key", "in", keys);
return eb.and([
keysMatch,
eb.or([
hasNoExpiration,
eb.and([hasExpiration, hasNotExpired]),
]),
]);
};
}
static filterExpiredKeys(keys) {
return (eb) => {
const keysMatch = eb("cache.key", "in", keys);
const hasExpiration = eb("cache.expiration", "is not", null);
const hasExpired = eb("cache.expiration", "<=", Date.now());
return eb.and([keysMatch, hasExpiration, hasExpired]);
};
}
serde;
kysely;
shouldRemoveExpiredKeys;
expiredKeysRemovalInterval;
timeoutId = null;
disableTransaction;
/**
* @example
* ```ts
* import { KyselyCacheAdapter } from "@daiso-tech/core/cache/adapters";
* import { Serde } from "@daiso-tech/core/serde";
* import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters"
* import Sqlite from "better-sqlite3";
*
* const serde = new Serde(new SuperJsonSerdeAdapter());
* const cacheAdapter = new KyselyCacheAdapter({
* kysely: new Kysely({
* dialect: new SqliteDialect({
* database: new Sqlite("local.db"),
* }),
* }),
* serde,
* });
* // You need initialize the adapter once before using it.
* await cacheAdapter.init();
* ```
*/
constructor(settings) {
const { kysely, serde, expiredKeysRemovalInterval = TimeSpan.fromMinutes(1), shouldRemoveExpiredKeys = true, } = settings;
this.disableTransaction = kysely instanceof Transaction;
this.kysely = kysely;
this.serde = serde;
this.expiredKeysRemovalInterval = expiredKeysRemovalInterval;
this.shouldRemoveExpiredKeys = shouldRemoveExpiredKeys;
}
async removeAllExpired() {
await this.kysely
.deleteFrom("cache")
.where("cache.expiration", "<=", Date.now())
.execute();
}
async init() {
// Should throw if the table already exists thats why the try catch is used.
try {
await this.kysely.schema
.createTable("cache")
.addColumn("key", "varchar(255)", (col) => col.primaryKey())
.addColumn("value", "varchar(255)", (col) => col.notNull())
.addColumn("expiration", "bigint")
.execute();
}
catch {
/* EMPTY */
}
// Should throw if the index already exists thats why the try catch is used.
try {
await this.kysely.schema
.createIndex("cache_expiration")
.on("cache")
.columns(["expiration"])
.execute();
}
catch {
/* EMPTY */
}
if (this.shouldRemoveExpiredKeys && this.timeoutId === null) {
this.timeoutId = setInterval(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.removeAllExpired();
}, this.expiredKeysRemovalInterval.toMilliseconds());
}
}
async deInit() {
if (this.shouldRemoveExpiredKeys && this.timeoutId !== null) {
clearTimeout(this.timeoutId);
}
// Should throw if the index does not exists thats why the try catch is used.
try {
await this.kysely.schema
.dropIndex("cache_expiration")
.on("cache")
.execute();
}
catch {
/* EMPTY */
}
// Should throw if the table does not exists thats why the try catch is used.
try {
await this.kysely.schema.dropTable("cache").execute();
}
catch {
/* EMPTY */
}
}
async find(key) {
const row = await this.kysely
.selectFrom("cache")
.where("cache.key", "=", key)
.select(["cache.expiration", "cache.value"])
.executeTakeFirst();
if (row === undefined) {
return null;
}
return {
value: this.serde.deserialize(row.value),
expiration: row.expiration !== null
? new Date(Number(row.expiration))
: null,
};
}
async insert(data) {
await this.kysely
.insertInto("cache")
.values({
key: data.key,
value: this.serde.serialize(data.value),
expiration: data.expiration?.getTime() ?? null,
})
.executeTakeFirst();
}
async transaction(fn) {
if (this.disableTransaction) {
return fn(this.kysely);
}
return await this.kysely
.transaction()
.setIsolationLevel("serializable")
.execute(fn);
}
async upsert(data) {
const expiration = data.expiration?.getTime() ?? null;
return await this.transaction(async (trx) => {
const prevRow = await trx
.selectFrom("cache")
.select(["cache.key", "cache.value", "cache.expiration"])
.where(KyselyCacheAdapter.filterUnexpiredKeys([data.key]))
.executeTakeFirst();
const isMysqlRunning = this.kysely.getExecutor().adapter instanceof MysqlAdapter;
await trx
.insertInto("cache")
.values({
key: data.key,
value: this.serde.serialize(data.value),
expiration,
})
.$if(isMysqlRunning, (eb) => eb.onDuplicateKeyUpdate({
value: this.serde.serialize(data.value),
expiration,
}))
.$if(!isMysqlRunning, (eb) => eb.onConflict((eb) => eb.columns(["key"]).doUpdateSet({
value: this.serde.serialize(data.value),
expiration,
})))
.executeTakeFirst();
if (prevRow === undefined) {
return null;
}
return {
value: this.serde.deserialize(prevRow.value),
expiration: prevRow.expiration !== null
? new Date(Number(prevRow.expiration))
: null,
};
});
}
async updateExpired(data) {
const updateResult = await this.kysely
.updateTable("cache")
.where(KyselyCacheAdapter.filterExpiredKeys([data.key]))
.set({
value: this.serde.serialize(data.value),
expiration: data.expiration?.getTime() ?? null,
})
.executeTakeFirst();
return Number(updateResult.numUpdatedRows);
}
async updateUnexpired(data) {
const updateResult = await this.kysely
.updateTable("cache")
.where(KyselyCacheAdapter.filterUnexpiredKeys([data.key]))
.set({
value: this.serde.serialize(data.value),
})
.executeTakeFirst();
return Number(updateResult.numUpdatedRows);
}
async incrementUnexpired(data) {
return await this.transaction(async (trx) => {
const row = await trx
.selectFrom("cache")
.select(["cache.value"])
.where(KyselyCacheAdapter.filterUnexpiredKeys([data.key]))
.executeTakeFirst();
if (row === undefined) {
return 0;
}
const { value: serializedValue } = row;
const value = this.serde.deserialize(serializedValue);
if (typeof value !== "number") {
throw new TypeCacheError(`Unable to increment or decrement none number type key "${data.key}"`);
}
const updateResult = await trx
.updateTable("cache")
.where(KyselyCacheAdapter.filterUnexpiredKeys([data.key]))
.set({
value: this.serde.serialize(value + data.value),
})
.executeTakeFirst();
return Number(updateResult.numUpdatedRows);
});
}
async removeExpiredMany(keys) {
const deleteResult = await this.kysely
.deleteFrom("cache")
.where(KyselyCacheAdapter.filterExpiredKeys(keys))
.executeTakeFirst();
return Number(deleteResult.numDeletedRows);
}
async removeUnexpiredMany(keys) {
const deleteResult = await this.kysely
.deleteFrom("cache")
.where(KyselyCacheAdapter.filterUnexpiredKeys(keys))
.executeTakeFirst();
return Number(deleteResult.numDeletedRows);
}
async removeAll() {
await this.kysely.deleteFrom("cache").execute();
}
async removeByKeyPrefix(prefix) {
await this.kysely
.deleteFrom("cache")
.where("cache.key", "like", `${prefix}%`)
.execute();
}
}
//# sourceMappingURL=kysely-cache-adapter.js.map