Files
jlinc-server/backend/http/sessionStore.js
2026-08-20 15:08:32 +00:00

116 lines
3.5 KiB
JavaScript

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);
}
}