Skip to content

Commit c3a5691

Browse files
committed
feat: add DatabaseStore + adapter system
1 parent d905df0 commit c3a5691

17 files changed

Lines changed: 314 additions & 297 deletions
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { E_LOCK_NOT_OWNED } from '../errors.js'
2+
import type { DatabaseAdapter, DatabaseOptions } from '../types/drivers.js'
3+
4+
/**
5+
* A store that uses a database to store locks
6+
*
7+
* You should provide an adapter that will handle the database interactions
8+
*/
9+
export class DatabaseStore {
10+
#adapter: DatabaseAdapter
11+
#initialized: Promise<void>
12+
13+
constructor(adapter: DatabaseAdapter, config: DatabaseOptions) {
14+
this.#adapter = adapter
15+
this.#adapter.setTableName(config.tableName || 'verrou')
16+
17+
if (config.autoCreateTable !== false) {
18+
this.#initialized = this.#adapter.createTableIfNotExists()
19+
} else {
20+
this.#initialized = Promise.resolve()
21+
}
22+
}
23+
24+
/**
25+
* Compute the expiration date based on the provided TTL
26+
*/
27+
#computeExpiresAt(ttl: number | null) {
28+
if (ttl) return Date.now() + ttl
29+
return null
30+
}
31+
32+
/**
33+
* Get the current owner of given lock key
34+
*/
35+
async #getCurrentOwner(key: string) {
36+
await this.#initialized
37+
const lock = await this.#adapter.getLock(key)
38+
39+
return lock?.owner
40+
}
41+
42+
/**
43+
* Save the lock in the store if not already locked by another owner
44+
*
45+
* We basically rely on primary key constraint to ensure the lock is
46+
* unique.
47+
*
48+
* If the lock already exists, we check if it's expired. If it is, we
49+
* update it with the new owner and expiration date.
50+
*/
51+
async save(key: string, owner: string, ttl: number | null) {
52+
await this.#initialized
53+
try {
54+
await this.#adapter.insertLock({ key, owner, expiration: this.#computeExpiresAt(ttl) })
55+
return true
56+
} catch (error) {
57+
const updatedRows = await this.#adapter.acquireLock({
58+
key,
59+
owner,
60+
expiration: this.#computeExpiresAt(ttl),
61+
})
62+
63+
return updatedRows > 0
64+
}
65+
}
66+
67+
/**
68+
* Delete the lock from the store if it is owned by the owner
69+
* Otherwise throws a E_LOCK_NOT_OWNED error
70+
*/
71+
async delete(key: string, owner: string): Promise<void> {
72+
const currentOwner = await this.#getCurrentOwner(key)
73+
if (currentOwner !== owner) throw new E_LOCK_NOT_OWNED()
74+
75+
await this.#adapter.deleteLock(key, owner)
76+
}
77+
78+
/**
79+
* Force delete the lock from the store. No check is made on the owner
80+
*/
81+
async forceDelete(key: string) {
82+
await this.#adapter.deleteLock(key)
83+
}
84+
85+
/**
86+
* Extend the lock expiration. Throws an error if the lock is not owned by the owner
87+
* Duration is in milliseconds
88+
*/
89+
async extend(key: string, owner: string, duration: number) {
90+
await this.#initialized
91+
const updated = await this.#adapter.extendLock(key, owner, duration)
92+
93+
if (updated === 0) throw new E_LOCK_NOT_OWNED()
94+
}
95+
96+
/**
97+
* Check if the lock exists
98+
*/
99+
async exists(key: string) {
100+
await this.#initialized
101+
const lock = await this.#adapter.getLock(key)
102+
103+
if (!lock) return false
104+
if (lock.expiration === null) return true
105+
106+
return lock.expiration > Date.now()
107+
}
108+
109+
async disconnect() {}
110+
}
Lines changed: 42 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,37 @@
11
import type { Knex } from 'knex'
22

3-
import { E_LOCK_NOT_OWNED } from '../errors.js'
4-
import type { KnexStoreOptions, LockStore } from '../types/main.js'
3+
import { DatabaseStore } from './database.js'
4+
import type { DatabaseAdapter, KnexStoreOptions } from '../types/main.js'
55

66
/**
7-
* Create a new database store
7+
* Create a new knex store
88
*/
99
export function knexStore(config: KnexStoreOptions) {
10-
return { config, factory: () => new KnexStore(config) }
10+
return {
11+
config,
12+
factory: () => {
13+
const adapter = new KnexAdapter(config.connection)
14+
return new DatabaseStore(adapter, config)
15+
},
16+
}
1117
}
1218

