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 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** -- RethinkDB import.
13
+ `client`: **Required** RethinkDB import.
16
14
 
17
- `conn`: **Required** -- RethinkDB database connection.
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
- const rc = await r.connect({ host })
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 = await _init_rdb_express_session_store({ session })
36
+ const RethinkdbSessionStore = _init_rdb_express_session_store({ session })
37
37
 
38
38
  app.use( session({
39
- store: new RethinkdbSessionStore({ client: r, conn: rc })
40
- })
39
+ store: new RethinkdbSessionStore({ client: r, connGetter: () => rc })
40
+ }) )
41
41
  ```
42
42
 
43
- ### Wait on Readiness
43
+ ### connGetter and Reconnection
44
44
 
45
- Using the `ready` property to wait for store initialization. Store will ensure the `table` table is created and has a secondary index for `expires` if not already.
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
- // Thennable
49
- rethinkdbSessionStoreInstance.ready.then( async () => {
50
- await rethinkdbSessionStoreInstance.vacuum()
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
- await rethinkdbSessionStoreInstance.vacuum()
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.5",
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
- "scripts": {}
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 ,// 2 weeks
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
- Object.assign(this, defaults, opt)
13
- if (! this.client) throw new Error('RethinkdbSessionStore requires a client in setup options.')
14
- if (! this.conn) throw new Error('RethinkdbSessionStore requires a connection in setup options.')
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
- this.initDb( this.client, this.conn, _p_then )
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 initDb( _r, rc, then ) {
27
- r = _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
- let result = await ( await this._t.run( this.conn ) ).toArray()
53
- cb?.( null, result )
54
- return result
55
- } catch (err) {
56
- if (!cb) throw err
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
- let result = await this._t.get( sid )
76
+ const rc = await this.connGetter()
77
+ const result = await this._t.get( sid )
64
78
  .delete()
65
- .run(this.conn)
66
- cb?.(null, result)
67
- return result
68
- } catch (err) {
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
- let result = await this._t
88
+ const rc = await this.connGetter()
89
+ const result = await this._t
76
90
  .delete()
77
- .run(this.conn)
78
- cb?.( null, result )
79
- return result
80
- } catch (err) {
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
- let result = await this._t
100
+ const rc = await this.connGetter()
101
+ const result = await this._t
89
102
  .count()
90
- .run(this.conn)
91
- cb?.( null, result )
92
- return result
93
- } catch (err) {
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
- let result = await this._t.get( sid )
102
- .run(this.conn)
103
- result = !result ? null
104
- : Date.now() > result.expires.getTime() ? null
105
- : JSON.parse( result.session )
106
- cb?.(null, result)
107
- return result
108
- } catch (err) {
109
- if (!cb) throw err
110
- cb(err)
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
- let obj = {
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
- let ans = await this._t.get( sid )
138
+ await this._t.get( sid )
123
139
  .replace( obj )
124
- .run( this.conn )
140
+ .run( rc )
125
141
 
126
- cb?.()
127
- } catch (err) {
128
- if (!cb) throw err
129
- cb(err)
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
- let obj = {
156
+ const rc = await this.connGetter()
157
+ const obj = {
136
158
  expires: this.getExpiry( session?.cookie ),
137
159
  }
138
160
 
139
- let ans = await this._t.get( sid )
161
+ await this._t.get( sid )
140
162
  .update( obj )
141
- .run( this.conn )
163
+ .run( rc )
142
164
 
143
- cb?.()
144
- } catch (err) {
145
- if (!cb) throw err
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
- // custom functions (not part of the express-session Session Store Implementation)
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
- await this._t.between( r.minval, new Date( Date.now() - this.ttl ), {index: 'expires'} ).delete().run( this.conn )
156
- cb?.()
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
- if ( !cb ) throw err
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
+ } )