| 1 | /**
|
|---|
| 2 | * Persistent session store backed by the existing `sessions` table in
|
|---|
| 3 | * the SQLite DB. Survives `node --watch` restarts (no more "had to log in
|
|---|
| 4 | * again after every save").
|
|---|
| 5 | *
|
|---|
| 6 | * Schema (already in migrations/001-init.sql):
|
|---|
| 7 | * sessions(sid TEXT PRIMARY KEY, data TEXT, expiresAt DATETIME)
|
|---|
| 8 | *
|
|---|
| 9 | * Implements the minimal express-session Store contract: get / set /
|
|---|
| 10 | * destroy / touch / length / clear / all. Periodic GC clears expired rows.
|
|---|
| 11 | */
|
|---|
| 12 |
|
|---|
| 13 | import session from 'express-session';
|
|---|
| 14 | import db from '../config/database.js';
|
|---|
| 15 |
|
|---|
| 16 | const Store = session.Store;
|
|---|
| 17 |
|
|---|
| 18 | export class SqliteSessionStore extends Store {
|
|---|
| 19 | constructor(options = {}) {
|
|---|
| 20 | super(options);
|
|---|
| 21 | // Periodic cleanup of expired rows. Default every 15 min.
|
|---|
| 22 | const intervalMs = options.cleanupIntervalMs ?? 15 * 60 * 1000;
|
|---|
| 23 | if (intervalMs > 0) {
|
|---|
| 24 | this._gcTimer = setInterval(() => this._gc(), intervalMs);
|
|---|
| 25 | // Don't keep the event loop alive just for GC.
|
|---|
| 26 | if (this._gcTimer.unref) this._gcTimer.unref();
|
|---|
| 27 | }
|
|---|
| 28 |
|
|---|
| 29 | // Prepared statements (faster than re-preparing per call)
|
|---|
| 30 | this._stmtGet = db.prepare('SELECT data, expiresAt FROM sessions WHERE sid = ?');
|
|---|
| 31 | this._stmtSet = db.prepare(
|
|---|
| 32 | 'INSERT INTO sessions (sid, data, expiresAt) VALUES (?, ?, ?) ' +
|
|---|
| 33 | 'ON CONFLICT(sid) DO UPDATE SET data = excluded.data, expiresAt = excluded.expiresAt'
|
|---|
| 34 | );
|
|---|
| 35 | this._stmtDestroy = db.prepare('DELETE FROM sessions WHERE sid = ?');
|
|---|
| 36 | this._stmtTouch = db.prepare('UPDATE sessions SET expiresAt = ? WHERE sid = ?');
|
|---|
| 37 | this._stmtCount = db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE expiresAt > datetime('now')");
|
|---|
| 38 | this._stmtClear = db.prepare('DELETE FROM sessions');
|
|---|
| 39 | this._stmtAll = db.prepare("SELECT sid, data FROM sessions WHERE expiresAt > datetime('now')");
|
|---|
| 40 | this._stmtGc = db.prepare("DELETE FROM sessions WHERE expiresAt <= datetime('now')");
|
|---|
| 41 | }
|
|---|
| 42 |
|
|---|
| 43 | _gc() {
|
|---|
| 44 | try { this._stmtGc.run(); } catch (e) { /* swallow */ }
|
|---|
| 45 | }
|
|---|
| 46 |
|
|---|
| 47 | _expiryFor(sess) {
|
|---|
| 48 | // express-session sets sess.cookie.expires (Date) when maxAge is set.
|
|---|
| 49 | // Fallback to "now + 30 days" if missing (matches our cookie maxAge default).
|
|---|
| 50 | const exp = sess?.cookie?.expires
|
|---|
| 51 | ? new Date(sess.cookie.expires)
|
|---|
| 52 | : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|---|
| 53 | return exp.toISOString();
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | get(sid, cb) {
|
|---|
| 57 | try {
|
|---|
| 58 | const row = this._stmtGet.get(sid);
|
|---|
| 59 | if (!row) return cb(null, null);
|
|---|
| 60 | // Lazy expiry check (the periodic GC may not have run yet)
|
|---|
| 61 | if (row.expiresAt && new Date(row.expiresAt).getTime() <= Date.now()) {
|
|---|
| 62 | this._stmtDestroy.run(sid);
|
|---|
| 63 | return cb(null, null);
|
|---|
| 64 | }
|
|---|
| 65 | cb(null, JSON.parse(row.data));
|
|---|
| 66 | } catch (e) {
|
|---|
| 67 | cb(e);
|
|---|
| 68 | }
|
|---|
| 69 | }
|
|---|
| 70 |
|
|---|
| 71 | set(sid, sess, cb) {
|
|---|
| 72 | try {
|
|---|
| 73 | this._stmtSet.run(sid, JSON.stringify(sess), this._expiryFor(sess));
|
|---|
| 74 | cb && cb(null);
|
|---|
| 75 | } catch (e) {
|
|---|
| 76 | cb && cb(e);
|
|---|
| 77 | }
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | destroy(sid, cb) {
|
|---|
| 81 | try {
|
|---|
| 82 | this._stmtDestroy.run(sid);
|
|---|
| 83 | cb && cb(null);
|
|---|
| 84 | } catch (e) {
|
|---|
| 85 | cb && cb(e);
|
|---|
| 86 | }
|
|---|
| 87 | }
|
|---|
| 88 |
|
|---|
| 89 | touch(sid, sess, cb) {
|
|---|
| 90 | try {
|
|---|
| 91 | this._stmtTouch.run(this._expiryFor(sess), sid);
|
|---|
| 92 | cb && cb(null);
|
|---|
| 93 | } catch (e) {
|
|---|
| 94 | cb && cb(e);
|
|---|
| 95 | }
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | length(cb) {
|
|---|
| 99 | try { cb(null, this._stmtCount.get().c); } catch (e) { cb(e); }
|
|---|
| 100 | }
|
|---|
| 101 |
|
|---|
| 102 | clear(cb) {
|
|---|
| 103 | try { this._stmtClear.run(); cb && cb(null); } catch (e) { cb && cb(e); }
|
|---|
| 104 | }
|
|---|
| 105 |
|
|---|
| 106 | all(cb) {
|
|---|
| 107 | try {
|
|---|
| 108 | const rows = this._stmtAll.all();
|
|---|
| 109 | const out = {};
|
|---|
| 110 | for (const r of rows) {
|
|---|
| 111 | try { out[r.sid] = JSON.parse(r.data); } catch (e) { /* skip */ }
|
|---|
| 112 | }
|
|---|
| 113 | cb(null, out);
|
|---|
| 114 | } catch (e) { cb(e); }
|
|---|
| 115 | }
|
|---|
| 116 | }
|
|---|
| 117 |
|
|---|
| 118 | export default SqliteSessionStore;
|
|---|