diff --git a/README.md b/README.md index 6e7ebf5..e6458bd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - ๐Ÿ”’ Easy usage - ๐Ÿ”„ Multiple drivers (Redis, Postgres, MySQL, Sqlite, In-Memory and others) +- ๐Ÿ“ฆ Multiple database adapters ( Knex, Kysely, Drizzle ...) - ๐Ÿ”‘ Customizable named locks - ๐ŸŒ Consistent API across all drivers - ๐Ÿงช Easy testing by switching to an in-memory driver diff --git a/docs/bin/serve.ts b/docs/bin/serve.ts index 27d0bc2..1d6bffc 100644 --- a/docs/bin/serve.ts +++ b/docs/bin/serve.ts @@ -14,7 +14,7 @@ import 'reflect-metadata' import { Ignitor } from '@adonisjs/core' import { readFile } from 'node:fs/promises' import { defineConfig } from '@adonisjs/vite' -import { ApplicationService } from '@adonisjs/core/types' +import type { ApplicationService } from '@adonisjs/core/types' import { defineConfig as defineHttpConfig } from '@adonisjs/core/http' /** @@ -50,7 +50,7 @@ async function defineRoutes(app: ApplicationService) { result[from] = to return result }, - {} as Record + {} as Record, ) router.get('*', async ({ request, response }) => { diff --git a/docs/content/docs/api.md b/docs/content/docs/api.md index ce7b20c..ceb9427 100644 --- a/docs/content/docs/api.md +++ b/docs/content/docs/api.md @@ -156,14 +156,6 @@ const lock1 = verrou.createLock('key', 'owner') const lock2 = verrou.restoreLock(lock1.serialize()) ``` -### `disconnect` - -Disconnect from the store if applicable. With a Redis Store, it will close the `ioredis` connection. - -```ts -await verrou.disconnect() -``` - ## Verrou API Verrou API is a wrapper around the LockFactory API. @@ -200,11 +192,3 @@ As explained above, the `use` method allows you to use a different store than th verrou.use('myMemoryStore').createLock('key') ``` -### `disconnectAll` - -Disconnect from all stores. - -```ts -await verrou.disconnectAll() -``` - diff --git a/docs/content/docs/drivers.md b/docs/content/docs/drivers.md index 2f930bd..2e45a81 100644 --- a/docs/content/docs/drivers.md +++ b/docs/content/docs/drivers.md @@ -22,16 +22,16 @@ The driver uses the [ioredis](https://github.com/redis/ioredis) library under th ```ts // title: Verrou API +import { Redis } from 'ioredis' import { Verrou } from '@verrou/core' import { redisStore } from '@verrou/core/drivers/redis' +const redis = new Redis({ host: 'localhost', port: 6379 }) const verrou = new Verrou({ default: 'redis', drivers: { - redis: { - driver: redisStore({ - connection: { host: 'localhost', port: 6379 } - }) + redis: { + driver: redisStore({ connection: redis }) }, } }) @@ -42,37 +42,19 @@ const verrou = new Verrou({ import { Verrou, LockFactory } from '@verrou/core' import { redisStore } from '@verrou/core/drivers/redis' -const store = redisStore({ - connection: { host: 'localhost', port: 6379 } -}) +const redis = new Redis({ host: 'localhost', port: 6379 }) +const store = redisStore({ connection: redis }) const lockFactory = new LockFactory(store) ``` ::: -It is also possible to directly pass an Ioredis instance to reuse a connection. - -```ts -import { Redis } from 'ioredis' -import { Verrou } from '@verrou/core' -import { redisStore } from '@verrou/core/drivers/redis' - -const ioredis = new Redis() - -const verrou = new Verrou({ - default: 'redis', - drivers: { - redis: { driver: redisStore({ connection: ioredis }) }, - } -}) -``` - ### Options | Option | Description | Default | | --- | --- | --- | -| `connection` | The connection options to use to connect to Redis or an instance of `ioredis` | N/A | +| `connection` | An instance of `ioredis` | N/A | ### Implementation details @@ -119,25 +101,18 @@ DynamoDB is also supported by Verrou. You will need to install `@aws-sdk/client- ```ts // title: Verrou API import { Verrou } from '@verrou/core' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { dynamodbStore } from '@verrou/core/drivers/dynamodb' +const dynamoClient = new DynamoDBClient(/* ... */) const verrou = new Verrou({ default: 'dynamo', stores: { dynamo: { driver: dynamodbStore({ - endpoint: '...', - region: 'eu-west-3', - table: { - // Name of the table where the locks will be stored - name: 'locks' - }, - - // Credentials to use to connect to DynamoDB - credentials: { - accessKeyId: '...', - secretAccessKey: '...' - } + connection: dynamoClient, + // Name of the table where the locks will be stored + table: { name: 'locks' }, }) } } @@ -147,21 +122,16 @@ const verrou = new Verrou({ ```ts // title: LockFactory API import { Verrou, LockFactory } from '@verrou/core' +import { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { dynamodbStore } from '@verrou/core/drivers/dynamodb' +const dynamoClient = new DynamoDBClient(/* ... */) const store = dynamodbStore({ - endpoint: '...', - region: 'eu-west-3', + connection: dynamoClient, + // Name of the table where the locks will be stored table: { - // Name of the table where the locks will be stored name: 'locks' }, - - // Credentials to use to connect to DynamoDB - credentials: { - accessKeyId: '...', - secretAccessKey: '...' - } }) const lockFactory = new LockFactory(store) @@ -176,192 +146,91 @@ The DynamoDB table will be automatically created if it does not exists. Otherwis | Option | Description | Default | | --- | --- | --- | | `table.name` | The name of the table that will be used to store the cache. | `cache` | -| `credentials` | The credentials to use to connect to DynamoDB. | N/A | -| `endpoint` | The endpoint to use to connect to DynamoDB. | N/A | -| `region` | The region to use to connect to DynamoDB. | N/A | +| `connection` | An instance of `DynamoDBClient` | N/A | ## Databases -We offer several drivers to use a database as the locks store. Under the hood, we use [Knex](https://knexjs.org/). So all Knex options are available, feel free to check out the documentation. +We offer several drivers to use a database as the locks store. The database store should use an adapter for your database. Out of the box, we support [Knex](https://knexjs.org/) and [Kysely](https://kysely.dev/) to interact with the database. Knex and Kysely support many databases : SQLite, MySQL, PostgreSQL, MSSQL, Oracle, and more. + +:::note -All SQL drivers accept the following options: +Note that you can easily create your own adapter by implementing the `DatabaseAdapter` interface if you are using another library not supported by Verrou. See the [documentation](/docs/advanced/custom-adapters) for more details. + +::: + +All Database drivers accept the following common options: | Option | Description | Default | | --- | --- | --- | | `tableName` | The name of the table that will be used to store the locks. | `verrou` | | `autoCreateTable` | If the table should be automatically created if it does not exist. | `true` | -| `connection` | The connection options to use to connect to the database or an instance of `knex`. | N/A | -### PostgreSQL +### Knex -You will need to install `pg` to use this driver. +You must provide a Knex instance to use the Knex driver. Feel free to check the [Knex documentation](https://knexjs.org/) for more details about the configuration. Knex support many databases : SQLite, MySQL, PostgreSQL, MSSQL, Oracle, and more. :::codegroup ```ts // title: Verrou API +import knex from 'knex' import { Verrou } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' +import { knexStore } from '@verrou/core/drivers/knex' -const verrou = new Verrou({ - default: 'pg', - stores: { - pg: { - driver: databaseStore({ - dialect: 'pg', - connection: { - user: 'root', - password: 'root', - database: 'postgres', - port: 5432 - } - }) - } - } -}) -``` - -```ts -// title: LockFactory API -import { Verrou, LockFactory } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' - -const store = databaseStore({ - dialect: 'pg', - connection: { - user: 'root', - password: 'root', - database: 'postgres', - port: 5432 - } -}) -const lockFactory = new LockFactory(store) -``` - -::: - -### MySQL - -You will need to install `mysql2` to use this driver. - -:::codegroup - -```ts -// title: Verrou API -import { Verrou } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' - -const verrou = new Verrou({ - default: 'mysql', - stores: { - mysql: { - driver: databaseStore({ - dialect: 'mysql', - connection: { - user: 'root', - password: 'root', - database: 'mysql', - port: 3306 - } - }) - } - } -}) -``` - -```ts -// title: LockFactory API -import { Verrou, LockFactory } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' - -const store = databaseStore({ - dialect: 'mysql', - connection: { - user: 'root', - password: 'root', - database: 'mysql', - port: 3306 - } -}) - -const lockFactory = new LockFactory(store) -``` - -::: - -### SQLite ( better-sqlite3 ) - -You will need to install `better-sqlite3` to use this driver. - -:::codegroup - -```ts -// title: Verrou API -import { Verrou } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' +const db = knex({ client: 'mysql2', connection: MYSQL_CREDENTIALS }) const verrou = new Verrou({ default: 'sqlite', stores: { - sqlite: { - driver: databaseStore({ - dialect: 'better-sqlite3', - connection: { filename: 'cache.sqlite3' } - }) - } + sqlite: { driver: knexStore({ connection: db }) } } }) ``` ```ts // title: LockFactory API +import knex from 'knex' import { Verrou, LockFactory } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' - -const store = databaseStore({ - dialect: 'better-sqlite3', - connection: { filename: 'cache.sqlite3' } -}) +import { knexStore } from '@verrou/core/drivers/knex' +const db = knex({ client: 'mysql2', connection: MYSQL_CREDENTIALS }) +const store = knexStore({ dialect: 'sqlite', connection: db }) const lockFactory = new LockFactory(store) ``` ::: +### Kysely -### SQLite ( sqlite3 ) +You must provide a Kysely instance to use the Kysely driver. Feel free to check the [Kysely documentation](https://kysely.dev/) for more details about the configuration. Kysely support the following databases : SQLite, MySQL, PostgreSQL and MSSQL. -You will need to install `sqlite3` to use this driver. :::codegroup ```ts // title: Verrou API +import { Kysely } from 'kysely' import { Verrou } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' +import { kyselyStore } from '@verrou/core/drivers/kysely' + +const db = new Kysely({ dialect }) const verrou = new Verrou({ - default: 'sqlite', + default: 'kysely', stores: { - sqlite: { - driver: databaseStore({ - connection: { filename: 'cache.sqlite3' } - }) - } + kysely: { driver: kyselyStore({ connection: db }) } } }) ``` ```ts // title: LockFactory API +import { Kysely } from 'kysely' import { Verrou, LockFactory } from '@verrou/core' -import { databaseStore } from '@verrou/core/drivers/database' - -const store = databaseStore({ - dialect: 'sqlite', - connection: { filename: 'cache.sqlite3' } -}) +import { kyselyStore } from '@verrou/core/drivers/kysely' +const db = new Kysely({ dialect }) +const store = kyselyStore({ connection: db }) const lockFactory = new LockFactory(store) ``` diff --git a/docs/content/docs/extend/custom_lock_store.md b/docs/content/docs/extend/custom_lock_store.md index 70860d5..702e7ac 100644 --- a/docs/content/docs/extend/custom_lock_store.md +++ b/docs/content/docs/extend/custom_lock_store.md @@ -12,7 +12,6 @@ interface LockStore { delete(key: string, owner: string): Promise exists(key: string): Promise extend(key: string, owner: string, duration: number): Promise - disconnect(): Promise } ``` diff --git a/packages/verrou/package.json b/packages/verrou/package.json index 82d284a..909fc8a 100644 --- a/packages/verrou/package.json +++ b/packages/verrou/package.json @@ -45,9 +45,13 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "^3.529.1", + "@types/better-sqlite3": "^7.6.9", + "@types/pg": "^8.11.2", "@types/proper-lockfile": "^4.1.4", + "better-sqlite3": "^9.4.3", "ioredis": "^5.3.2", "knex": "^3.1.0", + "kysely": "^0.27.3", "mysql2": "^3.9.2", "pg": "^8.11.3", "sqlite3": "^5.1.7" diff --git a/packages/verrou/src/drivers/database.ts b/packages/verrou/src/drivers/database.ts index ea69fa2..c97dbc3 100644 --- a/packages/verrou/src/drivers/database.ts +++ b/packages/verrou/src/drivers/database.ts @@ -1,75 +1,26 @@ -import knex, { type Knex } from 'knex' - import { E_LOCK_NOT_OWNED } from '../errors.js' -import type { DatabaseStoreOptions, LockStore } from '../types/main.js' +import type { DatabaseAdapter, DatabaseOptions } from '../types/drivers.js' /** - * Create a new database store + * A store that uses a database to store locks + * + * You should provide an adapter that will handle the database interactions */ -export function databaseStore(config: DatabaseStoreOptions) { - return { config, factory: () => new DatabaseStore(config) } -} - -export class DatabaseStore implements LockStore { - /** - * Knex connection instance - */ - #connection: Knex - - /** - * The name of the table used to store locks - */ - #tableName = 'verrou' - - /** - * A promise that resolves when the table is created - */ +export class DatabaseStore { + #adapter: DatabaseAdapter #initialized: Promise - constructor(config: DatabaseStoreOptions) { - this.#connection = this.#createConnection(config) - this.#tableName = config.tableName || this.#tableName + constructor(adapter: DatabaseAdapter, config: DatabaseOptions) { + this.#adapter = adapter + this.#adapter.setTableName(config.tableName || 'verrou') + if (config.autoCreateTable !== false) { - this.#initialized = this.#createTableIfNotExists() + this.#initialized = this.#adapter.createTableIfNotExists() } else { this.#initialized = Promise.resolve() } } - /** - * Create or reuse a Knex connection instance - */ - #createConnection(config: DatabaseStoreOptions) { - if (typeof config.connection === 'string') { - return knex({ client: config.dialect, connection: config.connection, useNullAsDefault: true }) - } - - /** - * This looks hacky. Maybe we can find a better way to do this? - * We check if config.connection is a Knex object. If it is, we - * return it as is. If it's not, we create a new Knex object - */ - if ('with' in config.connection!) { - return config.connection - } - - return knex({ client: config.dialect, connection: config.connection, useNullAsDefault: true }) - } - - /** - * Create the cache table if it doesn't exist - */ - async #createTableIfNotExists() { - const hasTable = await this.#connection.schema.hasTable(this.#tableName) - if (hasTable) return - - await this.#connection.schema.createTable(this.#tableName, (table) => { - table.string('key', 255).notNullable().primary() - table.string('owner').notNullable() - table.bigint('expiration').unsigned().nullable() - }) - } - /** * Compute the expiration date based on the provided TTL */ @@ -83,13 +34,9 @@ export class DatabaseStore implements LockStore { */ async #getCurrentOwner(key: string) { await this.#initialized - const result = await this.#connection - .table(this.#tableName) - .where('key', key) - .select('owner') - .first() + const lock = await this.#adapter.getLock(key) - return result?.owner + return lock?.owner } /** @@ -102,21 +49,18 @@ export class DatabaseStore implements LockStore { * update it with the new owner and expiration date. */ async save(key: string, owner: string, ttl: number | null) { + await this.#initialized try { - await this.#initialized - await this.#connection - .table(this.#tableName) - .insert({ key, owner, expiration: this.#computeExpiresAt(ttl) }) - + await this.#adapter.insertLock({ key, owner, expiration: this.#computeExpiresAt(ttl) }) return true } catch (error) { - const updated = await this.#connection - .table(this.#tableName) - .where('key', key) - .where('expiration', '<=', Date.now()) - .update({ owner, expiration: this.#computeExpiresAt(ttl) }) + const updatedRows = await this.#adapter.acquireLock({ + key, + owner, + expiration: this.#computeExpiresAt(ttl), + }) - return updated >= 1 + return updatedRows > 0 } } @@ -128,14 +72,14 @@ export class DatabaseStore implements LockStore { const currentOwner = await this.#getCurrentOwner(key) if (currentOwner !== owner) throw new E_LOCK_NOT_OWNED() - await this.#connection.table(this.#tableName).where('key', key).where('owner', owner).delete() + await this.#adapter.deleteLock(key, owner) } /** * Force delete the lock from the store. No check is made on the owner */ async forceDelete(key: string) { - await this.#connection.table(this.#tableName).where('key', key).delete() + await this.#adapter.deleteLock(key) } /** @@ -143,11 +87,8 @@ export class DatabaseStore implements LockStore { * Duration is in milliseconds */ async extend(key: string, owner: string, duration: number) { - const updated = await this.#connection - .table(this.#tableName) - .where('key', key) - .where('owner', owner) - .update({ expiration: Date.now() + duration }) + await this.#initialized + const updated = await this.#adapter.extendLock(key, owner, duration) if (updated === 0) throw new E_LOCK_NOT_OWNED() } @@ -157,22 +98,11 @@ export class DatabaseStore implements LockStore { */ async exists(key: string) { await this.#initialized - const result = await this.#connection - .table(this.#tableName) - .where('key', key) - .select('expiration') - .first() + const lock = await this.#adapter.getLock(key) - if (!result) return false - if (result.expiration === null) return true + if (!lock) return false + if (lock.expiration === null) return true - return result.expiration > Date.now() - } - - /** - * Disconnect knex connection - */ - disconnect() { - return this.#connection.destroy() + return lock.expiration > Date.now() } } diff --git a/packages/verrou/src/drivers/dynamodb.ts b/packages/verrou/src/drivers/dynamodb.ts index 6a9bbb5..0168825 100644 --- a/packages/verrou/src/drivers/dynamodb.ts +++ b/packages/verrou/src/drivers/dynamodb.ts @@ -1,8 +1,8 @@ +import type { DynamoDBClient } from '@aws-sdk/client-dynamodb' import { ConditionalCheckFailedException, CreateTableCommand, DeleteItemCommand, - DynamoDBClient, GetItemCommand, PutItemCommand, ResourceInUseException, @@ -22,9 +22,9 @@ export class DynamoDBStore implements LockStore { #initialized: Promise /** - * DynamoDB client + * DynamoDB connection */ - #client: DynamoDBClient + #connection: DynamoDBClient /** * DynamoDB table name @@ -33,12 +33,7 @@ export class DynamoDBStore implements LockStore { constructor(config: DynamoDbOptions) { this.#tableName = config.table.name - - this.#client = new DynamoDBClient({ - region: config.region, - credentials: config.credentials, - endpoint: config.endpoint, - }) + this.#connection = config.connection this.#initialized = this.#createTableIfNotExists() } @@ -58,7 +53,7 @@ export class DynamoDBStore implements LockStore { }) try { - await this.#client.send(command) + await this.#connection.send(command) } catch (error) { if (error instanceof ResourceInUseException) return throw error @@ -87,7 +82,7 @@ export class DynamoDBStore implements LockStore { ExpressionAttributeValues: { ':now': { N: Date.now().toString() } }, }) - const result = await this.#client.send(command) + const result = await this.#connection.send(command) return result.$metadata.httpStatusCode === 200 } catch (err) { if (err instanceof ConditionalCheckFailedException) return false @@ -109,7 +104,7 @@ export class DynamoDBStore implements LockStore { }) try { - await this.#client.send(command) + await this.#connection.send(command) } catch (err) { throw new E_LOCK_NOT_OWNED() } @@ -124,7 +119,7 @@ export class DynamoDBStore implements LockStore { Key: { key: { S: key } }, }) - await this.#client.send(command) + await this.#connection.send(command) } /** @@ -137,7 +132,7 @@ export class DynamoDBStore implements LockStore { Key: { key: { S: key } }, }) - const result = await this.#client.send(command) + const result = await this.#connection.send(command) const isExpired = result.Item?.expires_at?.N && result.Item.expires_at.N < Date.now().toString() return result.Item !== undefined && !isExpired @@ -161,16 +156,9 @@ export class DynamoDBStore implements LockStore { }) try { - await this.#client.send(command) + await this.#connection.send(command) } catch (err) { throw new E_LOCK_NOT_OWNED() } } - - /** - * Disconnect from DynamoDB - */ - async disconnect() { - this.#client.destroy() - } } diff --git a/packages/verrou/src/drivers/knex.ts b/packages/verrou/src/drivers/knex.ts new file mode 100644 index 0000000..a5a7840 --- /dev/null +++ b/packages/verrou/src/drivers/knex.ts @@ -0,0 +1,88 @@ +import type { Knex } from 'knex' + +import { DatabaseStore } from './database.js' +import type { DatabaseAdapter, KnexStoreOptions } from '../types/main.js' + +/** + * Create a new knex store + */ +export function knexStore(config: KnexStoreOptions) { + return { + config, + factory: () => { + const adapter = new KnexAdapter(config.connection) + return new DatabaseStore(adapter, config) + }, + } +} + +/** + * Knex adapter for the DatabaseStore + */ +export class KnexAdapter implements DatabaseAdapter { + #connection: Knex + #tableName!: string + + constructor(connection: Knex) { + this.#connection = connection + } + + setTableName(tableName: string) { + this.#tableName = tableName + } + + async createTableIfNotExists() { + const hasTable = await this.#connection.schema.hasTable(this.#tableName) + if (hasTable) return + + await this.#connection.schema.createTable(this.#tableName, (table) => { + table.string('key', 255).notNullable().primary() + table.string('owner').notNullable() + table.bigint('expiration').unsigned().nullable() + }) + } + + async insertLock(lock: { key: string; owner: string; expiration: number | null }) { + await this.#connection.table(this.#tableName).insert(lock) + } + + async acquireLock(lock: { key: string; owner: string; expiration: number | null }) { + const updated = await this.#connection + .table(this.#tableName) + .where('key', lock.key) + .where('expiration', '<=', Date.now()) + .update({ owner: lock.owner, expiration: lock.expiration }) + + return updated + } + + async deleteLock(key: string, owner?: string | undefined) { + await this.#connection + .table(this.#tableName) + .where('key', key) + .modify((query) => { + if (owner) query.where('owner', owner) + }) + .delete() + } + + async extendLock(key: string, owner: string, duration: number) { + const updated = await this.#connection + .table(this.#tableName) + .where('key', key) + .where('owner', owner) + .update({ expiration: Date.now() + duration }) + + return updated + } + + async getLock(key: string) { + const result = await this.#connection + .table(this.#tableName) + .where('key', key) + .select(['owner', 'expiration']) + .first() + + return result + } +} diff --git a/packages/verrou/src/drivers/kysely.ts b/packages/verrou/src/drivers/kysely.ts new file mode 100644 index 0000000..0cb6930 --- /dev/null +++ b/packages/verrou/src/drivers/kysely.ts @@ -0,0 +1,99 @@ +import { PostgresAdapter, type Kysely } from 'kysely' + +import { DatabaseStore } from './database.js' +import type { DatabaseAdapter, KyselyOptions } from '../types/main.js' + +/** + * Create a new Kysely store + */ +export function kyselyStore(config: KyselyOptions) { + return { + config, + factory: () => { + const adapter = new KyselyAdapter(config.connection) + return new DatabaseStore(adapter, config) + }, + } +} + +/** + * Kysely adapter for the DatabaseStore + */ +export class KyselyAdapter implements DatabaseAdapter { + #connection: Kysely + #tableName!: string + + constructor(connection: Kysely) { + this.#connection = connection + } + + setTableName(tableName: string) { + this.#tableName = tableName + } + + async createTableIfNotExists() { + const isPg = this.#connection.getExecutor().adapter instanceof PostgresAdapter + + await this.#connection.schema + .createTable(this.#tableName) + .addColumn('key', 'varchar(255)', (col) => col.primaryKey().notNull()) + .addColumn('owner', 'varchar(255)', (col) => col.notNull()) + .addColumn('expiration', 'bigint', (col) => { + if (!isPg) col.unsigned() + return col + }) + .ifNotExists() + .execute() + } + + async insertLock(lock: { key: string; owner: string; expiration: number | null }) { + await this.#connection + .insertInto(this.#tableName) + .values({ + key: lock.key, + owner: lock.owner, + expiration: lock.expiration, + }) + .execute() + } + + async acquireLock(lock: { key: string; owner: string; expiration: number | null }) { + const updated = await this.#connection + .updateTable(this.#tableName) + .where('key', '=', lock.key) + .where('expiration', '<=', Date.now()) + .set({ owner: lock.owner, expiration: lock.expiration }) + .executeTakeFirst() + + return Number(updated.numUpdatedRows) + } + + async deleteLock(key: string, owner?: string | undefined) { + await this.#connection + .deleteFrom(this.#tableName) + .where('key', '=', key) + .$if(owner !== undefined, (query) => query.where('owner', '=', owner)) + .execute() + } + + async extendLock(key: string, owner: string, duration: number) { + const updated = await this.#connection + .updateTable(this.#tableName) + .where('key', '=', key) + .where('owner', '=', owner) + .set({ expiration: Date.now() + duration }) + .executeTakeFirst() + + return Number(updated.numUpdatedRows) + } + + async getLock(key: string) { + const result = await this.#connection + .selectFrom(this.#tableName) + .where('key', '=', key) + .select(['owner', 'expiration']) + .executeTakeFirst() + + return result + } +} diff --git a/packages/verrou/src/drivers/memory.ts b/packages/verrou/src/drivers/memory.ts index 2c99ea7..ecbf58c 100644 --- a/packages/verrou/src/drivers/memory.ts +++ b/packages/verrou/src/drivers/memory.ts @@ -109,8 +109,4 @@ export class MemoryStore implements LockStore { return lock.mutex.isLocked() } - - async disconnect() { - // noop - } } diff --git a/packages/verrou/src/drivers/redis.ts b/packages/verrou/src/drivers/redis.ts index efec8e8..e25027f 100644 --- a/packages/verrou/src/drivers/redis.ts +++ b/packages/verrou/src/drivers/redis.ts @@ -1,4 +1,4 @@ -import { Redis as IoRedis } from 'ioredis' +import type { Redis as IoRedis } from 'ioredis' import { E_LOCK_NOT_OWNED } from '../errors.js' import type { LockStore, RedisStoreOptions } from '../types/main.js' @@ -17,11 +17,7 @@ export class RedisStore implements LockStore { #connection: IoRedis constructor(options: RedisStoreOptions) { - if (options.connection instanceof IoRedis) { - this.#connection = options.connection - } else { - this.#connection = new IoRedis(options.connection) - } + this.#connection = options.connection } /** @@ -85,11 +81,4 @@ export class RedisStore implements LockStore { const result = await this.#connection.eval(lua, 1, key, owner, duration) if (result === 0) throw new E_LOCK_NOT_OWNED() } - - /** - * Disconnect from Redis - */ - async disconnect() { - await this.#connection.quit() - } } diff --git a/packages/verrou/src/lock_factory.ts b/packages/verrou/src/lock_factory.ts index 7b7545b..d39966d 100644 --- a/packages/verrou/src/lock_factory.ts +++ b/packages/verrou/src/lock_factory.ts @@ -60,11 +60,4 @@ export class LockFactory { restoreLock(lock: SerializedLock) { return new Lock(lock.key, this.#store, this.#config, lock.owner, lock.ttl, lock.expirationTime) } - - /** - * Disconnect the store ( if applicable ) - */ - disconnect() { - return this.#store.disconnect() - } } diff --git a/packages/verrou/src/test_suite.ts b/packages/verrou/src/test_suite.ts index 6f8979e..6e0c0c6 100644 --- a/packages/verrou/src/test_suite.ts +++ b/packages/verrou/src/test_suite.ts @@ -8,16 +8,15 @@ import { LockFactory } from './lock_factory.js' import type { LockStore } from './types/main.js' import { E_LOCK_NOT_OWNED, E_LOCK_TIMEOUT } from '../index.js' -export function registerStoreTestSuite(options: { +export function registerStoreTestSuite(options: { test: typeof JapaTest - store: T - config: ConstructorParameters[0] + createStore: () => LockStore configureGroup?: (group: Group) => any }) { - const { test, store, config } = options + const { test } = options test('acquiring lock is exclusive', async ({ assert }) => { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') await lock.acquire() @@ -36,14 +35,14 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') assert.isFalse(await lock.isLocked()) }) test('acquiring lock makes it locked', async ({ assert }) => { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') await lock.acquire() @@ -52,7 +51,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock1 = provider.createLock('foo1') const lock2 = provider.createLock('foo1') @@ -64,7 +63,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') const lock2 = provider.createLock('foo') @@ -75,7 +74,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config), { + const provider = new LockFactory(options.createStore(), { retry: { timeout: 500 }, }) const lock = provider.createLock('foo') @@ -87,7 +86,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') const result = await lock.run(async () => 'hello world') @@ -96,7 +95,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') const result = await lock.run(async () => Promise.resolve('hello world')) @@ -105,7 +104,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') await assert.rejects( @@ -118,7 +117,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') await assert.rejects(async () => { @@ -129,7 +128,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') let flag = false @@ -150,7 +149,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') let flag = false @@ -167,7 +166,7 @@ export function registerStoreTestSuite { let v = 0 - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') const run = async () => { @@ -181,14 +180,14 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') assert.isString(lock.getOwner()) }) test('restore lock from another instance', async ({ assert }) => { - const storeInstance = new store(config) + const storeInstance = options.createStore() const provider = new LockFactory(storeInstance) const lock = provider.createLock('foo') @@ -204,7 +203,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo', 1000) await lock.acquire() @@ -217,7 +216,7 @@ export function registerStoreTestSuite { - const storeInstance = new store(config) + const storeInstance = options.createStore() const provider = new LockFactory(storeInstance, { retry: { delay: 25 } }) const lock1 = provider.createLock('foo', 1000) const lock2 = provider.createLock('foo', 1000) @@ -234,7 +233,7 @@ export function registerStoreTestSuite { - const storeInstance = new store(config) + const storeInstance = options.createStore() const provider = new LockFactory(storeInstance) const lock1 = provider.createLock('foo', 1000) const lock2 = provider.createLock('foo', 1000) @@ -247,7 +246,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo', 400) await lock.acquire() @@ -260,7 +259,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config), { + const provider = new LockFactory(options.createStore(), { retry: { attempts: 1, }, @@ -283,7 +282,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo', null) await lock.acquire() @@ -296,7 +295,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo') const lock2 = provider.createLock('foo') @@ -308,7 +307,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo', 1000) await lock.acquire() @@ -330,7 +329,7 @@ export function registerStoreTestSuite { - const provider = new LockFactory(new store(config)) + const provider = new LockFactory(options.createStore()) const lock = provider.createLock('foo', 1000) await lock.extend() diff --git a/packages/verrou/src/types/drivers.ts b/packages/verrou/src/types/drivers.ts index e9391e6..233d882 100644 --- a/packages/verrou/src/types/drivers.ts +++ b/packages/verrou/src/types/drivers.ts @@ -1,22 +1,16 @@ -import { type Knex } from 'knex' -import type { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb' -import type { RedisOptions as IoRedisOptions, Redis as IoRedis } from 'ioredis' - -export type DialectName = 'pg' | 'mysql2' | 'better-sqlite3' | 'sqlite3' - -export type DatabaseStoreOptions = { - /** - * The database dialect - */ - dialect: DialectName - - /** - * The database connection - */ - connection: Knex | Knex.Config['connection'] +import type { Knex } from 'knex' +import type { Kysely } from 'kysely' +import type { Redis as IoRedis } from 'ioredis' +import type { DynamoDBClient } from '@aws-sdk/client-dynamodb' +/** + * Common options for database stores + */ +export interface DatabaseOptions { /** * The table name to use ( to store the locks ) + * + * @default 'verrou' */ tableName?: string @@ -28,13 +22,39 @@ export type DatabaseStoreOptions = { autoCreateTable?: boolean } +/** + * Options for the Knex store + */ +export interface KnexStoreOptions extends DatabaseOptions { + /** + * The Knex instance + */ + connection: Knex +} + +/** + * Options for the Kysely store + */ +export interface KyselyOptions extends DatabaseOptions { + /** + * The Kysely instance + */ + connection: Kysely +} + +/** + * Options for the Redis store + */ export type RedisStoreOptions = { /** * The Redis connection */ - connection: IoRedis | IoRedisOptions + connection: IoRedis } +/** + * Options for the DynamoDB store + */ export type DynamoDbOptions = { /** * DynamoDB table name to use. @@ -43,18 +63,53 @@ export type DynamoDbOptions = { name: string } + connection: DynamoDBClient +} + +/** + * An adapter for the DatabaseStore + */ +export interface DatabaseAdapter { + /** + * Set the table name to store the locks + */ + setTableName(tableName: string): void + + /** + * Create the table to store the locks if it doesn't exist + */ + createTableIfNotExists(): Promise + + /** + * Insert the given lock in the store + */ + insertLock(lock: { key: string; owner: string; expiration: number | null }): Promise + + /** + * Acquire the lock by updating the owner and expiration date. + * + * The adapter should check if expiration date is in the past + * and return the number of updated rows. + */ + acquireLock(lock: { key: string; owner: string; expiration: number | null }): Promise + /** - * AWS credentials + * Delete a lock from the store. + * + * If owner is provided, the lock should only be deleted if the owner matches. */ - credentials?: DynamoDBClientConfig['credentials'] + deleteLock(key: string, owner?: string): Promise /** - * Region of your DynamoDB instance + * Extend the expiration date of the lock by the given + * duration ( Date.now() + duration ). + * + * The owner must match. */ - region: DynamoDBClientConfig['region'] + extendLock(key: string, owner: string, duration: number): Promise /** - * Endpoint to your DynamoDB instance + * Returns the current owner and expiration date of the lock */ - endpoint: DynamoDBClientConfig['endpoint'] + getLock(key: string): Promise<{ owner: string; expiration: number | null } | undefined> } diff --git a/packages/verrou/src/types/main.ts b/packages/verrou/src/types/main.ts index 478f791..126e45f 100644 --- a/packages/verrou/src/types/main.ts +++ b/packages/verrou/src/types/main.ts @@ -110,9 +110,4 @@ export interface LockStore { * Duration is in milliseconds */ extend(key: string, owner: string, duration: number): Promise - - /** - * Disconnect the store from the underlying storage ( when applicable ) - */ - disconnect(): Promise } diff --git a/packages/verrou/src/verrou.ts b/packages/verrou/src/verrou.ts index 4da81a4..08f810b 100644 --- a/packages/verrou/src/verrou.ts +++ b/packages/verrou/src/verrou.ts @@ -70,24 +70,4 @@ export class Verrou> { restoreLock(lock: SerializedLock) { return this.use(this.#defaultStoreName).restoreLock(lock) } - - /** - * Disconnect the default store - */ - async disconnect() { - await this.use(this.#defaultStoreName).disconnect() - } - - /** - * Disconnect all connections to the stores - */ - async disconnectAll() { - const promises = [] - - for (const store of this.#storesCache.values()) { - promises.push(store.disconnect()) - } - - await Promise.allSettled(promises) - } } diff --git a/packages/verrou/test_helpers/index.ts b/packages/verrou/test_helpers/index.ts index 33b337f..ea320c7 100644 --- a/packages/verrou/test_helpers/index.ts +++ b/packages/verrou/test_helpers/index.ts @@ -1,6 +1,3 @@ -import type { Knex } from 'knex' -import type { Group } from '@japa/runner/core' - export const BASE_URL = new URL('./tmp/', import.meta.url) export const REDIS_CREDENTIALS = { @@ -8,18 +5,14 @@ export const REDIS_CREDENTIALS = { port: Number(process.env.REDIS_PORT), } -export function configureDatabaseGroupHooks(db: Knex, group: Group) { - group.each.teardown(async () => { - const exists = await db.schema.hasTable('verrou') - if (!exists) return - - await db.table('verrou').truncate() - }) - - group.teardown(async () => { - const exists = await db.schema.hasTable('verrou') - if (exists) await db.schema.dropTable('verrou') +export const MYSQL_CREDENTIALS = { + user: 'root', + password: 'root', + database: 'mysql', + port: 3306, +} - await db.destroy() - }) +export const POSTGRES_CREDENTIALS = { + user: 'postgres', + password: 'postgres', } diff --git a/packages/verrou/test_helpers/null_store.ts b/packages/verrou/test_helpers/null_store.ts index f099146..b038949 100644 --- a/packages/verrou/test_helpers/null_store.ts +++ b/packages/verrou/test_helpers/null_store.ts @@ -24,8 +24,4 @@ export class NullStore implements LockStore { async extend(_key: string, _owner: string, _duration: number): Promise { return } - - async disconnect() { - return - } } diff --git a/packages/verrou/tests/drivers/dynamodb.spec.ts b/packages/verrou/tests/drivers/dynamodb.spec.ts index 5611715..839e0f0 100644 --- a/packages/verrou/tests/drivers/dynamodb.spec.ts +++ b/packages/verrou/tests/drivers/dynamodb.spec.ts @@ -4,13 +4,13 @@ import { DeleteTableCommand, DynamoDBClient, GetItemCommand } from '@aws-sdk/cli import { DynamoDBStore } from '../../src/drivers/dynamodb.js' import { registerStoreTestSuite } from '../../src/test_suite.js' -const credentials = { +const dynamoClient = new DynamoDBClient({ region: 'eu-west-3', endpoint: process.env.DYNAMODB_ENDPOINT, credentials: { accessKeyId: 'foo', secretAccessKey: 'foo' }, -} -const config = { ...credentials, table: { name: 'verrou' } } -const dynamoClient = new DynamoDBClient(credentials) +}) + +const config = { connection: dynamoClient, table: { name: 'verrou' } } function deleteTableTeardown(tableName: string) { return async () => { @@ -22,7 +22,10 @@ function deleteTableTeardown(tableName: string) { test.group('DynamoDB Store', (group) => { group.each.teardown(deleteTableTeardown('verrou')) - registerStoreTestSuite({ test, config, store: DynamoDBStore }) + registerStoreTestSuite({ + test, + createStore: () => new DynamoDBStore(config), + }) test('should automatically create table', async ({ assert }) => { const store = new DynamoDBStore(config) diff --git a/packages/verrou/tests/drivers/knex/helpers.ts b/packages/verrou/tests/drivers/knex/helpers.ts new file mode 100644 index 0000000..a3f80dd --- /dev/null +++ b/packages/verrou/tests/drivers/knex/helpers.ts @@ -0,0 +1,27 @@ +import type { Knex } from 'knex' +import type { Group } from '@japa/runner/core' + +import { KnexAdapter } from '../../../src/drivers/knex.js' +import { DatabaseStore } from '../../../src/drivers/database.js' +import type { KnexStoreOptions } from '../../../src/types/drivers.js' + +export function setupTeardownHooks(db: Knex, group: Group) { + group.each.teardown(async () => { + const exists = await db.schema.hasTable('verrou') + if (!exists) return + + await db.table('verrou').truncate() + }) + + group.teardown(async () => { + const exists = await db.schema.hasTable('verrou') + if (exists) await db.schema.dropTable('verrou') + + await db.destroy() + }) +} + +export function createKnexStore(options: KnexStoreOptions) { + const adapter = new KnexAdapter(options.connection) + return new DatabaseStore(adapter, options) +} diff --git a/packages/verrou/tests/drivers/database.spec.ts b/packages/verrou/tests/drivers/knex/knex.spec.ts similarity index 71% rename from packages/verrou/tests/drivers/database.spec.ts rename to packages/verrou/tests/drivers/knex/knex.spec.ts index 6fed0be..bd9c937 100644 --- a/packages/verrou/tests/drivers/database.spec.ts +++ b/packages/verrou/tests/drivers/knex/knex.spec.ts @@ -1,17 +1,15 @@ import knex from 'knex' import { test } from '@japa/runner' -import { DatabaseStore } from '../../src/drivers/database.js' -import { configureDatabaseGroupHooks } from '../../test_helpers/index.js' +import { createKnexStore, setupTeardownHooks } from './helpers.js' const db = knex({ client: 'pg', connection: { user: 'postgres', password: 'postgres' } }) test.group('Database Driver', (group) => { - configureDatabaseGroupHooks(db, group) + setupTeardownHooks(db, group) test('create table with specified tableName', async ({ assert, cleanup }) => { - const store = new DatabaseStore({ + const store = createKnexStore({ connection: db, - dialect: 'pg', tableName: 'verrou_my_locks', }) @@ -26,14 +24,14 @@ test.group('Database Driver', (group) => { }) test('doesnt create table if autoCreateTable is false', async ({ assert }) => { - new DatabaseStore({ connection: db, dialect: 'pg', autoCreateTable: false }) + createKnexStore({ connection: db, autoCreateTable: false }) const hasTable = await db.schema.hasTable('verrou') assert.isFalse(hasTable) }) test('null ttl', async ({ assert }) => { - const store = new DatabaseStore({ connection: db, dialect: 'pg' }) + const store = createKnexStore({ connection: db }) await store.save('foo', 'bar', null) diff --git a/packages/verrou/tests/drivers/knex/mysql.spec.ts b/packages/verrou/tests/drivers/knex/mysql.spec.ts new file mode 100644 index 0000000..20fb194 --- /dev/null +++ b/packages/verrou/tests/drivers/knex/mysql.spec.ts @@ -0,0 +1,16 @@ +import knex from 'knex' +import { test } from '@japa/runner' + +import { createKnexStore, setupTeardownHooks } from './helpers.js' +import { MYSQL_CREDENTIALS } from '../../../test_helpers/index.js' +import { registerStoreTestSuite } from '../../../src/test_suite.js' + +const db = knex({ client: 'mysql2', connection: MYSQL_CREDENTIALS }) + +test.group('Mysql driver', (group) => { + setupTeardownHooks(db, group) + registerStoreTestSuite({ + test, + createStore: () => createKnexStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/knex/postgres.spec.ts b/packages/verrou/tests/drivers/knex/postgres.spec.ts new file mode 100644 index 0000000..80d745c --- /dev/null +++ b/packages/verrou/tests/drivers/knex/postgres.spec.ts @@ -0,0 +1,16 @@ +import knex from 'knex' +import { test } from '@japa/runner' + +import { createKnexStore, setupTeardownHooks } from './helpers.js' +import { registerStoreTestSuite } from '../../../src/test_suite.js' +import { POSTGRES_CREDENTIALS } from '../../../test_helpers/index.js' + +const db = knex({ client: 'pg', connection: POSTGRES_CREDENTIALS }) + +test.group('Postgres Driver', (group) => { + setupTeardownHooks(db, group) + registerStoreTestSuite({ + test, + createStore: () => createKnexStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/knex/sqlite.spec.ts b/packages/verrou/tests/drivers/knex/sqlite.spec.ts new file mode 100644 index 0000000..cb080cb --- /dev/null +++ b/packages/verrou/tests/drivers/knex/sqlite.spec.ts @@ -0,0 +1,19 @@ +import knex from 'knex' +import { test } from '@japa/runner' + +import { createKnexStore, setupTeardownHooks } from './helpers.js' +import { registerStoreTestSuite } from '../../../src/test_suite.js' + +const db = knex({ + client: 'sqlite3', + connection: { filename: './cache.sqlite3' }, + useNullAsDefault: true, +}) + +test.group('Sqlite driver', (group) => { + setupTeardownHooks(db, group) + registerStoreTestSuite({ + test, + createStore: () => createKnexStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/kysely/helpers.ts b/packages/verrou/tests/drivers/kysely/helpers.ts new file mode 100644 index 0000000..4603e8b --- /dev/null +++ b/packages/verrou/tests/drivers/kysely/helpers.ts @@ -0,0 +1,24 @@ +import { sql, type Kysely } from 'kysely' +import type { Group } from '@japa/runner/core' + +import { KyselyAdapter } from '../../../src/drivers/kysely.js' +import { DatabaseStore } from '../../../src/drivers/database.js' +import type { KyselyOptions } from '../../../src/types/drivers.js' + +export function setupTeardownHooks(group: Group, db: Kysely) { + group.each.teardown(async () => { + sql`DELETE FROM verrou`.execute(db).catch((err) => { + console.error(err) + }) + }) + + group.teardown(async () => { + await db.schema.dropTable('verrou').ifExists().execute() + await db.destroy() + }) +} + +export function createKyselyStore(options: KyselyOptions) { + const adapter = new KyselyAdapter(options.connection) + return new DatabaseStore(adapter, options) +} diff --git a/packages/verrou/tests/drivers/kysely/mysql.spec.ts b/packages/verrou/tests/drivers/kysely/mysql.spec.ts new file mode 100644 index 0000000..b0ab3f7 --- /dev/null +++ b/packages/verrou/tests/drivers/kysely/mysql.spec.ts @@ -0,0 +1,19 @@ +import { test } from '@japa/runner' +import { createPool } from 'mysql2' +import { Kysely, MysqlDialect } from 'kysely' + +import { MYSQL_CREDENTIALS } from '../../../test_helpers/index.js' +import { registerStoreTestSuite } from '../../../src/test_suite.js' +import { createKyselyStore, setupTeardownHooks } from './helpers.js' + +const db = new Kysely({ + dialect: new MysqlDialect({ pool: createPool(MYSQL_CREDENTIALS) }), +}) + +test.group('Kysely | Mysql driver', (group) => { + setupTeardownHooks(group, db) + registerStoreTestSuite({ + test, + createStore: () => createKyselyStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/kysely/postgres.spec.ts b/packages/verrou/tests/drivers/kysely/postgres.spec.ts new file mode 100644 index 0000000..3388064 --- /dev/null +++ b/packages/verrou/tests/drivers/kysely/postgres.spec.ts @@ -0,0 +1,19 @@ +import pg from 'pg' +import { test } from '@japa/runner' +import { Kysely, PostgresDialect } from 'kysely' + +import { registerStoreTestSuite } from '../../../src/test_suite.js' +import { createKyselyStore, setupTeardownHooks } from './helpers.js' +import { POSTGRES_CREDENTIALS } from '../../../test_helpers/index.js' + +const db = new Kysely({ + dialect: new PostgresDialect({ pool: new pg.Pool(POSTGRES_CREDENTIALS) }), +}) + +test.group('Kysely | Postgres Driver', (group) => { + setupTeardownHooks(group, db) + registerStoreTestSuite({ + test, + createStore: () => createKyselyStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/kysely/sqlite.spec.ts b/packages/verrou/tests/drivers/kysely/sqlite.spec.ts new file mode 100644 index 0000000..1f4aa07 --- /dev/null +++ b/packages/verrou/tests/drivers/kysely/sqlite.spec.ts @@ -0,0 +1,20 @@ +import { test } from '@japa/runner' +import * as SQLite from 'better-sqlite3' +import { Kysely, SqliteDialect } from 'kysely' + +import { registerStoreTestSuite } from '../../../src/test_suite.js' +import { createKyselyStore, setupTeardownHooks } from './helpers.js' + +const db = new Kysely({ + dialect: new SqliteDialect({ + database: new SQLite.default('./cache.sqlite3'), + }), +}) + +test.group('Kysely | Sqlite Driver', (group) => { + setupTeardownHooks(group, db) + registerStoreTestSuite({ + test, + createStore: () => createKyselyStore({ connection: db }), + }) +}) diff --git a/packages/verrou/tests/drivers/memory.spec.ts b/packages/verrou/tests/drivers/memory.spec.ts index a48ea51..f2f92fc 100644 --- a/packages/verrou/tests/drivers/memory.spec.ts +++ b/packages/verrou/tests/drivers/memory.spec.ts @@ -6,7 +6,6 @@ import { registerStoreTestSuite } from '../../src/test_suite.js' test.group('Memory Store', () => { registerStoreTestSuite({ test, - config: undefined, - store: MemoryStore, + createStore: () => new MemoryStore(), }) }) diff --git a/packages/verrou/tests/drivers/mysql.spec.ts b/packages/verrou/tests/drivers/mysql.spec.ts deleted file mode 100644 index fb56a99..0000000 --- a/packages/verrou/tests/drivers/mysql.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import knex from 'knex' -import { test } from '@japa/runner' - -import { DatabaseStore } from '../../src/drivers/database.js' -import { registerStoreTestSuite } from '../../src/test_suite.js' -import { configureDatabaseGroupHooks } from '../../test_helpers/index.js' - -const db = knex({ - client: 'mysql2', - connection: { user: 'root', password: 'root', database: 'mysql', port: 3306 }, -}) - -test.group('Mysql driver', (group) => { - configureDatabaseGroupHooks(db, group) - registerStoreTestSuite({ - test, - config: { dialect: 'mysql2', connection: db }, - store: DatabaseStore, - }) -}) diff --git a/packages/verrou/tests/drivers/postgres.spec.ts b/packages/verrou/tests/drivers/postgres.spec.ts deleted file mode 100644 index 5935af9..0000000 --- a/packages/verrou/tests/drivers/postgres.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import knex from 'knex' -import { test } from '@japa/runner' - -import { DatabaseStore } from '../../src/drivers/database.js' -import { registerStoreTestSuite } from '../../src/test_suite.js' -import { configureDatabaseGroupHooks } from '../../test_helpers/index.js' - -const db = knex({ - client: 'pg', - connection: { user: 'postgres', password: 'postgres' }, -}) - -test.group('Postgres Driver', (group) => { - configureDatabaseGroupHooks(db, group) - registerStoreTestSuite({ - test, - store: DatabaseStore, - config: { dialect: 'pg', connection: db }, - }) -}) diff --git a/packages/verrou/tests/drivers/redis.spec.ts b/packages/verrou/tests/drivers/redis.spec.ts index 221c938..081faf0 100644 --- a/packages/verrou/tests/drivers/redis.spec.ts +++ b/packages/verrou/tests/drivers/redis.spec.ts @@ -18,8 +18,7 @@ test.group('Redis Driver', (group) => { registerStoreTestSuite({ test, - config: { connection: ioredis }, - store: RedisStore, + createStore: () => new RedisStore({ connection: ioredis }), }) test('null ttl', async ({ assert }) => { diff --git a/packages/verrou/tests/drivers/sqlite.spec.ts b/packages/verrou/tests/drivers/sqlite.spec.ts deleted file mode 100644 index 76b6163..0000000 --- a/packages/verrou/tests/drivers/sqlite.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import knex from 'knex' -import { test } from '@japa/runner' - -import { DatabaseStore } from '../../src/drivers/database.js' -import { registerStoreTestSuite } from '../../src/test_suite.js' -import { configureDatabaseGroupHooks } from '../../test_helpers/index.js' - -const db = knex({ - client: 'sqlite3', - connection: { filename: './cache.sqlite3' }, - useNullAsDefault: true, -}) - -test.group('Sqlite driver', (group) => { - configureDatabaseGroupHooks(db, group) - registerStoreTestSuite({ - test, - config: { dialect: 'sqlite3', connection: db }, - store: DatabaseStore, - }) -}) diff --git a/playground/package.json b/playground/package.json index 66da898..dc5eba5 100644 --- a/playground/package.json +++ b/playground/package.json @@ -3,6 +3,7 @@ "type": "module", "dependencies": { "@verrou/core": "workspace:*", + "ioredis": "^5.3.2", "pino": "^8.17.2" }, "devDependencies": { diff --git a/playground/src/index.ts b/playground/src/index.ts index 8599801..0f7c57c 100644 --- a/playground/src/index.ts +++ b/playground/src/index.ts @@ -1,4 +1,5 @@ import pino from 'pino' +import { Redis } from 'ioredis' import { Verrou } from '@verrou/core' import { setTimeout } from 'node:timers/promises' import { redisStore } from '@verrou/core/drivers/redis' @@ -8,18 +9,13 @@ const logger = pino.default({ level: 'debug', transport: { target: 'pino-pretty' logger.info('Hello world') +const ioredis = new Redis({ host: 'localhost', port: 6379 }) const verrou = new Verrou({ logger, default: 'redis', stores: { - memory: { - driver: memoryStore(), - }, - redis: { - driver: redisStore({ - connection: { host: 'localhost', port: 6379 }, - }), - }, + memory: { driver: memoryStore() }, + redis: { driver: redisStore({ connection: ioredis }) }, }, }) @@ -70,4 +66,4 @@ await Promise.all([ purchaseProduct('123', 1, 'CustomerB'), ]) -await verrou.disconnectAll() +await ioredis.quit() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b972e2..85fc9ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,15 +160,27 @@ importers: '@aws-sdk/client-dynamodb': specifier: ^3.529.1 version: 3.529.1 + '@types/better-sqlite3': + specifier: ^7.6.9 + version: 7.6.9 + '@types/pg': + specifier: ^8.11.2 + version: 8.11.2 '@types/proper-lockfile': specifier: ^4.1.4 version: 4.1.4 + better-sqlite3: + specifier: ^9.4.3 + version: 9.4.3 ioredis: specifier: ^5.3.2 version: 5.3.2 knex: specifier: ^3.1.0 - version: 3.1.0(mysql2@3.9.2)(pg@8.11.3)(sqlite3@5.1.7) + version: 3.1.0(better-sqlite3@9.4.3)(mysql2@3.9.2)(pg@8.11.3)(sqlite3@5.1.7) + kysely: + specifier: ^0.27.3 + version: 0.27.3 mysql2: specifier: ^3.9.2 version: 3.9.2 @@ -184,6 +196,9 @@ importers: '@verrou/core': specifier: workspace:* version: link:../packages/verrou + ioredis: + specifier: ^5.3.2 + version: 5.3.2 pino: specifier: ^8.17.2 version: 8.17.2 @@ -1626,7 +1641,6 @@ packages: /@ioredis/commands@1.2.0: resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - dev: true /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -2813,6 +2827,12 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@types/better-sqlite3@7.6.9: + resolution: {integrity: sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==} + dependencies: + '@types/node': 20.11.25 + dev: true + /@types/bytes@3.1.4: resolution: {integrity: sha512-A0uYgOj3zNc4hNjHc5lYUfJQ/HVyBXiUMKdXd7ysclaE6k9oJdavQzODHuwjpUu2/boCP8afjQYi8z/GtvNCWA==} @@ -2904,6 +2924,14 @@ packages: resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} dev: true + /@types/pg@8.11.2: + resolution: {integrity: sha512-G2Mjygf2jFMU/9hCaTYxJrwdObdcnuQde1gndooZSOHsNSaCehAuwc7EIuSA34Do8Jx2yZ19KtvW8P0j4EuUXw==} + dependencies: + '@types/node': 20.11.25 + pg-protocol: 1.6.0 + pg-types: 4.0.2 + dev: true + /@types/pluralize@0.0.33: resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} @@ -3473,6 +3501,14 @@ packages: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} dev: true + /better-sqlite3@9.4.3: + resolution: {integrity: sha512-ud0bTmD9O3uWJGuXDltyj3R47Nz0OHX8iqPOT5PMspGqlu/qQFn+5S2eFBUCrySpavTjFXbi4EgrfVvPAHlImw==} + requiresBuild: true + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.2 + dev: true + /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} @@ -3929,7 +3965,6 @@ packages: /cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - dev: true /code-block-writer@12.0.0: resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} @@ -4223,7 +4258,6 @@ packages: optional: true dependencies: ms: 2.1.2 - dev: true /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -4406,7 +4440,6 @@ packages: /denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - dev: true /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} @@ -6099,7 +6132,6 @@ packages: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - dev: true /ip@1.1.8: resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==} @@ -6706,7 +6738,7 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - /knex@3.1.0(mysql2@3.9.2)(pg@8.11.3)(sqlite3@5.1.7): + /knex@3.1.0(better-sqlite3@9.4.3)(mysql2@3.9.2)(pg@8.11.3)(sqlite3@5.1.7): resolution: {integrity: sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==} engines: {node: '>=16'} hasBin: true @@ -6734,6 +6766,7 @@ packages: tedious: optional: true dependencies: + better-sqlite3: 9.4.3 colorette: 2.0.19 commander: 10.0.1 debug: 4.3.4 @@ -6755,6 +6788,11 @@ packages: - supports-color dev: true + /kysely@0.27.3: + resolution: {integrity: sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA==} + engines: {node: '>=14.0.0'} + dev: true + /latest-version@7.0.0: resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} engines: {node: '>=14.16'} @@ -6819,7 +6857,6 @@ packages: /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: true /lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} @@ -6835,7 +6872,6 @@ packages: /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - dev: true /lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} @@ -7674,7 +7710,6 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7903,6 +7938,10 @@ packages: resolution: {integrity: sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==} dev: true + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: true + /on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -8285,6 +8324,11 @@ packages: engines: {node: '>=4.0.0'} dev: true + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: true + /pg-pool@3.6.1(pg@8.11.3): resolution: {integrity: sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==} peerDependencies: @@ -8308,6 +8352,19 @@ packages: postgres-interval: 1.2.0 dev: true + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: true + /pg@8.11.3: resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==} engines: {node: '>= 8.0.0'} @@ -8454,16 +8511,33 @@ packages: engines: {node: '>=4'} dev: true + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: true + /postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} dev: true + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: true + /postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} dev: true + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: true + /postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} @@ -8471,6 +8545,15 @@ packages: xtend: 4.0.2 dev: true + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: true + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: true + /preact@10.19.3: resolution: {integrity: sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==} dev: true @@ -8810,14 +8893,12 @@ packages: /redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - dev: true /redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} dependencies: redis-errors: 1.2.0 - dev: true /reflect-metadata@0.2.1: resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} @@ -9522,7 +9603,6 @@ packages: /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - dev: true /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}