UNPKG

@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
/** * @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