13-
export class KnexStore implements LockStore {
14-
/**
15-
* Knex connection instance
16-
*/
19+
/**
20+
* Knex adapter for the DatabaseStore
21+
*/
22+
export class KnexAdapter implements DatabaseAdapter {
1723
#connection: Knex
24+
#tableName!: string
1825

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>
26+
constructor(connection: Knex) {
27+
this.#connection = connection
28+
}
2829

29-
constructor(config: KnexStoreOptions) {
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-
}
30+
setTableName(tableName: string) {
31+
this.#tableName = tableName
3732
}
3833

39-
/**
40-
* Create the locks table if it doesn't exist
41-
*/
42-
async #createTableIfNotExists() {
34+
async createTableIfNotExists() {
4335
const hasTable = await this.#connection.schema.hasTable(this.#tableName)
4436
if (hasTable) return
4537

@@ -50,109 +42,47 @@ export class KnexStore implements LockStore {
5042
})
5143
}
5244

53-
/**
54-
* Compute the expiration date based on the provided TTL
55-
*/
56-
#computeExpiresAt(ttl: number | null) {
57-
if (ttl) return Date.now() + ttl
58-
return null
45+
async insertLock(lock: { key: string; owner: string; expiration: number | null }) {
46+
await this.#connection.table(this.#tableName).insert(lock)
5947
}
6048

61-
/**
62-
* Get the current owner of given lock key
63-
*/
64-
async #getCurrentOwner(key: string) {
65-
await this.#initialized
66-
const result = await this.#connection
49+
async acquireLock(lock: { key: string; owner: string; expiration: number | null }) {
50+
const updated = await this.#connection
6751
.table(this.#tableName)
68-
.where('key', key)
69-
.select('owner')
70-
.first()
52+
.where('key', lock.key)
53+
.where('expiration', '<=', Date.now())
54+
.update({ owner: lock.owner, expiration: lock.expiration })
7155

72-
return result?.owner
56+
return updated
7357
}
7458

75-
/**
76-
* Save the lock in the store if not already locked by another owner
77-
*
78-
* We basically rely on primary key constraint to ensure the lock is
79-
* unique.
80-
*
81-
* If the lock already exists, we check if it's expired. If it is, we
82-
* update it with the new owner and expiration date.
83-
*/
84-
async save(key: string, owner: string, ttl: number | null) {
85-
try {
86-
await this.#initialized
87-
await this.#connection
88-
.table(this.#tableName)
89-
.insert({ key, owner, expiration: this.#computeExpiresAt(ttl) })
90-
91-
return true
92-
} catch (error) {
93-
const updated = await this.#connection
94-
.table(this.#tableName)
95-
.where('key', key)
96-
.where('expiration', '<=', Date.now())
97-
.update({ owner, expiration: this.#computeExpiresAt(ttl) })
98-
99-
return updated >= 1
100-
}
101-
}
102-
103-
/**
104-
* Delete the lock from the store if it is owned by the owner
105-
* Otherwise throws a E_LOCK_NOT_OWNED error
106-
*/
107-
async delete(key: string, owner: string): Promise<void> {
108-
const currentOwner = await this.#getCurrentOwner(key)
109-
if (currentOwner !== owner) throw new E_LOCK_NOT_OWNED()
110-
111-
await this.#connection.table(this.#tableName).where('key', key).where('owner', owner).delete()
112-
}
113-
114-
/**
115-
* Force delete the lock from the store. No check is made on the owner
116-
*/
117-
async forceDelete(key: string) {
118-
await this.#connection.table(this.#tableName).where('key', key).delete()
59+
async deleteLock(key: string, owner?: string | undefined) {
60+
await this.#connection
61+
.table(this.#tableName)
62+
.where('key', key)
63+
.modify((query) => {
64+
if (owner) query.where('owner', owner)
65+
})
66+
.delete()
11967
}
12068

121-
/**
122-
* Extend the lock expiration. Throws an error if the lock is not owned by the owner
123-
* Duration is in milliseconds
124-
*/
125-
async extend(key: string, owner: string, duration: number) {
69+
async extendLock(key: string, owner: string, duration: number) {
12670
const updated = await this.#connection
12771
.table(this.#tableName)
12872
.where('key', key)
12973
.where('owner', owner)
13074
.update({ expiration: Date.now() + duration })
13175

132-
if (updated === 0) throw new E_LOCK_NOT_OWNED()
76+
return updated
13377
}
13478

135-
/**
136-
* Check if the lock exists
137-
*/
138-
async exists(key: string) {
139-
await this.#initialized
79+
async getLock(key: string) {
14080
const result = await this.#connection
14181
.table(this.#tableName)
14282
.where('key', key)
143-
.select('expiration')
83+
.select(['owner', 'expiration'])
14484
.first()
14585

146-
if (!result) return false
147-
if (result.expiration === null) return true
148-
149-
return result.expiration > Date.now()
150-
}
151-
152-
/**
153-
* Disconnect knex connection
154-
*/
155-
disconnect() {
156-
return this.#connection.destroy()
86+
return result
15787
}
15888
}

0 commit comments

Comments
 (0)