Skip to content

Commit 7b4d0c8

Browse files
committed
feat: add kysely adapter
1 parent d5142f1 commit 7b4d0c8

18 files changed

Lines changed: 459 additions & 109 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- 🔒 Easy usage
1212
- 🔄 Multiple drivers (Redis, Postgres, MySQL, Sqlite, In-Memory and others)
13+
- 📦 Multiple database adapters ( Knex, Kysely, Drizzle ...)
1314
- 🔑 Customizable named locks
1415
- 🌐 Consistent API across all drivers
1516
- 🧪 Easy testing by switching to an in-memory driver

packages/verrou/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,13 @@
4545
},
4646
"devDependencies": {
4747
"@aws-sdk/client-dynamodb": "^3.529.1",
48+
"@types/better-sqlite3": "^7.6.9",
49+
"@types/pg": "^8.11.2",
4850
"@types/proper-lockfile": "^4.1.4",
51+
"better-sqlite3": "^9.4.3",
4952
"ioredis": "^5.3.2",
5053
"knex": "^3.1.0",
54+
"kysely": "^0.27.3",
5155
"mysql2": "^3.9.2",
5256
"pg": "^8.11.3",
5357
"sqlite3": "^5.1.7"
Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import knex, { type Knex } from 'knex'
1+
import type { Knex } from 'knex'
22

33
import { E_LOCK_NOT_OWNED } from '../errors.js'
4-
import type { DatabaseStoreOptions, LockStore } from '../types/main.js'
4+
import type { KnexStoreOptions, LockStore } from '../types/main.js'
55

66
/**
77
* Create a new database store
88
*/
9-
export function databaseStore(config: DatabaseStoreOptions) {
10-
return { config, factory: () => new DatabaseStore(config) }
9+
export function knexStore(config: KnexStoreOptions) {
10+
return { config, factory: () => new KnexStore(config) }
1111
}
1212

13-
export class DatabaseStore implements LockStore {
13+
export class KnexStore implements LockStore {
1414
/**
1515
* Knex connection instance
1616
*/
@@ -26,8 +26,8 @@ export class DatabaseStore implements LockStore {
2626
*/
2727
#initialized: Promise<void>
2828

29-
constructor(config: DatabaseStoreOptions) {
30-
this.#connection = this.#createConnection(config)
29+
constructor(config: KnexStoreOptions) {
30+
this.#connection = config.connection
3131
this.#tableName = config.tableName || this.#tableName
3232
if (config.autoCreateTable !== false) {
3333
this.#initialized = this.#createTableIfNotExists()
@@ -37,27 +37,7 @@ export class DatabaseStore implements LockStore {
3737
}
3838

3939
/**
40-
* Create or reuse a Knex connection instance
41-
*/
42-
#createConnection(config: DatabaseStoreOptions) {
43-
if (typeof config.connection === 'string') {
44-
return knex({ client: config.dialect, connection: config.connection, useNullAsDefault: true })
45-
}
46-
47-
/**
48-
* This looks hacky. Maybe we can find a better way to do this?
49-
* We check if config.connection is a Knex object. If it is, we
50-
* return it as is. If it's not, we create a new Knex object
51-
*/
52-
if ('with' in config.connection!) {
53-
return config.connection
54-
}
55-
56-
return knex({ client: config.dialect, connection: config.connection, useNullAsDefault: true })
57-
}
58-
59-
/**
60-
* Create the cache table if it doesn't exist
40+
* Create the locks table if it doesn't exist
6141
*/
6242
async #createTableIfNotExists() {
6343
const hasTable = await this.#connection.schema.hasTable(this.#tableName)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { Kysely } from 'kysely'
2+
3+
import { E_LOCK_NOT_OWNED } from '../errors.js'
4+
import type { KyselyOptions, LockStore } from '../types/main.js'
5+
6+
/**
7+
* Create a new knex store
8+
*/
9+
export function knexStore(config: KyselyOptions) {
10+
return { config, factory: () => new KyselyStore(config) }
11+
}
12+
13+
export class KyselyStore implements LockStore {
14+
/**
15+
* Knex connection instance
16+
*/
17+
#connection: Kysely<any>
18+
19+
/**
20+
* The name of the table used to store locks
21+
*/
22+
#tableName = 'verrou'
23+
24+
/**
25+
* A promise that resolves when the table is created
26+
*/
27+
#initialized: Promise<void>
28+
29+
constructor(config: KyselyOptions) {
30+
this.#connection = config.connection
31+
this.#tableName = config.tableName || this.#tableName
32+
if (config.autoCreateTable !== false) {
33+
this.#initialized = this.#createTableIfNotExists()
34+
} else {
35+
this.#initialized = Promise.resolve()
36+
}
37+
}
38+
39+
/**
40+
* Create the locks table if it doesn't exist
41+
*/
42+
async #createTableIfNotExists() {
43+
await this.#connection.schema
44+
.createTable(this.#tableName)
45+
.addColumn('key', 'varchar(255)', (col) => col.primaryKey().notNull())
46+
.addColumn('owner', 'varchar(255)', (col) => col.notNull())
47+
.addColumn('expiration', 'bigint')
48+
.ifNotExists()
49+
.execute()
50+
}
51+
52+
/**
53+
* Compute the expiration date based on the provided TTL
54+
*/
55+
#computeExpiresAt(ttl: number | null) {
56+
if (ttl) return Date.now() + ttl
57+
return null
58+
}
59+
60+
/**
61+
* Get the current owner of given lock key
62+
*/
63+
async #getCurrentOwner(key: string) {
64+
await this.#initialized
65+
const result = await this.#connection
66+
.selectFrom(this.#tableName)
67+
.where('key', '=', key)
68+
.select('owner')
69+
.executeTakeFirst()
70+
71+
return result?.owner
72+
}
73+
74+
/**
75+
* Save the lock in the store if not already locked by another owner
76+
*
77+
* We basically rely on primary key constraint to ensure the lock is
78+
* unique.
79+
*
80+
* If the lock already exists, we check if it's expired. If it is, we
81+
* update it with the new owner and expiration date.
82+
*/
83+
async save(key: string, owner: string, ttl: number | null) {
84+
try {
85+
await this.#initialized
86+
await this.#connection
87+
.insertInto(this.#tableName)
88+
.values({ key, owner, expiration: this.#computeExpiresAt(ttl) })
89+
.execute()
90+
91+
return true
92+
} catch (error) {
93+
const updated = await this.#connection
94+
.updateTable(this.#tableName)
95+
.where('key', '=', key)
96+
.where('expiration', '<=', Date.now())
97+
.set({ owner, expiration: this.#computeExpiresAt(ttl) })
98+
.executeTakeFirst()
99+
100+
return updated.numUpdatedRows >= BigInt(1)
101+
}
102+
}
103+
104+
/**
105+
* Delete the lock from the store if it is owned by the owner
106+
* Otherwise throws a E_LOCK_NOT_OWNED error
107+
*/
108+
async delete(key: string, owner: string): Promise<void> {
109+
const currentOwner = await this.#getCurrentOwner(key)
110+
if (currentOwner !== owner) throw new E_LOCK_NOT_OWNED()
111+
112+
await this.#connection
113+
.deleteFrom(this.#tableName)
114+
.where('key', '=', key)
115+
.where('owner', '=', owner)
116+
.execute()
117+
}
118+
119+
/**
120+
* Force delete the lock from the store. No check is made on the owner
121+
*/
122+
async forceDelete(key: string) {
123+
await this.#connection.deleteFrom(this.#tableName).where('key', '=', key).execute()
124+
}
125+
126+
/**
127+
* Extend the lock expiration. Throws an error if the lock is not owned by the owner
128+
* Duration is in milliseconds
129+
*/
130+
async extend(key: string, owner: string, duration: number) {
131+
const updated = await this.#connection
132+
.updateTable(this.#tableName)
133+
.where('key', '=', key)
134+
.where('owner', '=', owner)
135+
.set({ expiration: Date.now() + duration })
136+
.executeTakeFirst()
137+
138+
if (updated.numUpdatedRows === BigInt(0)) throw new E_LOCK_NOT_OWNED()
139+
}
140+
141+
/**
142+
* Check if the lock exists
143+
*/
144+
async exists(key: string) {
145+
await this.#initialized
146+
const result = await this.#connection
147+
.selectFrom(this.#tableName)
148+
.where('key', '=', key)
149+
.select('expiration')
150+
.executeTakeFirst()
151+
152+
if (!result) return false
153+
if (result.expiration === null) return true
154+
155+
return result.expiration > Date.now()
156+
}
157+
158+
/**
159+
* Disconnect kysely connection
160+
*/
161+
disconnect() {
162+
return this.#connection.destroy()
163+
}
164+
}

packages/verrou/src/types/drivers.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
1-
import { type Knex } from 'knex'
1+
import type { Knex } from 'knex'
2+
import type { Kysely } from 'kysely'
23
import type { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'
34
import type { RedisOptions as IoRedisOptions, Redis as IoRedis } from 'ioredis'
45

56
export type DialectName = 'pg' | 'mysql2' | 'better-sqlite3' | 'sqlite3'
67

7-
export type DatabaseStoreOptions = {
8-
/**
9-
* The database dialect
10-
*/
11-
dialect: DialectName
12-
13-
/**
14-
* The database connection
15-
*/
16-
connection: Knex | Knex.Config['connection']
17-
8+
/**
9+
* Common options for database stores
10+
*/
11+
export interface DatabaseOptions {
1812
/**
1913
* The table name to use ( to store the locks )
14+
*
15+
* @default 'verrou'
2016
*/
2117
tableName?: string
2218

@@ -28,13 +24,44 @@ export type DatabaseStoreOptions = {
2824
autoCreateTable?: boolean
2925
}
3026

27+
/**
28+
* Options for the Knex store
29+
*/
30+
export interface KnexStoreOptions extends DatabaseOptions {
31+
/**
32+
* The database dialect
33+
*/
34+
dialect: DialectName
35+
36+
/**
37+
* The Knex instance
38+
*/
39+
connection: Knex
40+
}
41+
42+
/**
43+
* Options for the Kysely store
44+
*/
45+
export interface KyselyOptions extends DatabaseOptions {
46+
/**
47+
* The Kysely instance
48+
*/
49+
connection: Kysely<any>
50+
}
51+
52+
/**
53+
* Options for the Redis store
54+
*/
3155
export type RedisStoreOptions = {
3256
/**
3357
* The Redis connection
3458
*/
3559
connection: IoRedis | IoRedisOptions
3660
}
3761

62+
/**
63+
* Options for the DynamoDB store
64+
*/
3865
export type DynamoDbOptions = {
3966
/**
4067
* DynamoDB table name to use.
Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
1-
import type { Knex } from 'knex'
2-
import type { Group } from '@japa/runner/core'
3-
41
export const BASE_URL = new URL('./tmp/', import.meta.url)
52

63
export const REDIS_CREDENTIALS = {
74
host: process.env.REDIS_HOST!,
85
port: Number(process.env.REDIS_PORT),
96
}
107

11-
export function configureDatabaseGroupHooks(db: Knex, group: Group) {
12-
group.each.teardown(async () => {
13-
const exists = await db.schema.hasTable('verrou')
14-
if (!exists) return
15-
16-
await db.table('verrou').truncate()
17-
})
18-
19-
group.teardown(async () => {
20-
const exists = await db.schema.hasTable('verrou')
21-
if (exists) await db.schema.dropTable('verrou')
8+
export const MYSQL_CREDENTIALS = {
9+
user: 'root',
10+
password: 'root',
11+
database: 'mysql',
12+
port: 3306,
13+
}
2214

23-
await db.destroy()
24-
})
15+
export const POSTGRES_CREDENTIALS = {
16+
user: 'postgres',
17+
password: 'postgres',
2518
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Knex } from 'knex'
2+
import type { Group } from '@japa/runner/core'
3+
4+
export function setupTeardownHooks(db: Knex, group: Group) {
5+
group.each.teardown(async () => {
6+
const exists = await db.schema.hasTable('verrou')
7+
if (!exists) return
8+
9+
await db.table('verrou').truncate()
10+
})
11+
12+
group.teardown(async () => {
13+
const exists = await db.schema.hasTable('verrou')
14+
if (exists) await db.schema.dropTable('verrou')
15+
16+
await db.destroy()
17+
})
18+
}

0 commit comments

Comments
 (0)