import session from "express-session"; import { getPool } from "../db/index.js"; const ONE_DAY_MS = 86_400_000; const DEFAULT_PRUNE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes // Memory is per-process: a session mutated on another worker (e.g. a logout) // can be served stale here for at most one TTL. Keep it short. const DEFAULT_MEMORY_TTL_MS = 60 * 1000; // 60 seconds export const expiryOf = (sess) => new Date(sess?.cookie?.expires ?? Date.now() + ONE_DAY_MS); // In-memory cache in front of Postgres: sessions survive restarts and are // shared across workers, while the memory map serves the read-heavy hot path. export class PgSessionStore extends session.Store { constructor({ pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS, memoryTtlMs = DEFAULT_MEMORY_TTL_MS } = {}) { super(); // sid -> { sess, expire (ms epoch), freshUntil (ms epoch) } this._mem = new Map(); this._memoryTtlMs = memoryTtlMs; // unref'd so the timer never keeps the process alive. if (pruneIntervalMs > 0) { this._pruneTimer = setInterval(() => this.prune(), pruneIntervalMs); this._pruneTimer.unref?.(); } } async _withPg(fn) { const client = await getPool(); try { return await fn(client); } finally { await client.release(); } } get(sid, cb) { const now = Date.now(); const hit = this._mem.get(sid); if (hit) { if (hit.expire <= now) { this._mem.delete(sid); return cb(null, null); } if (hit.freshUntil > now) return cb(null, hit.sess); // fast path: no DB query } this._withPg((c) => c.query(`SELECT sess, expire FROM session WHERE sid = $1 AND expire > NOW()`, [sid]), ) .then((res) => { const row = res.rows[0]; if (!row) { this._mem.delete(sid); return cb(null, null); } this._mem.set(sid, { sess: row.sess, expire: new Date(row.expire).getTime(), freshUntil: now + this._memoryTtlMs, }); cb(null, row.sess); }) .catch(cb); } set(sid, sess, cb = () => {}) { const expire = expiryOf(sess); this._mem.set(sid, { sess, expire: expire.getTime(), freshUntil: Date.now() + this._memoryTtlMs }); this._withPg((c) => c.query( `INSERT INTO session (sid, sess, expire) VALUES ($1, $2::jsonb, $3) ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire, updated_ts = NOW()`, [sid, sess, expire], ), ) .then(() => cb(null)) .catch(cb); } destroy(sid, cb = () => {}) { this._mem.delete(sid); this._withPg((c) => c.query(`DELETE FROM session WHERE sid = $1`, [sid])) .then(() => cb(null)) .catch(cb); } touch(sid, sess, cb = () => {}) { const expire = expiryOf(sess); const hit = this._mem.get(sid); if (hit) { hit.expire = expire.getTime(); hit.freshUntil = Date.now() + this._memoryTtlMs; } this._withPg((c) => c.query(`UPDATE session SET expire = $2, updated_ts = NOW() WHERE sid = $1`, [sid, expire]), ) .then(() => cb(null)) .catch(cb); } // Errors are swallowed: runs on a timer with no caller. async prune() { const now = Date.now(); for (const [sid, v] of this._mem) if (v.expire <= now) this._mem.delete(sid); try { await this._withPg((c) => c.query(`DELETE FROM session WHERE expire <= NOW()`)); } catch { /* ignore */ } } stopPruning() { if (this._pruneTimer) clearInterval(this._pruneTimer); } }