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.

514 lines 17.8 kB
/** * @module SharedLock */ import { MysqlAdapter, Transaction } from "kysely"; import {} from "../../../../execution-context/contracts/_module.js"; import {} from "../../../../shared-lock/contracts/_module.js"; import {} from "../../../../time-span/contracts/_module.js"; import { TimeSpan } from "../../../../time-span/implementations/_module.js"; import {} from "../../../../utilities/_module.js"; /** * @internal */ class DatabaseReaderSemaphoreTransaction { kysely; isMysql; constructor(kysely) { this.kysely = kysely; this.isMysql = this.kysely.getExecutor().adapter instanceof MysqlAdapter; } async findSemaphore(_context, key) { const row = await this.kysely .selectFrom("readerSemaphore") .where("readerSemaphore.key", "=", key) .select("limit") .executeTakeFirst(); if (row === undefined) { return null; } return row; } async findSlots(_context, key) { const rows = await this.kysely .selectFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .select([ "readerSemaphoreSlot.id", "readerSemaphoreSlot.expiration", ]) .execute(); return rows.map((row) => { if (row.expiration === null) { return { id: row.id, expiration: null, }; } return { id: row.id, expiration: new Date(Number(row.expiration)), }; }); } async upsertSemaphore(_context, key, limit) { await this.kysely .insertInto("readerSemaphore") .values({ key, limit }) .$if(!this.isMysql, (eb) => eb.onConflict((eb_) => eb_.column("key").doUpdateSet({ key, limit, }))) .$if(this.isMysql, (eb) => eb.onDuplicateKeyUpdate({ key, limit, })) .execute(); } async upsertSlot(_context, key, lockId, expiration) { const expirationAsMs = expiration?.getTime() ?? null; await this.kysely .insertInto("readerSemaphoreSlot") .values({ key, id: lockId, expiration: expirationAsMs, }) .$if(!this.isMysql, (eb) => eb.onConflict((eb_) => eb_.column("id").doUpdateSet({ key, id: lockId, expiration: expirationAsMs, }))) .$if(this.isMysql, (eb) => eb.onDuplicateKeyUpdate({ key, id: lockId, expiration: expirationAsMs, })) .execute(); } async removeSlot(_context, key, slotId) { let row; if (this.isMysql) { row = await this.kysely .selectFrom("readerSemaphoreSlot") .select("readerSemaphoreSlot.expiration") .where("readerSemaphoreSlot.key", "=", key) .where("readerSemaphoreSlot.id", "=", slotId) .executeTakeFirst(); await this.kysely .deleteFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .where("readerSemaphoreSlot.id", "=", slotId) .executeTakeFirst(); } else { row = await this.kysely .deleteFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .where("readerSemaphoreSlot.id", "=", slotId) .returning("readerSemaphoreSlot.expiration") .executeTakeFirst(); } if (row === undefined) { return null; } if (row.expiration === null) { return { expiration: null, }; } return { expiration: new Date(Number(row.expiration)), }; } async removeAllSlots(_context, key) { let rows; if (this.isMysql) { rows = await this.kysely .selectFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .select("readerSemaphoreSlot.expiration") .execute(); await this.kysely .deleteFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .execute(); } else { rows = await this.kysely .deleteFrom("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .returning("readerSemaphoreSlot.expiration") .execute(); } return rows.map((row) => { if (row.expiration === null) { return { expiration: null, }; } return { expiration: new Date(Number(row.expiration)), }; }); } async updateExpiration(_context, key, slotId, expiration) { const result = await this.kysely .updateTable("readerSemaphoreSlot") .where("readerSemaphoreSlot.key", "=", key) .where("readerSemaphoreSlot.id", "=", slotId) .where((eb) => eb.and([ eb("readerSemaphoreSlot.expiration", "is not", null), eb("readerSemaphoreSlot.expiration", ">", Date.now()), ])) .set({ expiration: expiration.getTime(), }) .executeTakeFirst(); return Number(result.numUpdatedRows); } } /** * @internal */ class DatabaseWriterLockTransaction { kysely; isMysql; constructor(kysely) { this.kysely = kysely; this.isMysql = this.kysely.getExecutor().adapter instanceof MysqlAdapter; } async find(_context, key) { const row = await this.kysely .selectFrom("writerLock") .where("writerLock.key", "=", key) .select(["writerLock.owner", "writerLock.expiration"]) .executeTakeFirst(); if (row === undefined) { return null; } if (row.expiration === null) { return { owner: row.owner, expiration: null, }; } return { owner: row.owner, expiration: new Date(Number(row.expiration)), }; } async upsert(_context, key, lockId, expiration) { const expirationAsMs = expiration?.getTime() ?? null; await this.kysely .insertInto("writerLock") .values({ key, owner: lockId, expiration: expirationAsMs, }) .$if(!this.isMysql, (eb) => eb.onConflict((eb_) => eb_.column("key").doUpdateSet({ key, owner: lockId, expiration: expirationAsMs, }))) .$if(this.isMysql, (eb) => eb.onDuplicateKeyUpdate({ key, owner: lockId, expiration: expirationAsMs, })) .execute(); } async remove(_context, key) { let result; if (this.isMysql) { result = await this.kysely .selectFrom("writerLock") .where("writerLock.key", "=", key) .select("writerLock.expiration") .executeTakeFirst(); await this.kysely .deleteFrom("writerLock") .where("writerLock.key", "=", key) .executeTakeFirst(); } else { result = await this.kysely .deleteFrom("writerLock") .where("writerLock.key", "=", key) .returning(["writerLock.expiration"]) .executeTakeFirst(); } if (result === undefined) { return null; } if (result.expiration === null) { return { expiration: null, }; } return { expiration: new Date(Number(result.expiration)), }; } async removeIfOwner(_context, key, lockId) { let row; if (this.isMysql) { row = await this.kysely .selectFrom("writerLock") .where("writerLock.key", "=", key) .where("writerLock.owner", "=", lockId) .select(["writerLock.expiration", "writerLock.owner"]) .executeTakeFirst(); await this.kysely .deleteFrom("writerLock") .where("writerLock.key", "=", key) .where("writerLock.owner", "=", lockId) .execute(); } else { row = await this.kysely .deleteFrom("writerLock") .where("writerLock.key", "=", key) .where("writerLock.owner", "=", lockId) .returning(["writerLock.expiration", "writerLock.owner"]) .executeTakeFirst(); } if (row === undefined) { return null; } const { expiration } = row; if (expiration === null) { return { owner: row.owner, expiration: null, }; } return { owner: row.owner, expiration: new Date(Number(expiration)), }; } async updateExpiration(_context, key, lockId, expiration) { const result = await this.kysely .updateTable("writerLock") .where("writerLock.key", "=", key) .where("writerLock.owner", "=", lockId) .where((eb) => eb.and([ eb("writerLock.expiration", "is not", null), eb("writerLock.expiration", ">", Date.now()), ])) .set({ expiration: expiration.getTime(), }) .executeTakeFirst(); return Number(result.numUpdatedRows); } } /** * To utilize the `KyselySharedLockAdapter`, you must install the [`"kysely"`](https://www.npmjs.com/package/kysely) package and configure a `Kysely` class instance. * * Note in order to use `KyselySharedLockAdapter` correctly, ensure you use a single, consistent database across all server instances and use a database that has support for transactions. * The adapter have been tested with `sqlite`, `postgres` and `mysql` databases. * * IMPORT_PATH: `"@daiso-tech/core/shared-lock/kysely-shared-lock-adapter"` * @group Adapters */ export class KyselySharedLockAdapter { kysely; expiredKeysRemovalInterval; shouldRemoveExpiredKeys; intervalId = null; currentDate; enableTransactions; /** * @example * ```ts * import { KyselySharedLockAdapter } from "@daiso-tech/core/shared-lock/kysely-shared-lock-adapter"; * import Sqlite from "better-sqlite3"; * import { Kysely, SqliteDialect } from "kysely"; * * const sharedLockAdapter = new KyselySharedLockAdapter({ * kysely: new Kysely({ * dialect: new SqliteDialect({ * database: new Sqlite("local.db"), * }), * }), * }); * // You need initialize the adapter once before using it. * await sharedLockAdapter.init(); * ``` */ constructor(settings) { const { kysely, expiredKeysRemovalInterval = TimeSpan.fromMinutes(1), shouldRemoveExpiredKeys = true, currentDate = () => new Date(), enableTransactions = !(settings.kysely instanceof Transaction), } = settings; this.expiredKeysRemovalInterval = TimeSpan.fromTimeSpan(expiredKeysRemovalInterval); this.shouldRemoveExpiredKeys = shouldRemoveExpiredKeys; this.kysely = kysely; this.currentDate = currentDate; this.enableTransactions = enableTransactions; } _transaction(trxFn) { if (this.enableTransactions) { return this.kysely .transaction() .setIsolationLevel("serializable") .execute(async (trx) => { return await trxFn(trx); }); } return trxFn(this.kysely); } async init() { // Should throw if the table already exists thats why the try catch is used. try { await this.kysely.schema .createTable("readerSemaphore") .addColumn("key", "varchar(255)", (col) => col.notNull().primaryKey()) .addColumn("limit", "integer", (col) => col.notNull()) .execute(); } catch { /* EMPTY */ } // Should throw if the table already exists thats why the try catch is used. try { await this.kysely.schema .createTable("readerSemaphoreSlot") .addColumn("id", "varchar(255)", (col) => col.notNull().primaryKey()) .addColumn("key", "varchar(255)", (col) => col.notNull()) .addColumn("expiration", "bigint") .addForeignKeyConstraint("readerSemaphoreSlot_key", ["key"], "readerSemaphore", ["key"], (eb) => eb.onDelete("cascade")) .execute(); } catch { /* EMPTY */ } // Should throw if the index already exists thats why the try catch is used. try { await this.kysely.schema .createIndex("readerSemaphoreSlot_expiration_index") .on("readerSemaphoreSlot") .columns(["key", "expiration"]) .execute(); } catch { /* EMPTY */ } // Should throw if the table already exists thats why the try catch is used. try { await this.kysely.schema .createTable("writerLock") .addColumn("key", "varchar(255)", (col) => col.primaryKey().notNull()) .addColumn("owner", "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("writerLock_expiration") .on("writerLock") .column("expiration") .execute(); } catch { /* EMPTY */ } if (this.shouldRemoveExpiredKeys) { this.intervalId = setInterval(() => { void this.removeAllExpired(); }, this.expiredKeysRemovalInterval.toMilliseconds()); } } /** * Removes all related shared-lock tables and their rows. * Note all shared-lock data will be removed. */ async deInit() { if (this.shouldRemoveExpiredKeys && this.intervalId !== null) { clearInterval(this.intervalId); } // Should throw if the index does not exists thats why the try catch is used. try { await this.kysely.schema .dropIndex("readerSemaphoreSlot_expiration_index") .on("readerSemaphoreSlot") .execute(); } catch { /* EMPTY */ } // Should throw if the table does not exists thats why the try catch is used. try { await this.kysely.schema.dropTable("readerSemaphoreSlot").execute(); } catch { /* EMPTY */ } // Should throw if the table does not exists thats why the try catch is used. try { await this.kysely.schema.dropTable("readerSemaphore").execute(); } catch { /* EMPTY */ } // Should throw if the index does not exists thats why the try catch is used. try { await this.kysely.schema .dropIndex("writerLock_expiration") .on("writerLock") .execute(); } catch { /* EMPTY */ } // Should throw if the table does not exists thats why the try catch is used. try { await this.kysely.schema.dropTable("writerLock").execute(); } catch { /* EMPTY */ } } async removeAllExpiredReaders() { await this.kysely .deleteFrom("readerSemaphore") .where((eb) => { const hasUnexpiredSlots = eb .selectFrom("readerSemaphoreSlot") .select(eb.val(1).as("value")) .where("readerSemaphoreSlot.key", "=", eb.ref("readerSemaphore.key")) .where((eb_) => eb_.and([ eb_("readerSemaphoreSlot.expiration", "is not", null), eb_("readerSemaphoreSlot.expiration", ">", Date.now()), ])); return eb.not(eb.exists(hasUnexpiredSlots)); }) .execute(); } async removeAllExpiredWriters() { await this.kysely .deleteFrom("writerLock") .where("writerLock.expiration", "<=", this.currentDate().getTime()) .execute(); } async removeAllExpired() { await Promise.all([ this.removeAllExpiredWriters(), this.removeAllExpiredReaders(), ]); } async transaction(_context, fn) { return await this._transaction(async (trx) => { return await fn({ reader: new DatabaseReaderSemaphoreTransaction(trx), writer: new DatabaseWriterLockTransaction(trx), }); }); } } //# sourceMappingURL=kysely-shared-lock-adapter.js.map