express-session-rethinkdb-esm 0.0.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +110 -16
- package/package.json +11 -3
- package/rdb-session.js +95 -73
- package/test/store.test.js +407 -0
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -4,28 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
Thank you to everyone writing `express-session` drivers out there for the boilerplate and inspiration.
|
|
6
6
|
|
|
7
|
-
**Note**: Sessions are not vacuumed by the adapter like some of the others out there (`setInterval` and friends). Pull requests welcome :)
|
|
8
|
-
|
|
9
7
|
## Installation
|
|
10
8
|
|
|
11
9
|
`npm install express-session-rethinkdb-esm --save`
|
|
12
10
|
|
|
13
11
|
## Constructor Options
|
|
14
12
|
|
|
15
|
-
`client`: **Required**
|
|
13
|
+
`client`: **Required** — RethinkDB import.
|
|
16
14
|
|
|
17
|
-
`
|
|
15
|
+
`connGetter`: **Required** — A function that returns the current RethinkDB connection. Called on every operation so the store always uses the live connection after a reconnect.
|
|
18
16
|
|
|
19
17
|
`table`: RethinkDB table to store sessions in (default: `sessions`).
|
|
20
18
|
|
|
21
19
|
`ttl`: Time in milliseconds to expire sessions (default: two weeks).
|
|
22
20
|
|
|
21
|
+
`gcProbability`: Probability (0–1) that each `set()` call triggers a background sweep deleting all expired sessions (default: `0.01`). Set to `0` to disable.
|
|
22
|
+
|
|
23
23
|
## Usage
|
|
24
24
|
|
|
25
25
|
```javascript
|
|
26
26
|
// rethinkdb
|
|
27
27
|
import r from 'rethinkdb'
|
|
28
|
-
|
|
28
|
+
let rc = await r.connect({ host })
|
|
29
29
|
|
|
30
30
|
// express
|
|
31
31
|
import express from 'express'
|
|
@@ -33,24 +33,118 @@ import session from 'express-session'
|
|
|
33
33
|
import _init_rdb_express_session_store from 'express-session-rethinkdb-esm'
|
|
34
34
|
|
|
35
35
|
const app = express()
|
|
36
|
-
const RethinkdbSessionStore =
|
|
36
|
+
const RethinkdbSessionStore = _init_rdb_express_session_store({ session })
|
|
37
37
|
|
|
38
38
|
app.use( session({
|
|
39
|
-
store: new RethinkdbSessionStore({ client: r,
|
|
40
|
-
})
|
|
39
|
+
store: new RethinkdbSessionStore({ client: r, connGetter: () => rc })
|
|
40
|
+
}) )
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
###
|
|
43
|
+
### connGetter and Reconnection
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Pass a getter function rather than a static reference so the store always uses the current live connection:
|
|
46
46
|
|
|
47
47
|
```javascript
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
let rc = await r.connect({ host })
|
|
49
|
+
|
|
50
|
+
// reconnect handler — rc is reassigned on reconnect
|
|
51
|
+
rc.on( 'close', async () => {
|
|
52
|
+
rc = await r.connect({ host })
|
|
53
|
+
} )
|
|
52
54
|
|
|
55
|
+
// store dereferences rc at call time — no manual patching needed
|
|
56
|
+
const store = new RethinkdbSessionStore({ client: r, connGetter: () => rc })
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Wait on Readiness
|
|
60
|
+
|
|
61
|
+
Use the `ready` property to wait for store initialization (table creation and index setup):
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
53
64
|
// Async/Await
|
|
54
65
|
await rethinkdbSessionStoreInstance.ready
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Session Cleanup
|
|
69
|
+
|
|
70
|
+
Expired sessions are cleaned up automatically:
|
|
71
|
+
|
|
72
|
+
- **Lazy deletion**: When `get()` finds an expired session, it deletes it in the background before returning `null`.
|
|
73
|
+
- **Probabilistic GC**: Each `set()` call has a `gcProbability` chance (default 1%) of sweeping all expired sessions. This handles abandoned sessions that are never read again.
|
|
74
|
+
|
|
75
|
+
No timers or manual vacuum calls are required for normal operation.
|
|
76
|
+
|
|
77
|
+
### `vacuum()` — Admin Escape Hatch
|
|
78
|
+
|
|
79
|
+
`vacuum()` immediately deletes all expired sessions. Useful as a one-off maintenance operation (e.g. after upgrading from a version that had the broken vacuum query):
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
await store.vacuum()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Running Tests
|
|
86
|
+
|
|
87
|
+
Integration tests run against a real RethinkDB instance. A dedicated `sessions_test` table is created and dropped automatically.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# start a throwaway RethinkDB
|
|
91
|
+
docker run --rm -p 28015:28015 rethinkdb:2.4.3
|
|
92
|
+
|
|
93
|
+
# install devDependencies and run tests
|
|
94
|
+
npm install
|
|
95
|
+
npm test
|
|
96
|
+
|
|
97
|
+
# custom host/port
|
|
98
|
+
RETHINKDB_HOST=myhost RETHINKDB_PORT=28015 npm test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## 1.0.0 Changelog
|
|
102
|
+
|
|
103
|
+
**`conn` replaced by `connGetter`**
|
|
104
|
+
|
|
105
|
+
Previously the constructor accepted a static connection reference. It now accepts a getter function that is called on every operation, so the store always uses the current live connection after a reconnect.
|
|
106
|
+
|
|
107
|
+
```javascript
|
|
108
|
+
// before
|
|
109
|
+
new RethinkdbSessionStore({ client: r, conn: rc })
|
|
110
|
+
|
|
111
|
+
// after
|
|
112
|
+
new RethinkdbSessionStore({ client: r, connGetter: () => rc })
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If you were manually patching the connection after reconnects (e.g. `store.conn = root_rc` in a reconnect handler), that workaround can be removed.
|
|
116
|
+
|
|
117
|
+
**`vacuum()` query fixed**
|
|
118
|
+
|
|
119
|
+
The previous implementation deleted sessions with `expires < (now - ttl)` instead of `expires < now`. Expired sessions were never actually cleaned up. The query now correctly deletes everything with an expiry in the past.
|
|
120
|
+
|
|
121
|
+
**`set()` and `touch()` now surface errors without a callback**
|
|
122
|
+
|
|
123
|
+
Previously, errors in `set()` and `touch()` were silently swallowed when called without a callback. They now throw, consistent with all other methods.
|
|
124
|
+
|
|
125
|
+
**`destroy()` no longer crashes without a callback**
|
|
126
|
+
|
|
127
|
+
Previously `destroy()` always called `cb( err )` unconditionally, throwing `TypeError: cb is not a function` if no callback was provided. Fixed.
|
|
128
|
+
|
|
129
|
+
**New: lazy deletion on `get()`**
|
|
130
|
+
|
|
131
|
+
When a session is found but has expired, it is deleted from the database in the background before returning `null`.
|
|
132
|
+
|
|
133
|
+
**New: probabilistic GC on `set()` (default 1%)**
|
|
134
|
+
|
|
135
|
+
On each `set()` call, there is a configurable probability (default `gcProbability: 0.01`) that a background sweep deletes all expired sessions. Set to `0` to disable.
|
|
136
|
+
|
|
137
|
+
**Initialization errors now propagate via EventEmitter**
|
|
138
|
+
|
|
139
|
+
Previously, if the DB was unreachable at startup, the constructor's internal `_init` call rejected silently (`UnhandledPromiseRejection`) and `store.ready` hung forever. Init errors now route through the standard Node.js error channel. Attach an `'error'` listener if you need to handle startup failures explicitly:
|
|
140
|
+
|
|
141
|
+
```javascript
|
|
142
|
+
store.on( 'error', err => {
|
|
143
|
+
console.error( 'Session store init failed:', err )
|
|
144
|
+
process.exit( 1 )
|
|
145
|
+
} )
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Unknown constructor options no longer clobber internals**
|
|
149
|
+
|
|
150
|
+
Previously all options were merged onto the instance via `Object.assign`, meaning any option key matching a class member (`_t`, `_init`, `ready`, `get`, `set`, etc.) would silently overwrite it. The constructor now extracts only the five known keys (`client`, `connGetter`, `table`, `ttl`, `gcProbability`). Unknown keys are ignored.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-session-rethinkdb-esm",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Robert Sirois",
|
|
6
6
|
"homepage": "https://github.com/rpsirois/express-session-rethinkdb-esm#readme",
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./rdb-session.js"
|
|
17
17
|
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/store.test.js"
|
|
23
|
+
},
|
|
18
24
|
"dependencies": {},
|
|
19
|
-
"devDependencies": {
|
|
20
|
-
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"rethinkdb": "^2.4.1",
|
|
27
|
+
"express-session": "^1.18.0"
|
|
28
|
+
}
|
|
21
29
|
}
|
package/rdb-session.js
CHANGED
|
@@ -1,36 +1,50 @@
|
|
|
1
|
+
function optionalCb( err, data, cb ) {
|
|
2
|
+
if ( cb ) return cb( err, data )
|
|
3
|
+
if ( err ) throw err
|
|
4
|
+
return data
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
const defaults = {
|
|
2
|
-
ttl: 1000 * 60 * 60 * 24 * 14
|
|
8
|
+
ttl: 1000 * 60 * 60 * 24 * 14, // 2 weeks
|
|
3
9
|
table: 'sessions',
|
|
10
|
+
gcProbability: 0.01,
|
|
4
11
|
}
|
|
5
12
|
|
|
6
|
-
let r
|
|
7
|
-
|
|
8
13
|
export default cfg => (
|
|
9
14
|
class RethinkdbSessionStore extends cfg.session.Store {
|
|
10
15
|
constructor( opt ) {
|
|
11
|
-
super(opt)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
super( opt )
|
|
17
|
+
// Extract known keys only — prevents opt properties from clobbering class internals
|
|
18
|
+
this.client = opt?.client
|
|
19
|
+
this.connGetter = opt?.connGetter
|
|
20
|
+
this.table = opt?.table ?? defaults.table
|
|
21
|
+
this.ttl = opt?.ttl ?? defaults.ttl
|
|
22
|
+
this.gcProbability = opt?.gcProbability ?? defaults.gcProbability
|
|
23
|
+
if ( !this.client ) throw new Error( 'RethinkdbSessionStore requires a client in setup options.' )
|
|
24
|
+
if ( typeof this.connGetter !== 'function' ) throw new Error( 'RethinkdbSessionStore requires a connGetter function in setup options.' )
|
|
15
25
|
let _p_then
|
|
16
26
|
this.ready = new Promise( then => _p_then = then )
|
|
17
|
-
|
|
27
|
+
// Forward init errors through EventEmitter so callers can attach an 'error' listener.
|
|
28
|
+
// Without this, a DB-unreachable startup causes UnhandledPromiseRejection and
|
|
29
|
+
// leaves this.ready hanging forever.
|
|
30
|
+
this._init( _p_then ).catch( err => this.emit( 'error', err ) )
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
getExpiry( cookie ) {
|
|
21
34
|
return cookie?.expires
|
|
22
|
-
? new Date(cookie.expires)
|
|
23
|
-
: new Date(Date.now() + this.ttl)
|
|
35
|
+
? new Date( cookie.expires )
|
|
36
|
+
: new Date( Date.now() + this.ttl )
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
async
|
|
27
|
-
r =
|
|
39
|
+
async _init( then ) {
|
|
40
|
+
const r = this.client
|
|
41
|
+
const rc = await this.connGetter()
|
|
28
42
|
let { table } = this
|
|
29
43
|
|
|
30
44
|
// table
|
|
31
45
|
const existing_tables = new Set( await r.tableList().run( rc ) )
|
|
32
46
|
|
|
33
|
-
if ( !existing_tables.has( table ) ) await r.tableCreate( table ).run(rc)
|
|
47
|
+
if ( !existing_tables.has( table ) ) await r.tableCreate( table ).run( rc )
|
|
34
48
|
|
|
35
49
|
const _t = this._t = r.table( table )
|
|
36
50
|
|
|
@@ -49,114 +63,122 @@ export default cfg => (
|
|
|
49
63
|
|
|
50
64
|
async all( cb ) {
|
|
51
65
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return result
|
|
55
|
-
} catch (err) {
|
|
56
|
-
|
|
57
|
-
cb(err)
|
|
66
|
+
const rc = await this.connGetter()
|
|
67
|
+
const result = await ( await this._t.run( rc ) ).toArray()
|
|
68
|
+
return optionalCb( null, result, cb )
|
|
69
|
+
} catch ( err ) {
|
|
70
|
+
return optionalCb( err, null, cb )
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
async destroy( sid, cb ) {
|
|
62
75
|
try {
|
|
63
|
-
|
|
76
|
+
const rc = await this.connGetter()
|
|
77
|
+
const result = await this._t.get( sid )
|
|
64
78
|
.delete()
|
|
65
|
-
.run(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
cb(err)
|
|
79
|
+
.run( rc )
|
|
80
|
+
return optionalCb( null, result, cb )
|
|
81
|
+
} catch ( err ) {
|
|
82
|
+
return optionalCb( err, null, cb )
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
async clear( cb ) {
|
|
74
87
|
try {
|
|
75
|
-
|
|
88
|
+
const rc = await this.connGetter()
|
|
89
|
+
const result = await this._t
|
|
76
90
|
.delete()
|
|
77
|
-
.run(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!cb) throw err
|
|
82
|
-
cb(err)
|
|
91
|
+
.run( rc )
|
|
92
|
+
return optionalCb( null, result, cb )
|
|
93
|
+
} catch ( err ) {
|
|
94
|
+
return optionalCb( err, null, cb )
|
|
83
95
|
}
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
async length( cb ) {
|
|
87
99
|
try {
|
|
88
|
-
|
|
100
|
+
const rc = await this.connGetter()
|
|
101
|
+
const result = await this._t
|
|
89
102
|
.count()
|
|
90
|
-
.run(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!cb) throw err
|
|
95
|
-
cb(err)
|
|
103
|
+
.run( rc )
|
|
104
|
+
return optionalCb( null, result, cb )
|
|
105
|
+
} catch ( err ) {
|
|
106
|
+
return optionalCb( err, null, cb )
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
async get( sid, cb ) {
|
|
100
111
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
const rc = await this.connGetter()
|
|
113
|
+
const row = await this._t.get( sid ).run( rc )
|
|
114
|
+
|
|
115
|
+
if ( !row ) return optionalCb( null, null, cb )
|
|
116
|
+
|
|
117
|
+
if ( Date.now() > row.expires.getTime() ) {
|
|
118
|
+
this._t.get( sid ).delete().run( rc ).catch( () => {} )
|
|
119
|
+
return optionalCb( null, null, cb )
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = JSON.parse( row.session )
|
|
123
|
+
return optionalCb( null, result, cb )
|
|
124
|
+
} catch ( err ) {
|
|
125
|
+
return optionalCb( err, null, cb )
|
|
111
126
|
}
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
async set( sid, session, cb ) {
|
|
115
130
|
try {
|
|
116
|
-
|
|
131
|
+
const rc = await this.connGetter()
|
|
132
|
+
const obj = {
|
|
117
133
|
id: sid,
|
|
118
|
-
session: JSON.stringify(session),
|
|
119
|
-
expires: this.getExpiry(session?.cookie),
|
|
134
|
+
session: JSON.stringify( session ),
|
|
135
|
+
expires: this.getExpiry( session?.cookie ),
|
|
120
136
|
}
|
|
121
137
|
|
|
122
|
-
|
|
138
|
+
await this._t.get( sid )
|
|
123
139
|
.replace( obj )
|
|
124
|
-
.run(
|
|
140
|
+
.run( rc )
|
|
125
141
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
if ( this.gcProbability > 0 && Math.random() < this.gcProbability ) {
|
|
143
|
+
const r = this.client
|
|
144
|
+
this._t.between( r.minval, new Date(), { index: 'expires' } )
|
|
145
|
+
.delete().run( rc ).catch( () => {} )
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return optionalCb( null, null, cb )
|
|
149
|
+
} catch ( err ) {
|
|
150
|
+
return optionalCb( err, null, cb )
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
|
|
133
154
|
async touch( sid, session, cb ) {
|
|
134
155
|
try {
|
|
135
|
-
|
|
156
|
+
const rc = await this.connGetter()
|
|
157
|
+
const obj = {
|
|
136
158
|
expires: this.getExpiry( session?.cookie ),
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
|
|
161
|
+
await this._t.get( sid )
|
|
140
162
|
.update( obj )
|
|
141
|
-
.run(
|
|
163
|
+
.run( rc )
|
|
142
164
|
|
|
143
|
-
cb
|
|
144
|
-
} catch (err) {
|
|
145
|
-
|
|
146
|
-
cb(err)
|
|
165
|
+
return optionalCb( null, null, cb )
|
|
166
|
+
} catch ( err ) {
|
|
167
|
+
return optionalCb( err, null, cb )
|
|
147
168
|
}
|
|
148
169
|
}
|
|
149
170
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
// clear table of expired sessions
|
|
171
|
+
// Admin escape hatch — clears all expired sessions immediately.
|
|
172
|
+
// Not needed for normal operation (lazy deletion + probabilistic GC handle cleanup).
|
|
153
173
|
async vacuum( cb ) {
|
|
154
174
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
175
|
+
const r = this.client
|
|
176
|
+
const rc = await this.connGetter()
|
|
177
|
+
await this._t.between( r.minval, new Date(), { index: 'expires' } ).delete().run( rc )
|
|
178
|
+
return optionalCb( null, null, cb )
|
|
157
179
|
} catch ( err ) {
|
|
158
|
-
|
|
159
|
-
cb( err )
|
|
180
|
+
return optionalCb( err, null, cb )
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
|
-
}
|
|
183
|
+
}
|
|
184
|
+
)
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import r from 'rethinkdb'
|
|
4
|
+
import session from 'express-session'
|
|
5
|
+
import _init_store from '../rdb-session.js'
|
|
6
|
+
|
|
7
|
+
const HOST = process.env.RETHINKDB_HOST ?? 'localhost'
|
|
8
|
+
const PORT = parseInt( process.env.RETHINKDB_PORT ?? '28015', 10 )
|
|
9
|
+
const TABLE = 'sessions_test'
|
|
10
|
+
|
|
11
|
+
const RethinkdbSessionStore = _init_store({ session })
|
|
12
|
+
|
|
13
|
+
let rc
|
|
14
|
+
|
|
15
|
+
async function makeStore( opts = {} ) {
|
|
16
|
+
const store = new RethinkdbSessionStore({
|
|
17
|
+
client: r,
|
|
18
|
+
connGetter: () => rc,
|
|
19
|
+
table: TABLE,
|
|
20
|
+
gcProbability: 0,
|
|
21
|
+
...opts,
|
|
22
|
+
})
|
|
23
|
+
await store.ready
|
|
24
|
+
return store
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Insert a row directly, bypassing the store (for setup / verification)
|
|
28
|
+
async function insertRaw( row ) {
|
|
29
|
+
await r.table( TABLE ).insert( row, { conflict: 'replace' } ).run( rc )
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getRaw( sid ) {
|
|
33
|
+
return r.table( TABLE ).get( sid ).run( rc )
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
before( async () => {
|
|
37
|
+
rc = await r.connect({ host: HOST, port: PORT })
|
|
38
|
+
|
|
39
|
+
// Drop and recreate the test table
|
|
40
|
+
const tables = new Set( await r.tableList().run( rc ) )
|
|
41
|
+
if ( tables.has( TABLE ) ) await r.tableDrop( TABLE ).run( rc )
|
|
42
|
+
} )
|
|
43
|
+
|
|
44
|
+
after( async () => {
|
|
45
|
+
const tables = new Set( await r.tableList().run( rc ) )
|
|
46
|
+
if ( tables.has( TABLE ) ) await r.tableDrop( TABLE ).run( rc )
|
|
47
|
+
await rc.close()
|
|
48
|
+
} )
|
|
49
|
+
|
|
50
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// get()
|
|
52
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe( 'get()', () => {
|
|
55
|
+
let store
|
|
56
|
+
|
|
57
|
+
before( async () => {
|
|
58
|
+
store = await makeStore()
|
|
59
|
+
await store.clear()
|
|
60
|
+
} )
|
|
61
|
+
|
|
62
|
+
it( 'returns null for non-existent sid — promise style', async () => {
|
|
63
|
+
const result = await store.get( 'no-such-sid' )
|
|
64
|
+
assert.equal( result, null )
|
|
65
|
+
} )
|
|
66
|
+
|
|
67
|
+
it( 'returns null for non-existent sid — callback style', ( _, done ) => {
|
|
68
|
+
store.get( 'no-such-sid', ( err, result ) => {
|
|
69
|
+
assert.ifError( err )
|
|
70
|
+
assert.equal( result, null )
|
|
71
|
+
done()
|
|
72
|
+
} )
|
|
73
|
+
} )
|
|
74
|
+
|
|
75
|
+
it( 'returns parsed session data for valid session — promise style', async () => {
|
|
76
|
+
const sid = 'valid-sid'
|
|
77
|
+
const data = { user: 'alice', cookie: {} }
|
|
78
|
+
await store.set( sid, data )
|
|
79
|
+
|
|
80
|
+
const result = await store.get( sid )
|
|
81
|
+
assert.equal( result.user, 'alice' )
|
|
82
|
+
} )
|
|
83
|
+
|
|
84
|
+
it( 'returns parsed session data for valid session — callback style', ( _, done ) => {
|
|
85
|
+
const sid = 'valid-sid-cb'
|
|
86
|
+
store.set( sid, { user: 'bob', cookie: {} } ).then( () => {
|
|
87
|
+
store.get( sid, ( err, result ) => {
|
|
88
|
+
assert.ifError( err )
|
|
89
|
+
assert.equal( result.user, 'bob' )
|
|
90
|
+
done()
|
|
91
|
+
} )
|
|
92
|
+
} )
|
|
93
|
+
} )
|
|
94
|
+
|
|
95
|
+
it( 'returns null for expired session', async () => {
|
|
96
|
+
const sid = 'expired-sid'
|
|
97
|
+
await insertRaw({
|
|
98
|
+
id: sid,
|
|
99
|
+
session: JSON.stringify({ user: 'ghost' }),
|
|
100
|
+
expires: new Date( Date.now() - 1000 ),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const result = await store.get( sid )
|
|
104
|
+
assert.equal( result, null )
|
|
105
|
+
} )
|
|
106
|
+
|
|
107
|
+
it( 'lazily deletes the expired row after get()', async () => {
|
|
108
|
+
const sid = 'lazy-delete-sid'
|
|
109
|
+
await insertRaw({
|
|
110
|
+
id: sid,
|
|
111
|
+
session: JSON.stringify({ user: 'phantom' }),
|
|
112
|
+
expires: new Date( Date.now() - 1000 ),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await store.get( sid )
|
|
116
|
+
|
|
117
|
+
// Give the background delete a moment to complete
|
|
118
|
+
await new Promise( resolve => setTimeout( resolve, 100 ) )
|
|
119
|
+
|
|
120
|
+
const row = await getRaw( sid )
|
|
121
|
+
assert.equal( row, null )
|
|
122
|
+
} )
|
|
123
|
+
} )
|
|
124
|
+
|
|
125
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
// set()
|
|
127
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe( 'set()', () => {
|
|
130
|
+
let store
|
|
131
|
+
|
|
132
|
+
before( async () => {
|
|
133
|
+
store = await makeStore()
|
|
134
|
+
await store.clear()
|
|
135
|
+
} )
|
|
136
|
+
|
|
137
|
+
it( 'creates a new session — promise style', async () => {
|
|
138
|
+
await store.set( 'new-sid', { user: 'carol', cookie: {} } )
|
|
139
|
+
const row = await getRaw( 'new-sid' )
|
|
140
|
+
assert.ok( row )
|
|
141
|
+
assert.equal( JSON.parse( row.session ).user, 'carol' )
|
|
142
|
+
} )
|
|
143
|
+
|
|
144
|
+
it( 'upserts (overwrites) an existing session', async () => {
|
|
145
|
+
const sid = 'upsert-sid'
|
|
146
|
+
await store.set( sid, { user: 'dave', cookie: {} } )
|
|
147
|
+
await store.set( sid, { user: 'dave-updated', cookie: {} } )
|
|
148
|
+
|
|
149
|
+
const row = await getRaw( sid )
|
|
150
|
+
assert.equal( JSON.parse( row.session ).user, 'dave-updated' )
|
|
151
|
+
} )
|
|
152
|
+
|
|
153
|
+
it( 'uses cookie.expires when present', async () => {
|
|
154
|
+
const expires = new Date( Date.now() + 60_000 )
|
|
155
|
+
await store.set( 'cookie-exp-sid', { cookie: { expires } } )
|
|
156
|
+
|
|
157
|
+
const row = await getRaw( 'cookie-exp-sid' )
|
|
158
|
+
assert.equal( row.expires.getTime(), expires.getTime() )
|
|
159
|
+
} )
|
|
160
|
+
|
|
161
|
+
it( 'falls back to ttl when no cookie.expires', async () => {
|
|
162
|
+
const before = Date.now()
|
|
163
|
+
await store.set( 'ttl-sid', { cookie: {} } )
|
|
164
|
+
const after = Date.now()
|
|
165
|
+
|
|
166
|
+
const row = await getRaw( 'ttl-sid' )
|
|
167
|
+
const exp = row.expires.getTime()
|
|
168
|
+
assert.ok( exp >= before + store.ttl - 100 )
|
|
169
|
+
assert.ok( exp <= after + store.ttl + 100 )
|
|
170
|
+
} )
|
|
171
|
+
|
|
172
|
+
it( 'creates a new session — callback style', ( _, done ) => {
|
|
173
|
+
store.set( 'cb-set-sid', { user: 'erin', cookie: {} }, async ( err ) => {
|
|
174
|
+
assert.ifError( err )
|
|
175
|
+
const row = await getRaw( 'cb-set-sid' )
|
|
176
|
+
assert.ok( row )
|
|
177
|
+
done()
|
|
178
|
+
} )
|
|
179
|
+
} )
|
|
180
|
+
} )
|
|
181
|
+
|
|
182
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// destroy()
|
|
184
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe( 'destroy()', () => {
|
|
187
|
+
let store
|
|
188
|
+
|
|
189
|
+
before( async () => {
|
|
190
|
+
store = await makeStore()
|
|
191
|
+
await store.clear()
|
|
192
|
+
} )
|
|
193
|
+
|
|
194
|
+
it( 'removes an existing session — promise style', async () => {
|
|
195
|
+
await store.set( 'destroy-sid', { cookie: {} } )
|
|
196
|
+
await store.destroy( 'destroy-sid' )
|
|
197
|
+
const row = await getRaw( 'destroy-sid' )
|
|
198
|
+
assert.equal( row, null )
|
|
199
|
+
} )
|
|
200
|
+
|
|
201
|
+
it( 'no error on non-existent sid', async () => {
|
|
202
|
+
await assert.doesNotReject( () => store.destroy( 'ghost-sid' ) )
|
|
203
|
+
} )
|
|
204
|
+
|
|
205
|
+
it( 'removes an existing session — callback style', ( _, done ) => {
|
|
206
|
+
store.set( 'destroy-cb-sid', { cookie: {} }, async () => {
|
|
207
|
+
store.destroy( 'destroy-cb-sid', async ( err ) => {
|
|
208
|
+
assert.ifError( err )
|
|
209
|
+
const row = await getRaw( 'destroy-cb-sid' )
|
|
210
|
+
assert.equal( row, null )
|
|
211
|
+
done()
|
|
212
|
+
} )
|
|
213
|
+
} )
|
|
214
|
+
} )
|
|
215
|
+
} )
|
|
216
|
+
|
|
217
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
// touch()
|
|
219
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe( 'touch()', () => {
|
|
222
|
+
let store
|
|
223
|
+
|
|
224
|
+
before( async () => {
|
|
225
|
+
store = await makeStore()
|
|
226
|
+
await store.clear()
|
|
227
|
+
} )
|
|
228
|
+
|
|
229
|
+
it( 'updates expires on an existing session', async () => {
|
|
230
|
+
const sid = 'touch-sid'
|
|
231
|
+
await store.set( sid, { cookie: {} } )
|
|
232
|
+
const before = await getRaw( sid )
|
|
233
|
+
|
|
234
|
+
// Small delay so the new expiry is meaningfully later
|
|
235
|
+
await new Promise( resolve => setTimeout( resolve, 10 ) )
|
|
236
|
+
|
|
237
|
+
const newExpires = new Date( Date.now() + store.ttl )
|
|
238
|
+
await store.touch( sid, { cookie: { expires: newExpires } } )
|
|
239
|
+
|
|
240
|
+
const after = await getRaw( sid )
|
|
241
|
+
assert.ok( after.expires.getTime() > before.expires.getTime() )
|
|
242
|
+
} )
|
|
243
|
+
|
|
244
|
+
it( 'no error on non-existent sid', async () => {
|
|
245
|
+
await assert.doesNotReject( () => store.touch( 'ghost-sid', { cookie: {} } ) )
|
|
246
|
+
} )
|
|
247
|
+
} )
|
|
248
|
+
|
|
249
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
// all()
|
|
251
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
describe( 'all()', () => {
|
|
254
|
+
let store
|
|
255
|
+
|
|
256
|
+
before( async () => {
|
|
257
|
+
store = await makeStore()
|
|
258
|
+
await store.clear()
|
|
259
|
+
} )
|
|
260
|
+
|
|
261
|
+
it( 'returns all sessions', async () => {
|
|
262
|
+
await store.set( 'all-sid-1', { cookie: {} } )
|
|
263
|
+
await store.set( 'all-sid-2', { cookie: {} } )
|
|
264
|
+
|
|
265
|
+
const result = await store.all()
|
|
266
|
+
const ids = result.map( row => row.id )
|
|
267
|
+
assert.ok( ids.includes( 'all-sid-1' ) )
|
|
268
|
+
assert.ok( ids.includes( 'all-sid-2' ) )
|
|
269
|
+
} )
|
|
270
|
+
} )
|
|
271
|
+
|
|
272
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// clear() + length()
|
|
274
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
describe( 'clear() and length()', () => {
|
|
277
|
+
let store
|
|
278
|
+
|
|
279
|
+
before( async () => {
|
|
280
|
+
store = await makeStore()
|
|
281
|
+
await store.clear()
|
|
282
|
+
} )
|
|
283
|
+
|
|
284
|
+
it( 'length() returns correct count', async () => {
|
|
285
|
+
await store.set( 'len-sid-1', { cookie: {} } )
|
|
286
|
+
await store.set( 'len-sid-2', { cookie: {} } )
|
|
287
|
+
|
|
288
|
+
const count = await store.length()
|
|
289
|
+
assert.equal( count, 2 )
|
|
290
|
+
} )
|
|
291
|
+
|
|
292
|
+
it( 'clear() removes all sessions and length() returns 0', async () => {
|
|
293
|
+
await store.clear()
|
|
294
|
+
const count = await store.length()
|
|
295
|
+
assert.equal( count, 0 )
|
|
296
|
+
} )
|
|
297
|
+
} )
|
|
298
|
+
|
|
299
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
// vacuum()
|
|
301
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
describe( 'vacuum()', () => {
|
|
304
|
+
let store
|
|
305
|
+
|
|
306
|
+
before( async () => {
|
|
307
|
+
store = await makeStore()
|
|
308
|
+
await store.clear()
|
|
309
|
+
} )
|
|
310
|
+
|
|
311
|
+
it( 'deletes expired sessions and leaves valid ones', async () => {
|
|
312
|
+
const validSid = 'vacuum-valid'
|
|
313
|
+
const expiredSid = 'vacuum-expired'
|
|
314
|
+
|
|
315
|
+
await store.set( validSid, { cookie: {} } )
|
|
316
|
+
await insertRaw({
|
|
317
|
+
id: expiredSid,
|
|
318
|
+
session: JSON.stringify({ user: 'stale' }),
|
|
319
|
+
expires: new Date( Date.now() - 1000 ),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
await store.vacuum()
|
|
323
|
+
|
|
324
|
+
const validRow = await getRaw( validSid )
|
|
325
|
+
const expiredRow = await getRaw( expiredSid )
|
|
326
|
+
|
|
327
|
+
assert.ok( validRow, 'valid session should still exist' )
|
|
328
|
+
assert.equal( expiredRow, null )
|
|
329
|
+
} )
|
|
330
|
+
} )
|
|
331
|
+
|
|
332
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
// Probabilistic GC
|
|
334
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
describe( 'probabilistic GC', () => {
|
|
337
|
+
it( 'gcProbability: 1 — expired sessions removed after set()', async () => {
|
|
338
|
+
const store = await makeStore({ gcProbability: 1 })
|
|
339
|
+
await store.clear()
|
|
340
|
+
|
|
341
|
+
const expiredSid = 'gc-expired'
|
|
342
|
+
await insertRaw({
|
|
343
|
+
id: expiredSid,
|
|
344
|
+
session: JSON.stringify({ user: 'stale' }),
|
|
345
|
+
expires: new Date( Date.now() - 1000 ),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// Trigger set() which will fire GC
|
|
349
|
+
await store.set( 'gc-trigger-sid', { cookie: {} } )
|
|
350
|
+
|
|
351
|
+
// Give background GC time to complete
|
|
352
|
+
await new Promise( resolve => setTimeout( resolve, 200 ) )
|
|
353
|
+
|
|
354
|
+
const row = await getRaw( expiredSid )
|
|
355
|
+
assert.equal( row, null )
|
|
356
|
+
} )
|
|
357
|
+
|
|
358
|
+
it( 'gcProbability: 0 — expired sessions remain after set()', async () => {
|
|
359
|
+
const store = await makeStore({ gcProbability: 0 })
|
|
360
|
+
await store.clear()
|
|
361
|
+
|
|
362
|
+
const expiredSid = 'gc-disabled-expired'
|
|
363
|
+
await insertRaw({
|
|
364
|
+
id: expiredSid,
|
|
365
|
+
session: JSON.stringify({ user: 'stale' }),
|
|
366
|
+
expires: new Date( Date.now() - 1000 ),
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
await store.set( 'gc-disabled-trigger', { cookie: {} } )
|
|
370
|
+
|
|
371
|
+
// Brief wait — GC should NOT have run
|
|
372
|
+
await new Promise( resolve => setTimeout( resolve, 100 ) )
|
|
373
|
+
|
|
374
|
+
const row = await getRaw( expiredSid )
|
|
375
|
+
assert.ok( row, 'expired row should still exist when GC is disabled' )
|
|
376
|
+
} )
|
|
377
|
+
} )
|
|
378
|
+
|
|
379
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
// connGetter — connection replacement between calls
|
|
381
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
describe( 'connGetter', () => {
|
|
384
|
+
it( 'replacing the connection reference between calls still works', async () => {
|
|
385
|
+
let currentConn = rc
|
|
386
|
+
|
|
387
|
+
const store = new RethinkdbSessionStore({
|
|
388
|
+
client: r,
|
|
389
|
+
connGetter: () => currentConn,
|
|
390
|
+
table: TABLE,
|
|
391
|
+
gcProbability: 0,
|
|
392
|
+
})
|
|
393
|
+
await store.ready
|
|
394
|
+
|
|
395
|
+
await store.set( 'conn-getter-sid', { user: 'frank', cookie: {} } )
|
|
396
|
+
|
|
397
|
+
// Simulate reconnect: swap in a fresh connection
|
|
398
|
+
const newConn = await r.connect({ host: HOST, port: PORT })
|
|
399
|
+
currentConn = newConn
|
|
400
|
+
|
|
401
|
+
// Should still work through the new connection
|
|
402
|
+
const result = await store.get( 'conn-getter-sid' )
|
|
403
|
+
assert.equal( result.user, 'frank' )
|
|
404
|
+
|
|
405
|
+
await newConn.close()
|
|
406
|
+
} )
|
|
407
|
+
} )
|