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