├── .npmrc ├── .husky ├── pre-push └── commit-msg ├── .github ├── release-please │ ├── manifest.json │ └── config.json ├── FUNDING.yml └── workflows │ ├── dependency-review.yml │ ├── lint.yml │ ├── release-please.yml │ ├── codeql-analysis.yml │ ├── nodejs.yml │ └── compliance.yml ├── renovate.json ├── eslint.config.mjs ├── .knip.jsonc ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── SECURITY.md ├── table.sql ├── .vscode └── extensions.json ├── LICENSE ├── test ├── db-utils.js ├── integration │ ├── basic.spec.js │ ├── pgpromise.spec.js │ └── express.spec.js └── main.spec.js ├── package.json ├── README.md ├── CHANGELOG.md └── index.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm test 4 | -------------------------------------------------------------------------------- /.github/release-please/manifest.json: -------------------------------------------------------------------------------- 1 | {".":"10.0.0"} 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: voxpelli 2 | tidelift: npm/connect-pg-simple 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>voxpelli/renovate-config:default" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | npx --no validate-conventional-commit < .git/COMMIT_EDITMSG 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { voxpelli } from '@voxpelli/eslint-config'; 2 | 3 | export default voxpelli({ cjs: true }); 4 | -------------------------------------------------------------------------------- /.knip.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@2/schema.json", 3 | "mocha": { 4 | "entry": ["test/**/*.spec.js"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Basic ones 2 | /coverage 3 | /docs 4 | /node_modules 5 | /.env 6 | /test/.env 7 | /.nyc_output 8 | 9 | # We're a library, so please, no lock files 10 | /package-lock.json 11 | /yarn.lock 12 | 13 | # Library specific ones 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli/tsconfig/node18.json", 3 | "files": [ 4 | "index.js" 5 | ], 6 | "include": [ 7 | "test/**/*.js" 8 | ], 9 | "compilerOptions": { 10 | "types": ["node", "mocha"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | uses: voxpelli/ghatemplates/.github/workflows/dependency-review.yml@main 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | uses: voxpelli/ghatemplates/.github/workflows/lint.yml@main 19 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The latest minor release, unless stated otherwise 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a security vulnerability, please use the 10 | [Tidelift security contact](https://tidelift.com/security). 11 | Tidelift will coordinate the fix and disclosure. 12 | -------------------------------------------------------------------------------- /table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "session" ( 2 | "sid" varchar NOT NULL COLLATE "default", 3 | "sess" json NOT NULL, 4 | "expire" timestamp(6) NOT NULL 5 | ) 6 | WITH (OIDS=FALSE); 7 | 8 | ALTER TABLE "session" ADD CONSTRAINT "session_pkey" PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE; 9 | 10 | CREATE INDEX "IDX_session_expire" ON "session" ("expire"); 11 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | packages: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release-please: 16 | uses: voxpelli/ghatemplates/.github/workflows/release-please-4.yml@main 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bierner.github-markdown-preview", 4 | "dbaeumer.vscode-eslint", 5 | "editorconfig.editorconfig", 6 | "github.vscode-github-actions", 7 | "gruntfuggly.todo-tree", 8 | "hbenl.vscode-mocha-test-adapter", 9 | "mikestead.dotenv", 10 | "usernamehw.errorlens", 11 | "vivaxy.vscode-conventional-commits" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '26 15 * * 6' 10 | 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | jobs: 17 | analyze: 18 | uses: voxpelli/ghatemplates/.github/workflows/codeql-analysis.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | uses: voxpelli/ghatemplates/.github/workflows/test-pg.yml@main 19 | with: 20 | node-versions: '18,20,22' 21 | os: 'ubuntu-latest' 22 | pg-versions: '9.6,12,13' 23 | -------------------------------------------------------------------------------- /.github/workflows/compliance.yml: -------------------------------------------------------------------------------- 1 | name: Compliance 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited, reopened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | compliance: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: mtfoley/pr-compliance-action@11b664f0fcf2c4ce954f05ccfcaab6e52b529f86 15 | with: 16 | body-auto-close: false 17 | body-regex: '.*' 18 | ignore-authors: | 19 | renovate 20 | renovate[bot] 21 | ignore-team-members: false 22 | -------------------------------------------------------------------------------- /.github/release-please/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/v16.12.0/schemas/config.json", 3 | "release-type": "node", 4 | "include-component-in-tag": false, 5 | "changelog-sections": [ 6 | { "type": "feat", "section": "🌟 Features", "hidden": false }, 7 | { "type": "fix", "section": "🩹 Fixes", "hidden": false }, 8 | { "type": "docs", "section": "📚 Documentation", "hidden": false }, 9 | 10 | { "type": "chore", "section": "🧹 Chores", "hidden": false }, 11 | { "type": "perf", "section": "🧹 Chores", "hidden": false }, 12 | { "type": "refactor", "section": "🧹 Chores", "hidden": false }, 13 | { "type": "test", "section": "🧹 Chores", "hidden": false }, 14 | 15 | { "type": "build", "section": "🤖 Automation", "hidden": false }, 16 | { "type": "ci", "section": "🤖 Automation", "hidden": true } 17 | ], 18 | "packages": { 19 | ".": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Pelle Wessman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/db-utils.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | 'use strict'; 4 | 5 | const fs = require('node:fs').promises; 6 | const pathModule = require('node:path'); 7 | 8 | const pg = require('pg'); 9 | 10 | // eslint-disable-next-line n/no-process-env 11 | const dotEnvFile = process.env['DOTENV_FILE'] || pathModule.resolve(__dirname, './.env'); 12 | 13 | require('dotenv').config({ path: dotEnvFile }); 14 | 15 | const conObject = { 16 | // eslint-disable-next-line n/no-process-env 17 | database: process.env['PGDATABASE'] || 'connect_pg_simple_test', 18 | }; 19 | 20 | const pool = new pg.Pool(conObject); 21 | 22 | const tables = ['session']; 23 | 24 | /** @returns {Promise} */ 25 | const removeTables = async () => { 26 | await Promise.all(tables.map(table => pool.query('DROP TABLE IF EXISTS ' + table))); 27 | }; 28 | 29 | /** @returns {Promise} */ 30 | const initTables = async () => { 31 | const tableDef = await fs.readFile(pathModule.resolve(__dirname, '../table.sql'), 'utf8'); 32 | await pool.query(tableDef); 33 | }; 34 | 35 | module.exports = Object.freeze({ 36 | conObject, 37 | queryPromise: pool.query.bind(pool), 38 | removeTables, 39 | initTables, 40 | }); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "connect-pg-simple", 3 | "version": "10.0.0", 4 | "description": "A simple, minimal PostgreSQL session store for Connect/Express", 5 | "url": "http://github.com/voxpelli/node-connect-pg-simple", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/voxpelli/node-connect-pg-simple.git" 9 | }, 10 | "author": { 11 | "name": "Pelle Wessman", 12 | "email": "pelle@kodfabrik.se", 13 | "url": "http://kodfabrik.se/" 14 | }, 15 | "license": "MIT", 16 | "dependencies": { 17 | "pg": "^8.12.0" 18 | }, 19 | "engines": { 20 | "node": "^18.18.0 || ^20.9.0 || >=22.0.0" 21 | }, 22 | "main": "index.js", 23 | "files": [ 24 | "index.js", 25 | "table.sql" 26 | ], 27 | "scripts": { 28 | "check:installed-check": "installed-check", 29 | "check:knip": "knip", 30 | "check:lint": "eslint", 31 | "check:tsc": "tsc", 32 | "check:type-coverage": "type-coverage --detail --strict --at-least 85 --ignore-files 'test/**/*'", 33 | "check": "run-p check:*", 34 | "light:mocha": "c8 --reporter=lcov --reporter text mocha test/*.spec.js", 35 | "prepare": "husky install", 36 | "test-light": "run-s check light:*", 37 | "test:mocha": "c8 --reporter=lcov --reporter text mocha 'test/**/*.spec.js' --exit", 38 | "test-ci": "run-s test:*", 39 | "test": "run-s check test:*" 40 | }, 41 | "devDependencies": { 42 | "@types/chai": "^4.3.19", 43 | "@types/chai-as-promised": "^7.1.8", 44 | "@types/cookie-signature": "^1.1.2", 45 | "@types/express": "^4.17.21", 46 | "@types/express-session": "^1.17.10", 47 | "@types/mocha": "^10.0.8", 48 | "@types/node": "^18.19.50", 49 | "@types/pg": "^8.11.0", 50 | "@types/proxyquire": "^1.3.31", 51 | "@types/sinon": "^17.0.3", 52 | "@types/sinon-chai": "^3.2.12", 53 | "@types/supertest": "^6.0.2", 54 | "@voxpelli/eslint-config": "^22.2.0", 55 | "@voxpelli/tsconfig": "^14.0.0", 56 | "c8": "^10.1.2", 57 | "chai": "^4.5.0", 58 | "chai-as-promised": "^7.1.2", 59 | "cookie-signature": "^1.2.1", 60 | "cookiejar": "^2.1.4", 61 | "dotenv": "^16.4.5", 62 | "eslint": "^9.23.0", 63 | "express": "^4.21.0", 64 | "express-session": "^1.18.0", 65 | "husky": "^9.1.6", 66 | "installed-check": "^9.3.0", 67 | "knip": "^5.46.2", 68 | "mocha": "^10.7.3", 69 | "npm-run-all2": "^6.2.2", 70 | "pg-promise": "^11.9.1", 71 | "proxyquire": "^2.1.3", 72 | "sinon": "^17.0.1", 73 | "sinon-chai": "^3.7.0", 74 | "supertest": "^6.3.4", 75 | "type-coverage": "^2.29.1", 76 | "typescript": "~5.5.4", 77 | "validate-conventional-commit": "^1.0.4" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/integration/basic.spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | 'use strict'; 4 | 5 | const chai = require('chai'); 6 | const chaiAsPromised = require('chai-as-promised'); 7 | const sinon = require('sinon'); 8 | const sinonChai = require('sinon-chai'); 9 | 10 | chai.use(chaiAsPromised); 11 | chai.use(sinonChai); 12 | chai.should(); 13 | 14 | const session = require('express-session'); 15 | 16 | const connectPgSimple = require('../..'); 17 | const { 18 | initTables, 19 | queryPromise, 20 | removeTables, 21 | } = require('../db-utils'); 22 | 23 | describe('pg', () => { 24 | /** @type {import('../..').PGStoreOptions} */ 25 | let options; 26 | 27 | beforeEach(() => { 28 | options = { 29 | pruneSessionInterval: false, 30 | }; 31 | }); 32 | 33 | afterEach(() => { 34 | sinon.restore(); 35 | }); 36 | 37 | describe('table creation', () => { 38 | beforeEach(async () => { 39 | await removeTables(); 40 | }); 41 | 42 | it('should auto create table when requested', async () => { 43 | await queryPromise('SELECT COUNT(sid) FROM session').should.be.rejectedWith('relation "session" does not exist'); 44 | 45 | const store = new (connectPgSimple(session))({ createTableIfMissing: true, ...options }); 46 | const asyncQuerySpy = sinon.spy(store, '_asyncQuery'); 47 | 48 | await store._ensureSessionStoreTable(); 49 | 50 | asyncQuerySpy.should.have.been.calledTwice; 51 | 52 | await queryPromise('SELECT COUNT(sid) FROM session'); 53 | }); 54 | 55 | it('should not auto create table when already exists', async () => { 56 | await initTables(); 57 | 58 | await queryPromise('SELECT COUNT(sid) FROM session'); 59 | 60 | const store = new (connectPgSimple(session))({ createTableIfMissing: true, ...options }); 61 | const asyncQuerySpy = sinon.spy(store, '_asyncQuery'); 62 | 63 | await store._ensureSessionStoreTable(); 64 | 65 | asyncQuerySpy.should.have.been.calledOnceWith('SELECT to_regclass($1::text)'); 66 | 67 | await queryPromise('SELECT COUNT(sid) FROM session'); 68 | }); 69 | 70 | it('should not auto create table when not requested', async () => { 71 | await queryPromise('SELECT COUNT(sid) FROM session').should.be.rejectedWith('relation "session" does not exist'); 72 | 73 | const store = new (connectPgSimple(session))(options); 74 | await store._ensureSessionStoreTable(); 75 | 76 | await queryPromise('SELECT COUNT(sid) FROM session').should.be.rejectedWith('relation "session" does not exist'); 77 | }); 78 | 79 | it('should auto create table on first query', async () => { 80 | await queryPromise('SELECT COUNT(sid) FROM session').should.be.rejectedWith('relation "session" does not exist'); 81 | 82 | const store = new (connectPgSimple(session))({ createTableIfMissing: true, ...options }); 83 | const asyncQuerySpy = sinon.spy(store, '_asyncQuery'); 84 | 85 | await store._asyncQuery('SELECT COUNT(sid) FROM session'); 86 | 87 | asyncQuerySpy.should.have.been.calledThrice; 88 | 89 | await queryPromise('SELECT COUNT(sid) FROM session'); 90 | }); 91 | 92 | it("shouldn't start more than one table creation", async () => { 93 | await queryPromise('SELECT COUNT(sid) FROM session').should.be.rejectedWith('relation "session" does not exist'); 94 | 95 | const store = new (connectPgSimple(session))({ createTableIfMissing: true, ...options }); 96 | const asyncQuerySpy = sinon.spy(store, '_asyncQuery'); 97 | const ensureSessionStoreTableSpy = sinon.spy(store, '_ensureSessionStoreTable'); 98 | const rawEnsureSessionStoreTableSpy = sinon.spy(store, '_rawEnsureSessionStoreTable'); 99 | 100 | await Promise.all([ 101 | store._asyncQuery('SELECT COUNT(sid) FROM session'), 102 | store._asyncQuery('SELECT COUNT(sid) FROM session'), 103 | store._asyncQuery('SELECT COUNT(sid) FROM session'), 104 | ]); 105 | 106 | asyncQuerySpy.should.have.been.called; 107 | asyncQuerySpy.callCount.should.equal(3 + 2); 108 | 109 | ensureSessionStoreTableSpy.should.have.been.called; 110 | ensureSessionStoreTableSpy.callCount.should.equal(3 + 2); 111 | 112 | rawEnsureSessionStoreTableSpy.should.have.been.calledOnce; 113 | 114 | await queryPromise('SELECT COUNT(sid) FROM session'); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/integration/pgpromise.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable promise/prefer-await-to-then */ 2 | // @ts-check 3 | 4 | 'use strict'; 5 | 6 | const chai = require('chai'); 7 | const chaiAsPromised = require('chai-as-promised'); 8 | const sinon = require('sinon'); 9 | const request = require('supertest'); 10 | 11 | chai.use(chaiAsPromised); 12 | chai.should(); 13 | 14 | const express = require('express'); 15 | const session = require('express-session'); 16 | const pgp = require('pg-promise')(); 17 | 18 | const connectPgSimple = require('../..'); 19 | const dbUtils = require('../db-utils'); 20 | const conObject = dbUtils.conObject; 21 | const queryPromise = dbUtils.queryPromise; 22 | 23 | const pgPromise = pgp(conObject); 24 | 25 | describe('pgPromise', () => { 26 | const secret = 'abc123'; 27 | const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days 28 | 29 | /** 30 | * @param {import('../..').ExpressSessionStore} store 31 | * @returns {import('express').Express} 32 | */ 33 | const appSetup = (store) => { 34 | const app = express(); 35 | 36 | app.use(session({ 37 | store, 38 | secret, 39 | resave: false, 40 | saveUninitialized: true, 41 | cookie: { maxAge }, 42 | })); 43 | 44 | app.get('/', (_req, res) => { 45 | res.send('Hello World!'); 46 | }); 47 | 48 | return app; 49 | }; 50 | 51 | beforeEach(async () => { 52 | await dbUtils.removeTables(); 53 | await dbUtils.initTables(); 54 | }); 55 | 56 | afterEach(() => { 57 | sinon.restore(); 58 | }); 59 | 60 | describe('main', () => { 61 | it('should generate a token', () => { 62 | const store = new (connectPgSimple(session))({ pgPromise }); 63 | const app = appSetup(store); 64 | 65 | return queryPromise('SELECT COUNT(sid) FROM session') 66 | .should.eventually.have.nested.property('rows[0].count', '0') 67 | .then(() => request(app) 68 | .get('/') 69 | .expect(200) 70 | ) 71 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 72 | .should.eventually.have.nested.property('rows[0].count', '1'); 73 | }); 74 | 75 | it('should reuse existing session when given a cookie', () => { 76 | const store = new (connectPgSimple(session))({ pgPromise }); 77 | const app = appSetup(store); 78 | const agent = request.agent(app); 79 | 80 | return queryPromise('SELECT COUNT(sid) FROM session') 81 | .should.eventually.have.nested.property('rows[0].count', '0') 82 | .then(() => agent.get('/')) 83 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 84 | .should.eventually.have.nested.property('rows[0].count', '1') 85 | .then(() => agent.get('/').expect(200)) 86 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 87 | .should.eventually.have.nested.property('rows[0].count', '1'); 88 | }); 89 | 90 | it('should invalidate a too old token', () => { 91 | const store = new (connectPgSimple(session))({ pgPromise, pruneSessionInterval: false }); 92 | const app = appSetup(store); 93 | const agent = request.agent(app); 94 | 95 | const clock = sinon.useFakeTimers({ now: Date.now(), shouldClearNativeTimers: true }); 96 | 97 | return queryPromise('SELECT COUNT(sid) FROM session') 98 | .should.eventually.have.nested.property('rows[0].count', '0') 99 | .then(() => Promise.all([ 100 | request(app).get('/'), 101 | agent.get('/'), 102 | ])) 103 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 104 | .should.eventually.have.nested.property('rows[0].count', '2') 105 | .then(() => { 106 | clock.tick(maxAge * 0.6); 107 | // eslint-disable-next-line unicorn/no-useless-undefined 108 | return new Promise((resolve, reject) => store.pruneSessions(/** @param {Error} err */ err => { err ? reject(err) : resolve(undefined); })); 109 | }) 110 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 111 | .should.eventually.have.nested.property('rows[0].count', '2') 112 | .then(() => agent.get('/').expect(200)) 113 | .then(() => { 114 | clock.tick(maxAge * 0.6); 115 | // eslint-disable-next-line unicorn/no-useless-undefined 116 | return new Promise((resolve, reject) => store.pruneSessions(/** @param {Error} err */ err => { err ? reject(err) : resolve(undefined); })); 117 | }) 118 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 119 | .should.eventually.have.nested.property('rows[0].count', '1'); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connect PG Simple 2 | 3 | A simple, minimal PostgreSQL session store for Express/Connect 4 | 5 | [![npm version](https://img.shields.io/npm/v/connect-pg-simple.svg?style=flat)](https://www.npmjs.com/package/connect-pg-simple) 6 | [![npm downloads](https://img.shields.io/npm/dm/connect-pg-simple.svg?style=flat)](https://www.npmjs.com/package/connect-pg-simple) 7 | [![Module type: CJS](https://img.shields.io/badge/module%20type-cjs-brightgreen)](https://github.com/voxpelli/badges-cjs-esm) 8 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard) 9 | [![Follow @voxpelli@mastodon.social](https://img.shields.io/mastodon/follow/109247025527949675?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@voxpelli) 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install connect-pg-simple 15 | ``` 16 | 17 | **Once npm installed the module, you need to create the _"session"_ table in your database.** 18 | 19 | For that you can use the [table.sql](table.sql) file provided with the module: 20 | 21 | ```bash 22 | psql mydatabase < node_modules/connect-pg-simple/table.sql 23 | ``` 24 | 25 | Or simply play the file via a GUI, like the pgAdminIII queries tool. 26 | 27 | Or instruct this module to create it itself, by setting the `createTableIfMissing` option. 28 | 29 | Note that `connect-pg-simple` requires PostgreSQL version 9.5 or above. 30 | 31 | ## Usage 32 | 33 | Examples are based on Express 4. 34 | 35 | Simple example: 36 | 37 | ```javascript 38 | const session = require('express-session'); 39 | 40 | app.use(session({ 41 | store: new (require('connect-pg-simple')(session))({ 42 | // Insert connect-pg-simple options here 43 | }), 44 | secret: process.env.FOO_COOKIE_SECRET, 45 | resave: false, 46 | cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 } // 30 days 47 | // Insert express-session options here 48 | })); 49 | ``` 50 | 51 | Advanced example showing some custom options: 52 | 53 | ```javascript 54 | const pg = require('pg'); 55 | const expressSession = require('express-session'); 56 | const pgSession = require('connect-pg-simple')(expressSession); 57 | 58 | const pgPool = new pg.Pool({ 59 | // Insert pool options here 60 | }); 61 | 62 | app.use(expressSession({ 63 | store: new pgSession({ 64 | pool : pgPool, // Connection pool 65 | tableName : 'user_sessions' // Use another table-name than the default "session" one 66 | // Insert connect-pg-simple options here 67 | }), 68 | secret: process.env.FOO_COOKIE_SECRET, 69 | resave: false, 70 | cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 } // 30 days 71 | // Insert express-session options here 72 | })); 73 | ``` 74 | 75 | ## Advanced options 76 | 77 | 78 | ### Connection options 79 | 80 | Listed in the order they will be picked up. If multiple are defined, then the first in the lists that is defined will be used, the rest ignored. 81 | 82 | * **pool** - _The recommended one_ – Connection pool object (compatible with [pg.Pool](https://github.com/brianc/node-pg-pool)) for the underlying database module. 83 | * **pgPromise** - Database object from `pg-promise` to be used for DB communications. 84 | * **conObject** - If you don't specify a pool object, use this option or `conString` to specify a [PostgreSQL Pool connection object](https://node-postgres.com/api/client#constructor) and this module will create a new pool for you. 85 | * **conString** - If you don't specify a pool object, use this option or `conObject` to specify a PostgreSQL connection string like `postgres://user:password@host:5432/database` and this module will create a new pool for you. If there's a connection string in the `DATABASE_URL` environment variable (as it is by default on eg. Heroku) then this module will fallback to that if no other connection method has been specified. 86 | 87 | ### Other options 88 | 89 | * **ttl** - the time to live for the session in the database – specified in seconds. Defaults to the cookie maxAge if the cookie has a maxAge defined and otherwise defaults to one day. 90 | * **createTableIfMissing** - if set to `true` then creates the table in the case where the table does not already exist. Defaults to `false`. 91 | * **disableTouch** – boolean value that if set to `true` disables the updating of TTL in the database when using touch. Defaults to false. 92 | * **schemaName** - if your session table is in another Postgres schema than the default (it normally isn't), then you can specify that here. 93 | * **tableName** - if your session table is named something else than `session`, then you can specify that here. 94 | * **pruneSessionInterval** - sets the delay in seconds at which expired sessions are pruned from the database. Default is `900` seconds (15 minutes). If set to `false` no automatic pruning will happen. By default every delay is randomized between 50% and 150% of set value, resulting in an average delay equal to the set value, but spread out to even the load on the database. Automatic pruning will happen `pruneSessionInterval` seconds after the last pruning (includes manual prunes). 95 | * **pruneSessionRandomizedInterval** – if set to `false`, then the exact value of `pruneSessionInterval` will be used in all delays. No randomization will happen. If multiple instances all start at once, disabling randomization can mean that multiple instances are all triggering pruning at once, causing unnecessary load on the database. Can also be set to a method, taking a numeric `delay` parameter and returning a modified one, thus allowing a custom delay algorithm if wanted. 96 | * **errorLog** – the method used to log errors in those cases where an error can't be returned to a callback. Defaults to `console.error()`, but can be useful to override if one eg. uses [Bunyan](https://github.com/trentm/node-bunyan) for logging. 97 | 98 | ## Useful methods 99 | 100 | * **close()** – if this module used its own database module to connect to Postgres, then this will shut that connection down to allow a graceful shutdown. Returns a `Promise` that will resolve when the database has shut down. 101 | * **pruneSessions([callback(err)])** – will prune old sessions. Only really needed to be called if **pruneSessionInterval** has been set to `false` – which can be useful if one wants improved control of the pruning. 102 | 103 | ## For enterprise 104 | 105 | Available as part of the Tidelift Subscription. 106 | 107 | The maintainers of connect-pg-simple and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/npm-connect-pg-simple?utm_source=npm-connect-pg-simple&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) 108 | -------------------------------------------------------------------------------- /test/integration/express.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable promise/prefer-await-to-then */ 2 | /* eslint-disable unicorn/no-await-expression-member */ 3 | // @ts-check 4 | 5 | 'use strict'; 6 | 7 | const chai = require('chai'); 8 | const chaiAsPromised = require('chai-as-promised'); 9 | const sinon = require('sinon'); 10 | const request = require('supertest'); 11 | 12 | chai.use(chaiAsPromised); 13 | chai.should(); 14 | 15 | const express = require('express'); 16 | const session = require('express-session'); 17 | const Cookie = require('cookiejar').Cookie; 18 | const signature = require('cookie-signature'); 19 | 20 | const connectPgSimple = require('../..'); 21 | const dbUtils = require('../db-utils'); 22 | const conObject = dbUtils.conObject; 23 | const queryPromise = dbUtils.queryPromise; 24 | 25 | describe('Express', () => { 26 | const secret = 'abc123'; 27 | const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days 28 | /** @type {import('../..').ExpressSessionStore} */ 29 | let store; 30 | 31 | /** 32 | * @param {import('../..').ExpressSessionStore} store 33 | * @param {Partial} [sessionOptions] 34 | * @returns {import('express').Express} 35 | */ 36 | const appSetup = (store, sessionOptions = {}) => { 37 | const app = express(); 38 | 39 | app.use(session({ 40 | store, 41 | secret, 42 | resave: false, 43 | rolling: true, 44 | saveUninitialized: true, 45 | cookie: { maxAge }, 46 | ...sessionOptions, 47 | })); 48 | 49 | app.get('/', (_req, res) => { 50 | res.send('Hello World!'); 51 | }); 52 | 53 | return app; 54 | }; 55 | 56 | beforeEach(async () => { 57 | await dbUtils.removeTables(); 58 | await dbUtils.initTables(); 59 | }); 60 | 61 | afterEach(async () => { 62 | store && await store.close(); 63 | sinon.restore(); 64 | }); 65 | 66 | describe('main', () => { 67 | it('should generate a token', () => { 68 | store = new (connectPgSimple(session))({ conObject }); 69 | 70 | const app = appSetup(store); 71 | 72 | return queryPromise('SELECT COUNT(sid) FROM session') 73 | .should.eventually.have.nested.property('rows[0].count', '0') 74 | .then(() => request(app) 75 | .get('/') 76 | .expect(200) 77 | ) 78 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 79 | .should.eventually.have.nested.property('rows[0].count', '1'); 80 | }); 81 | 82 | it('should return the token it generates', () => { 83 | store = new (connectPgSimple(session))({ conObject }); 84 | 85 | const app = appSetup(store); 86 | 87 | return request(app) 88 | .get('/') 89 | .then(res => { 90 | const cookieHeader = /** @type {string[]|undefined} */ (res.header['set-cookie']); 91 | 92 | if (!cookieHeader || !cookieHeader[0]) { 93 | throw new TypeError('Unexpected cookie header'); 94 | } 95 | 96 | const sessionCookie = new Cookie(cookieHeader[0]); 97 | const cookieValue = decodeURIComponent(sessionCookie.value); 98 | 99 | cookieValue.slice(0, 2).should.equal('s:'); 100 | 101 | return signature.unsign(cookieValue.slice(2), secret); 102 | }) 103 | .then(decodedCookie => queryPromise('SELECT sid FROM session WHERE sid = $1', [decodedCookie])) 104 | .should.eventually.have.nested.property('rowCount', 1); 105 | }); 106 | 107 | it('should reuse existing session when given a cookie', () => { 108 | store = new (connectPgSimple(session))({ conObject }); 109 | 110 | const app = appSetup(store); 111 | const agent = request.agent(app); 112 | 113 | return queryPromise('SELECT COUNT(sid) FROM session') 114 | .should.eventually.have.nested.property('rows[0].count', '0') 115 | .then(() => agent.get('/')) 116 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 117 | .should.eventually.have.nested.property('rows[0].count', '1') 118 | .then(() => agent.get('/').expect(200)) 119 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 120 | .should.eventually.have.nested.property('rows[0].count', '1'); 121 | }); 122 | 123 | it('should not reuse existing session when not given a cookie', () => { 124 | store = new (connectPgSimple(session))({ conObject }); 125 | 126 | const app = appSetup(store); 127 | 128 | return queryPromise('SELECT COUNT(sid) FROM session') 129 | .should.eventually.have.nested.property('rows[0].count', '0') 130 | .then(() => request(app).get('/')) 131 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 132 | .should.eventually.have.nested.property('rows[0].count', '1') 133 | .then(() => request(app).get('/').expect(200)) 134 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 135 | .should.eventually.have.nested.property('rows[0].count', '2'); 136 | }); 137 | 138 | describe('touching', () => { 139 | it('should update expiry dates on existing sessions when rolling is set', async () => { 140 | const clock = sinon.useFakeTimers({ now: 1483228800000, shouldClearNativeTimers: true }); 141 | 142 | store = new (connectPgSimple(session))({ conObject }); 143 | 144 | const app = appSetup(store); 145 | const agent = request.agent(app); 146 | 147 | (await queryPromise('SELECT expire FROM session')).should.have.nested.property('rows').that.is.empty; 148 | 149 | await agent.get('/'); 150 | 151 | const firstResult = await queryPromise('SELECT extract(epoch from expire) AS expire FROM session'); 152 | firstResult.should.have.property('rows').that.has.length(1) 153 | .with.nested.property('[0].expire').that.matches(/\d+/); 154 | 155 | await clock.tickAsync(10000); 156 | 157 | await agent.get('/').expect(200); 158 | 159 | const secondResult = await queryPromise('SELECT extract(epoch from expire) AS expire FROM session'); 160 | secondResult.should.have.property('rows').that.has.length(1) 161 | .with.nested.property('[0].expire').that.matches(/\d+/); 162 | 163 | (secondResult.rows[0].expire - firstResult.rows[0].expire).should.equal(10); 164 | }); 165 | 166 | it('should not update expiry dates on existing sessions when disableTouch is set', async () => { 167 | const clock = sinon.useFakeTimers({ now: 1483228800000, shouldClearNativeTimers: true }); 168 | 169 | store = new (connectPgSimple(session))({ conObject, disableTouch: true }); 170 | 171 | const app = appSetup(store); 172 | const agent = request.agent(app); 173 | 174 | (await queryPromise('SELECT expire FROM session')).should.have.nested.property('rows').that.is.empty; 175 | 176 | await agent.get('/'); 177 | 178 | const firstResult = await queryPromise('SELECT extract(epoch from expire) AS expire FROM session'); 179 | firstResult.should.have.property('rows').that.has.length(1) 180 | .with.nested.property('[0].expire').that.matches(/\d+/); 181 | 182 | await clock.tickAsync(10000); 183 | 184 | await agent.get('/').expect(200); 185 | 186 | const secondResult = await queryPromise('SELECT extract(epoch from expire) AS expire FROM session'); 187 | secondResult.should.have.property('rows').that.has.length(1) 188 | .with.nested.property('[0].expire').that.matches(/\d+/); 189 | 190 | (secondResult.rows[0].expire - firstResult.rows[0].expire).should.equal(0); 191 | }); 192 | }); 193 | 194 | it('should invalidate a too old token', () => { 195 | store = new (connectPgSimple(session))({ conObject, pruneSessionInterval: false }); 196 | 197 | const app = appSetup(store); 198 | const agent = request.agent(app); 199 | 200 | const clock = sinon.useFakeTimers({ now: Date.now(), shouldClearNativeTimers: true }); 201 | 202 | return queryPromise('SELECT COUNT(sid) FROM session') 203 | .should.eventually.have.nested.property('rows[0].count', '0') 204 | .then(() => Promise.all([ 205 | request(app).get('/'), 206 | agent.get('/'), 207 | ])) 208 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 209 | .should.eventually.have.nested.property('rows[0].count', '2') 210 | .then(() => { 211 | clock.tick(maxAge * 0.6); 212 | // eslint-disable-next-line unicorn/no-useless-undefined 213 | return new Promise((resolve, reject) => store.pruneSessions(/** @param {Error} err */ err => { err ? reject(err) : resolve(undefined); })); 214 | }) 215 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 216 | .should.eventually.have.nested.property('rows[0].count', '2') 217 | .then(() => agent.get('/').expect(200)) 218 | .then(() => { 219 | clock.tick(maxAge * 0.6); 220 | // eslint-disable-next-line unicorn/no-useless-undefined 221 | return new Promise((resolve, reject) => store.pruneSessions(/** @param {Error} err */ err => { err ? reject(err) : resolve(undefined); })); 222 | }) 223 | .then(() => queryPromise('SELECT COUNT(sid) FROM session')) 224 | .should.eventually.have.nested.property('rows[0].count', '1'); 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [10.0.0](https://github.com/voxpelli/node-connect-pg-simple/compare/v9.0.1...v10.0.0) (2024-09-13) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * require node.js >=18 9 | 10 | ### 🩹 Fixes 11 | 12 | * add name to main function ([c98e2ec](https://github.com/voxpelli/node-connect-pg-simple/commit/c98e2ecc2fe5b99531d6368dd515f3db157d8825)) 13 | * **deps:** use latest version of `pg` ([81b5630](https://github.com/voxpelli/node-connect-pg-simple/commit/81b5630955c2b19c4797abcc10100cbc2020c256)) 14 | * require node.js >=18 ([d51b6ca](https://github.com/voxpelli/node-connect-pg-simple/commit/d51b6ca773ac151f44f6b96c7c17fbfffd280967)) 15 | 16 | 17 | ### 🧹 Chores 18 | 19 | * **deps:** update `knip` ([f78ef50](https://github.com/voxpelli/node-connect-pg-simple/commit/f78ef50516f1eeee8bf687cf0da2d4fb8114ffa6)) 20 | * **deps:** update `validate-conventional-commit` ([84f525a](https://github.com/voxpelli/node-connect-pg-simple/commit/84f525a1edc700958758cb72bd93412aefef144e)) 21 | * **deps:** update dependency dotenv to ^16.4.5 ([#308](https://github.com/voxpelli/node-connect-pg-simple/issues/308)) ([f97bd51](https://github.com/voxpelli/node-connect-pg-simple/commit/f97bd519c5e26e32b2c3291fabb58ba40d058a71)) 22 | * **deps:** update dependency express to ^4.21.0 ([#310](https://github.com/voxpelli/node-connect-pg-simple/issues/310)) ([01c9a55](https://github.com/voxpelli/node-connect-pg-simple/commit/01c9a55a09337d7c2e4b32da411c74a3c3549e7f)) 23 | * **deps:** update dependency express-session to ^1.18.0 ([#309](https://github.com/voxpelli/node-connect-pg-simple/issues/309)) ([eef0e31](https://github.com/voxpelli/node-connect-pg-simple/commit/eef0e31715ee8e3d8207a18a70fe33aa2a46257b)) 24 | * **deps:** update dependency pg-promise to ^11.9.1 ([#312](https://github.com/voxpelli/node-connect-pg-simple/issues/312)) ([a553301](https://github.com/voxpelli/node-connect-pg-simple/commit/a553301cf5f35a1f49831eea0c3f78cfeef92fe5)) 25 | * **deps:** update dev dependencies ([d4488dc](https://github.com/voxpelli/node-connect-pg-simple/commit/d4488dcd0f7e6c1d7e916268bc322733df27aa2b)) 26 | * **deps:** update dev dependencies ([21e41c4](https://github.com/voxpelli/node-connect-pg-simple/commit/21e41c445318a5337cc155fd8dad5820601b6ef2)) 27 | * **deps:** update linting dependencies ([817f082](https://github.com/voxpelli/node-connect-pg-simple/commit/817f082769b22d59ce9780f42251d768f54a8b50)) 28 | * **deps:** update linting dependencies ([a4e9e46](https://github.com/voxpelli/node-connect-pg-simple/commit/a4e9e469cb52ddb72db108930ee56dfab07e6934)) 29 | * **deps:** update type dependencies ([97e581f](https://github.com/voxpelli/node-connect-pg-simple/commit/97e581f8f8a3a29372f697f77a1514259c75b05f)) 30 | * **deps:** update typescript setup ([7c86411](https://github.com/voxpelli/node-connect-pg-simple/commit/7c8641113c17b6294463cebd3c636fdddd4dbbfe)) 31 | * **deps:** use neostandard linting ([354f6b3](https://github.com/voxpelli/node-connect-pg-simple/commit/354f6b310f5fa6225b34ae49d9210ee6694e53c8)) 32 | * fix @types/superagent type regression ([649888c](https://github.com/voxpelli/node-connect-pg-simple/commit/649888c887b418bb67d9ce638cc05435cc1ede74)) 33 | 34 | ## [9.0.1](https://github.com/voxpelli/node-connect-pg-simple/compare/v9.0.0...v9.0.1) (2023-11-01) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * 18: Move @types/pg to dev dependencies ([ea4a9c1](https://github.com/voxpelli/node-connect-pg-simple/commit/ea4a9c1d26f4a712a59ab198f3192894c16db963)) 40 | 41 | ## 9.0.0 (2023-06-07) 42 | 43 | - **Breaking change:** Require Node version `>=16.0.0` 44 | - **Notable:** Start automatic pruning lazily. Wait for first use of the session manager before scheduling the pruning. #285 45 | - **Change:** Log whole error object instead of only message. Thanks @safareli! #225 46 | - ...and internal updates to dev dependencies etc 47 | 48 | ## 8.0.0 (2022-11-12) 49 | 50 | - **Breaking change:** Require Node version `^14.18.0 || >=16.0.0` 51 | - **Notable:** Mark most private methods and properties as actually private using the [Private class feature](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_class_fields) (having the name begin with a `#`) This **can be breaking** if you relied on those properties or methods 52 | - **Internal:** Update included version of `pg` 53 | - **Internal:** Use `node:` to import built in modules 54 | - ...and a lot of updates to dev dependencies, GitHub Action workflows etc 55 | 56 | ## 7.0.0 (2021-09-06) 57 | 58 | * **Breaking change:** Now requires at least Node.js 12.x 59 | * **Internal:** Updated some developer dependencies and test targets 60 | * As well as all changes in `7.0.0-0` 61 | 62 | ## 7.0.0-0 (2021-01-18) 63 | 64 | * **Possibly breaking change:** Align session expiration logic with [connect-redis](https://github.com/tj/connect-redis/blob/30efd159103ace270c844a5967428b43e7b8ba4a/migration-to-v4.md#changes-to-ttl-management) which in turned aligned with connect-mongo. Fixes #54. 65 | * **Minor breaking change:** The `.close()` method is now async and returns a `Promise` that will be resolved when this session store is fully shut down. Fixes #183. 66 | * **Minor breaking change:** Now requires Node version `^10.17.0 || >=11.14.0` 67 | * **Feature:** New option: `disableTouch`. Disables updating of TTL in database on touch. Fixes #55. 68 | * **Feature:** New option: `createTableIfMissing`. When set, the session table will be automatically created if missing. Fixes #158 and #173. Thanks @aadeshmisra! 69 | * **Tweak**: Slightly tweaked the pg-promise integration. Fixes #153. 70 | * **Tweak**: Introduced a new internal `_asyncQuery()` function in a move to modernize internal code on top of Promise / async / await. 71 | 72 | ## 6.2.1 (2020-08-19) 73 | 74 | * **Fix:** Regression, query errors wasn't properly forwarded. Fixes #180 and #179. Thanks @alxndrsn! (5c324ac) 75 | * **Test:** Added test for above regression (fd36978) 76 | * **Change:** Improved types + error return values (f73ea0d 68a2242) 77 | * **Change:** Updated SECURITY.md to delegate security reports to Tidelift, and thus ensure quicker responses (7683d40 59c7fbc) 78 | 79 | ## 6.2.0 (2020-08-06) 80 | 81 | * **Important fix**: Bump pg to 8.2.1 to support node 14+ 82 | * **Change**: Change default prine interval to 15 mins 83 | * **Test**: Add Node 14 to GitHub CI 84 | * **Test**: Added more types and type linting 85 | 86 | ## 6.1.0 (2019-12-29) 87 | 88 | * **Feature**: Prune intervals are now by default randomized between 50% and 150% of the set prune value, making the average prune interval be the same as before, but makes database load more even by making it unlikely for eg. many instances to all prune at once. 89 | * **Feature**: New option `pruneSessionRandomizedInterval` enables deactivation + customization of the new random prune interval feature. 90 | * **Change**: Default prune interval is now `5` minutes, rather than `1` minute. No need to clean extremely often. Will probably make even longer eventually, but a more drastic change could be kind of a breaking change. Please comment in #162 with feedback on future default. 91 | * **Performance**: The database schema definition now specifies an index on the `expire` column. You have to **add this yourself** if you have already set up this module. The change is purely for enhancing performance and can be skipped if no performance issues have been experiences. It is recommended to apply it though. 92 | 93 | ## 6.0.1 (2019-08-21) 94 | 95 | * **Very minor security fix**: `schemaName` and `tableName` wasn't escaped. If any of the two contained a string with a double quote in it, then that would enable an SQL injection. This was previously a feature of `tableName`, before the introduction of a separate `schemaName`, as that allowed a schema to be defined as part of `tableName`. Defining schema name through `tableName` is still supported after this fix, but is now *deprecated*. 96 | * **Fix**: Errors wasn't propagated properly. Fixed in #150. Thanks @bobnil! 97 | 98 | ## 6.0.0 (2019-07-28) 99 | 100 | * **Breaking change**: Now requires at least Node.js 10.x, this as Node.js 8.x [only have a short time left in its LTS](https://github.com/nodejs/Release) 101 | * **Breaking change**: This project now uses [`INSERT ... ON CONFLICT`](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT), more popularly known as `UPSERT`. This is only supported on PostgreSQL version 9.5 and above. 102 | * Listen on pool errors. Fixes #29 103 | 104 | ## 5.0.0 (2018-06-06) 105 | 106 | * **Breaking change**: Now requires at least Node.js 8.x (this as Node.js 6.x [only have a short time left in its LTS](https://github.com/nodejs/Release) and I rather don't bump the major version more often than I have to) 107 | * **Breaking change**: Now expects [pg](https://www.npmjs.com/package/pg) 7.x to be used 108 | * **Fix**: Connection string is now handled by [pg](https://www.npmjs.com/package/pg) instead of by this module. Should improve support for things like ssl. 109 | 110 | ## 4.2.1 (2017-08-20) 111 | 112 | * **Fix**: The pruning timer will no longer keep Node alive, it's been given the [`unref()`](https://nodejs.org/api/timers.html#timers_timeout_unref) treatment 113 | 114 | ## 4.2.0 (2017-05-20) 115 | 116 | * **Feature**: New option `pgPromise` enables the library to re-use an existing connection from [pg-promise](https://github.com/vitaly-t/pg-promise). This is a mutually-exclusive alternative to specifying `pool`, `conObject`, or `conString` (only one of these can be provided). 117 | 118 | ## 4.1.0 (2017-05-19) 119 | 120 | * **Feature**: New option `conObject` enables connection details to be set through an object 121 | * **Improvement**: Hardening of `conString` parsing + some added tests of it 122 | 123 | ## 4.0.0 (2017-05-19) 124 | 125 | * **Breaking change + improved support**: When the [pg](https://www.npmjs.com/package/pg) module is provided to this module, then a pool from the new `6.x` version of that module is now required rather than providing the module itself 126 | 127 | ## 3.1.2 128 | 129 | * **Bug fix**: Previous timestamp fix failed critically, fixing it again. Thanks @G3z and @eemeli 130 | 131 | ## 3.1.1 132 | 133 | * **Bug fix**: The internal query helper was treating params() wrong when called with two argument. Thanks for reporting @colideum! 134 | * **Bug fix**: If the database and the node instances had different clocks, then things wouldn't work that well due to mixed timestamp sources. Now node handles all timestamps. Thanks for reporting @sverkoye! 135 | 136 | ## 3.1.0 137 | 138 | * **Feature**: Support the `store.touch()` method to allow for extending the life time of a session without changing the data of it. This enables setting the `resave` option to `false`, which is recommended to avoid a session extender save overwriting another save that adds new data to the session. More info in the [express-session readme](https://github.com/expressjs/session#resave). 139 | * **Fix**: Relax the engine requirements – accept newer versions of Node.js/iojs as well 140 | 141 | ## 3.0.2 142 | 143 | * **Fix**: Added support for [sails](http://sailsjs.org/) by supporting sending the full Express 3 object into the plugin 144 | 145 | ## 3.0.1 146 | 147 | * **Fix**: If the `pg` instance used is created by this module, then this module should also close it on `close()` 148 | 149 | ## 3.0.0 150 | 151 | * **Improvement**: Rather than randomly cleaning up expired sessions that will now happen at the `options.pruneSessionInterval` defined interval. 152 | * **Breaking change**: Clients now need to close the session store to gracefully shut down their app as the pruning of sessions can't know when the rest of the app has stopped running and thus can't know when to stop pruning sessions if it itsn't told so explicitly through thew new `close()` method – or by deactivating the automatic pruning by settinging `options.pruneSessionInterval` to `false`. If automatic pruning is disabled the client needs to call `pruneSessions()` manually or otherwise ensure that old sessions are pruned. 153 | 154 | ## 2.3.0 155 | 156 | * **Fix regression**: No longer default to `public` schema, as added in `2.2.0`, but rather default to the pre-`2.2.0` behavior of no defined schema. This to ensure backwards compatibility with the `2.x` branch, per semantic versioning best practise. 157 | 158 | ## 2.2.1 159 | 160 | * **Hotfix**: Update `require('pg')` to match package.json, thanks for reporting @dmitriiabramov 161 | 162 | ## 2.2.0 163 | 164 | * **New**: Now possibly to set another schema than the default 165 | * **Change**: Now using the `pg` dependency again rather than `pg.js` as the latter will be discontinued as `pg` now fills its role 166 | 167 | ## 2.1.1 168 | 169 | * Fix bug with creating new sessions that was caused by 2.1.0 170 | 171 | ## 2.1.0 172 | 173 | * Enable the table name to be configured through new `tableName` option 174 | 175 | ## 2.0.0 176 | 177 | * **Backwards incompatible change**: Support for Express 4 means that Express 3 apps (and similar for Connect apps) should send `express.session` to the module rather than just `express`. 178 | * **Dependency change**: The database module is now [pg.js](https://www.npmjs.org/package/pg.js) rather than [pg](https://www.npmjs.org/package/pg) – same library, but without compilation of any native bindings and thus less delay when eg. installing the application from scratch. 179 | 180 | ## 1.0.2 181 | 182 | * Support for PostgreSQL versions older than 9.2 183 | 184 | ## 1.0.1 185 | 186 | * Fix for sometimes not expiring sessions correctly 187 | 188 | ## 1.0.0 189 | 190 | * First NPM-version of the script originally published as a Gist here: https://gist.github.com/voxpelli/6447728 191 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /// 3 | 4 | 'use strict'; 5 | 6 | const DEFAULT_PRUNE_INTERVAL_IN_SECONDS = 60 * 15; 7 | const ONE_DAY = 86400; 8 | 9 | /** @typedef {*} ExpressSession */ 10 | /** @typedef {*} ExpressSessionStore */ 11 | 12 | /** 13 | * Inspired by util.callbackify() 14 | * 15 | * Never throws, even if callback is left out, as that's how it was 16 | * 17 | * @template T 18 | * @param {Promise} value 19 | * @param {((err: Error|null, result: T) => void)|undefined} cb 20 | * @returns {void} 21 | */ 22 | const callbackifyPromiseResolution = (value, cb) => { 23 | if (!cb) { 24 | // eslint-disable-next-line promise/prefer-await-to-then 25 | value.catch(() => {}); 26 | } else { 27 | // eslint-disable-next-line promise/catch-or-return, promise/prefer-await-to-then 28 | value.then( 29 | // eslint-disable-next-line unicorn/no-null 30 | (ret) => process.nextTick(cb, null, ret), 31 | (err) => process.nextTick(cb, err || new Error('Promise was rejected with falsy value')) 32 | ); 33 | } 34 | }; 35 | 36 | /** @returns {number} */ 37 | const currentTimestamp = () => Math.ceil(Date.now() / 1000); 38 | 39 | /** 40 | * @see https://www.postgresql.org/docs/9.5/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS 41 | * @param {string} value 42 | * @returns {string} 43 | */ 44 | const escapePgIdentifier = (value) => value.replaceAll('"', '""'); 45 | 46 | /** @typedef {(err: Error|null) => void} SimpleErrorCallback */ 47 | 48 | /** @typedef {{ cookie: { maxAge?: number, expire?: number, [property: string]: any }, [property: string]: any }} SessionObject */ 49 | 50 | /** @typedef {(delay: number) => number} PGStorePruneDelayRandomizer */ 51 | /** @typedef {Object} PGStoreQueryResult */ 52 | /** @typedef {(err: Error|null, firstRow?: PGStoreQueryResult) => void} PGStoreQueryCallback */ 53 | 54 | /** 55 | * @typedef PGStoreOptions 56 | * @property {string} [schemaName] 57 | * @property {string} [tableName] 58 | * @property {boolean} [createTableIfMissing] 59 | * @property {number} [ttl] 60 | * @property {boolean} [disableTouch] 61 | * @property {typeof console.error} [errorLog] 62 | * @property {import('pg').Pool} [pool] 63 | * @property {*} [pgPromise] 64 | * @property {string} [conString] 65 | * @property {*} [conObject] 66 | * @property {false|number} [pruneSessionInterval] 67 | * @property {false|PGStorePruneDelayRandomizer} [pruneSessionRandomizedInterval] 68 | */ 69 | 70 | /** 71 | * @param {ExpressSession} session 72 | * @returns {ExpressSessionStore} 73 | */ 74 | module.exports = function connectPgSimple (session) { 75 | /** @type {ExpressSessionStore} */ 76 | const Store = session.Store || 77 | // @ts-ignore 78 | session.session.Store; 79 | 80 | class PGStore extends Store { 81 | /** @type {boolean} */ 82 | #createTableIfMissing; 83 | /** @type {boolean} */ 84 | #disableTouch; 85 | /** @type {typeof console.error} */ 86 | #errorLog; 87 | /** @type {boolean} */ 88 | #ownsPg; 89 | /** @type {*} */ 90 | #pgPromise; 91 | /** @type {import('pg').Pool|undefined} */ 92 | #pool; 93 | /** @type {false|number} */ 94 | #pruneSessionInterval; 95 | /** @type {PGStorePruneDelayRandomizer|undefined} */ 96 | #pruneSessionRandomizedInterval; 97 | /** @type {string|undefined} */ 98 | #schemaName; 99 | /** @type {Promise|undefined} */ 100 | #tableCreationPromise; 101 | /** @type {string} */ 102 | #tableName; 103 | 104 | /** @param {PGStoreOptions} options */ 105 | constructor (options = {}) { 106 | super(options); 107 | 108 | this.#schemaName = options.schemaName ? escapePgIdentifier(options.schemaName) : undefined; 109 | this.#tableName = options.tableName ? escapePgIdentifier(options.tableName) : 'session'; 110 | 111 | if (!this.#schemaName && this.#tableName.includes('"."')) { 112 | // eslint-disable-next-line no-console 113 | console.warn('DEPRECATION WARNING: Schema should be provided through its dedicated "schemaName" option rather than through "tableName"'); 114 | this.#tableName = this.#tableName.replace(/^([^"]+)""\.""([^"]+)$/, '$1"."$2'); 115 | } 116 | 117 | this.#createTableIfMissing = !!options.createTableIfMissing; 118 | this.#tableCreationPromise = undefined; 119 | 120 | this.ttl = options.ttl; // TODO: Make this private as well, some bug in at least TS 4.6.4 stops that 121 | this.#disableTouch = !!options.disableTouch; 122 | 123 | // eslint-disable-next-line no-console 124 | this.#errorLog = options.errorLog || console.error.bind(console); 125 | 126 | if (options.pool !== undefined) { 127 | this.#pool = options.pool; 128 | this.#ownsPg = false; 129 | } else if (options.pgPromise !== undefined) { 130 | if (typeof options.pgPromise.any !== 'function') { 131 | throw new TypeError('`pgPromise` config must point to an existing and configured instance of pg-promise pointing at your database'); 132 | } 133 | this.#pgPromise = options.pgPromise; 134 | this.#ownsPg = false; 135 | } else { 136 | // eslint-disable-next-line n/no-process-env 137 | const conString = options.conString || process.env['DATABASE_URL']; 138 | let conObject = options.conObject; 139 | 140 | if (!conObject) { 141 | conObject = {}; 142 | 143 | if (conString) { 144 | conObject.connectionString = conString; 145 | } 146 | } 147 | this.#pool = new (require('pg')).Pool(conObject); 148 | this.#pool.on('error', err => { 149 | this.#errorLog('PG Pool error:', err); 150 | }); 151 | this.#ownsPg = true; 152 | } 153 | 154 | if (options.pruneSessionInterval === false) { 155 | this.#pruneSessionInterval = false; 156 | } else { 157 | this.#pruneSessionInterval = (options.pruneSessionInterval || DEFAULT_PRUNE_INTERVAL_IN_SECONDS) * 1000; 158 | if (options.pruneSessionRandomizedInterval !== false) { 159 | this.#pruneSessionRandomizedInterval = ( 160 | options.pruneSessionRandomizedInterval || 161 | // Results in at least 50% of the specified interval and at most 150%. Makes it so that multiple instances doesn't all prune at the same time. 162 | (delay => Math.ceil(delay / 2 + delay * Math.random())) 163 | ); 164 | } 165 | } 166 | } 167 | 168 | /** 169 | * Ensures the session store table exists, creating it if its missing 170 | * 171 | * @access private 172 | * @returns {Promise} 173 | */ 174 | async _rawEnsureSessionStoreTable () { 175 | const quotedTable = this.quotedTable(); 176 | 177 | const res = await this._asyncQuery('SELECT to_regclass($1::text)', [quotedTable], true); 178 | 179 | if (res && res['to_regclass'] === null) { 180 | const pathModule = require('node:path'); 181 | const fs = require('node:fs').promises; 182 | 183 | const tableDefString = await fs.readFile(pathModule.resolve(__dirname, './table.sql'), 'utf8'); 184 | const tableDefModified = tableDefString.replaceAll('"session"', quotedTable); 185 | 186 | await this._asyncQuery(tableDefModified, [], true); 187 | } 188 | } 189 | 190 | /** 191 | * Ensures the session store table exists, creating it if its missing 192 | * 193 | * @access private 194 | * @param {boolean|undefined} noTableCreation 195 | * @returns {Promise} 196 | */ 197 | async _ensureSessionStoreTable (noTableCreation) { 198 | if (noTableCreation || this.#createTableIfMissing === false) return; 199 | 200 | if (!this.#tableCreationPromise) { 201 | this.#tableCreationPromise = this._rawEnsureSessionStoreTable(); 202 | } 203 | 204 | return this.#tableCreationPromise; 205 | } 206 | 207 | /** 208 | * Closes the session store 209 | * 210 | * Currently only stops the automatic pruning, if any, from continuing 211 | * 212 | * @access public 213 | * @returns {Promise} 214 | */ 215 | async close () { 216 | this.closed = true; 217 | 218 | this.#clearPruneTimer(); 219 | 220 | if (this.#ownsPg && this.#pool) { 221 | await this.#pool.end(); 222 | } 223 | } 224 | 225 | #initPruneTimer () { 226 | if (this.#pruneSessionInterval && !this.closed && !this.pruneTimer) { 227 | const delay = this.#pruneSessionRandomizedInterval 228 | ? this.#pruneSessionRandomizedInterval(this.#pruneSessionInterval) 229 | : this.#pruneSessionInterval; 230 | 231 | this.pruneTimer = setTimeout( 232 | () => { this.pruneSessions(); }, 233 | delay 234 | ); 235 | this.pruneTimer.unref(); 236 | } 237 | } 238 | 239 | #clearPruneTimer () { 240 | if (this.pruneTimer) { 241 | clearTimeout(this.pruneTimer); 242 | this.pruneTimer = undefined; 243 | } 244 | } 245 | 246 | /** 247 | * Does garbage collection for expired session in the database 248 | * 249 | * @param {SimpleErrorCallback} [fn] - standard Node.js callback called on completion 250 | * @returns {void} 251 | * @access public 252 | */ 253 | pruneSessions (fn) { 254 | this.query('DELETE FROM ' + this.quotedTable() + ' WHERE expire < to_timestamp($1)', [currentTimestamp()], err => { 255 | if (fn && typeof fn === 'function') { 256 | return fn(err); 257 | } 258 | 259 | if (err) { 260 | this.#errorLog('Failed to prune sessions:', err); 261 | } 262 | 263 | this.#clearPruneTimer(); 264 | this.#initPruneTimer(); 265 | }); 266 | } 267 | 268 | /** 269 | * Get the quoted table. 270 | * 271 | * @returns {string} the quoted schema + table for use in queries 272 | * @access private 273 | */ 274 | quotedTable () { 275 | let result = '"' + this.#tableName + '"'; 276 | 277 | if (this.#schemaName) { 278 | result = '"' + this.#schemaName + '".' + result; 279 | } 280 | 281 | return result; 282 | } 283 | 284 | /** 285 | * Figure out when a session should expire 286 | * 287 | * @param {SessionObject} sess – the session object to store 288 | * @returns {number} the unix timestamp, in seconds 289 | * @access private 290 | */ 291 | #getExpireTime (sess) { 292 | let expire; 293 | 294 | if (sess && sess.cookie && sess.cookie['expires']) { 295 | const expireDate = new Date(sess.cookie['expires']); 296 | expire = Math.ceil(expireDate.valueOf() / 1000); 297 | } else { 298 | const ttl = this.ttl || ONE_DAY; 299 | expire = Math.ceil(Date.now() / 1000 + ttl); 300 | } 301 | 302 | return expire; 303 | } 304 | 305 | /** 306 | * Query the database. 307 | * 308 | * @param {string} query - the database query to perform 309 | * @param {any[]} [params] - the parameters of the query 310 | * @param {boolean} [noTableCreation] 311 | * @returns {Promise} 312 | * @access private 313 | */ 314 | async _asyncQuery (query, params, noTableCreation) { 315 | await this._ensureSessionStoreTable(noTableCreation); 316 | 317 | if (this.#pgPromise) { 318 | const res = await this.#pgPromise.any(query, params); 319 | return res && res[0] ? res[0] : undefined; 320 | } else { 321 | if (!this.#pool) throw new Error('Pool missing for some reason'); 322 | const res = await this.#pool.query(query, params); 323 | return res && res.rows && res.rows[0] ? res.rows[0] : undefined; 324 | } 325 | } 326 | 327 | /** 328 | * Query the database. 329 | * 330 | * @param {string} query - the database query to perform 331 | * @param {any[]|PGStoreQueryCallback} [params] - the parameters of the query or the callback function 332 | * @param {PGStoreQueryCallback} [fn] - standard Node.js callback returning the resulting rows 333 | * @param {boolean} [noTableCreation] 334 | * @returns {void} 335 | * @access private 336 | */ 337 | query (query, params, fn, noTableCreation) { 338 | /** @type {any[]} */ 339 | let resolvedParams; 340 | 341 | if (typeof params === 'function') { 342 | if (fn) throw new Error('Two callback functions set at once'); 343 | fn = params; 344 | resolvedParams = []; 345 | } else { 346 | resolvedParams = params || []; 347 | } 348 | 349 | const result = this._asyncQuery(query, resolvedParams, noTableCreation); 350 | 351 | callbackifyPromiseResolution(result, fn); 352 | } 353 | 354 | /** 355 | * Attempt to fetch session by the given `sid`. 356 | * 357 | * @param {string} sid – the session id 358 | * @param {(err: Error|null, firstRow?: PGStoreQueryResult) => void} fn – a standard Node.js callback returning the parsed session object 359 | * @access public 360 | */ 361 | get (sid, fn) { 362 | this.#initPruneTimer(); 363 | 364 | this.query('SELECT sess FROM ' + this.quotedTable() + ' WHERE sid = $1 AND expire >= to_timestamp($2)', [sid, currentTimestamp()], (err, data) => { 365 | if (err) { return fn(err); } 366 | // eslint-disable-next-line unicorn/no-null 367 | if (!data) { return fn(null); } 368 | try { 369 | // eslint-disable-next-line unicorn/no-null 370 | return fn(null, (typeof data['sess'] === 'string') ? JSON.parse(data['sess']) : data['sess']); 371 | } catch { 372 | return this.destroy(sid, fn); 373 | } 374 | }); 375 | } 376 | 377 | /** 378 | * Commit the given `sess` object associated with the given `sid`. 379 | * 380 | * @param {string} sid – the session id 381 | * @param {SessionObject} sess – the session object to store 382 | * @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object 383 | * @access public 384 | */ 385 | set (sid, sess, fn) { 386 | this.#initPruneTimer(); 387 | 388 | const expireTime = this.#getExpireTime(sess); 389 | const query = 'INSERT INTO ' + this.quotedTable() + ' (sess, expire, sid) SELECT $1, to_timestamp($2), $3 ON CONFLICT (sid) DO UPDATE SET sess=$1, expire=to_timestamp($2) RETURNING sid'; 390 | 391 | this.query( 392 | query, 393 | [sess, expireTime, sid], 394 | err => { fn && fn(err); } 395 | ); 396 | } 397 | 398 | /** 399 | * Destroy the session associated with the given `sid`. 400 | * 401 | * @param {string} sid – the session id 402 | * @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object 403 | * @access public 404 | */ 405 | destroy (sid, fn) { 406 | this.#initPruneTimer(); 407 | 408 | this.query( 409 | 'DELETE FROM ' + this.quotedTable() + ' WHERE sid = $1', 410 | [sid], 411 | err => { fn && fn(err); } 412 | ); 413 | } 414 | 415 | /** 416 | * Touch the given session object associated with the given session ID. 417 | * 418 | * @param {string} sid – the session id 419 | * @param {SessionObject} sess – the session object to store 420 | * @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object 421 | * @access public 422 | */ 423 | touch (sid, sess, fn) { 424 | this.#initPruneTimer(); 425 | 426 | if (this.#disableTouch) { 427 | // eslint-disable-next-line unicorn/no-null 428 | fn && fn(null); 429 | return; 430 | } 431 | 432 | const expireTime = this.#getExpireTime(sess); 433 | 434 | this.query( 435 | 'UPDATE ' + this.quotedTable() + ' SET expire = to_timestamp($1) WHERE sid = $2 RETURNING sid', 436 | [expireTime, sid], 437 | err => { fn && fn(err); } 438 | ); 439 | } 440 | } 441 | 442 | return PGStore; 443 | }; 444 | -------------------------------------------------------------------------------- /test/main.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-process-env */ 2 | // @ts-check 3 | 4 | 'use strict'; 5 | 6 | const { promisify } = require('node:util'); 7 | 8 | const chai = require('chai'); 9 | const sinon = require('sinon'); 10 | const sinonChai = require('sinon-chai'); 11 | const proxyquire = require('proxyquire').noPreserveCache().noCallThru(); 12 | 13 | chai.use(sinonChai); 14 | 15 | const should = chai.should(); 16 | 17 | const connectPgSimple = require('..'); 18 | 19 | process.on('unhandledRejection', reason => { throw reason; }); 20 | 21 | describe('PGStore', () => { 22 | const DEFAULT_DELAY = 60 * 15 * 1000; 23 | 24 | /** @type {import('..').ExpressSessionStore} */ 25 | let PGStore; 26 | /** @type {import('..').PGStoreOptions} */ 27 | let options; 28 | 29 | beforeEach(() => { 30 | PGStore = connectPgSimple({ 31 | Store: class FakeStore {}, 32 | }); 33 | 34 | options = { 35 | // @ts-ignore 36 | pool: {}, 37 | pruneSessionInterval: false, 38 | }; 39 | }); 40 | 41 | afterEach(() => { 42 | sinon.restore(); 43 | }); 44 | 45 | describe('pruneSessions', () => { 46 | /** @type {import('sinon').SinonFakeTimers} */ 47 | let fakeClock; 48 | 49 | beforeEach(() => { 50 | fakeClock = sinon.useFakeTimers(); 51 | }); 52 | 53 | it('should by default run on randomized interval and close', async () => { 54 | const MOCKED_RANDOM = 0.1; 55 | const ACTUAL_DELAY = DEFAULT_DELAY / 2 + DEFAULT_DELAY * MOCKED_RANDOM; 56 | 57 | delete options.pruneSessionInterval; 58 | 59 | // Mocks and setup 60 | 61 | sinon.stub(Math, 'random').returns(MOCKED_RANDOM); 62 | 63 | const store = new PGStore(options); 64 | sinon.spy(store, 'pruneSessions'); 65 | 66 | const mock = sinon.mock(store); 67 | mock.expects('query').thrice().yields(); 68 | 69 | // Execution 70 | 71 | await fakeClock.tickAsync(1); 72 | 73 | store.pruneSessions.callCount.should.equal(0, 'Called from constructor'); 74 | 75 | await fakeClock.tickAsync(ACTUAL_DELAY); 76 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 77 | 78 | await promisify(store.set.bind(store))('123', { cookie: { foo: 'bar' } }); 79 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 80 | 81 | await fakeClock.tickAsync(ACTUAL_DELAY); 82 | store.pruneSessions.callCount.should.equal(1, 'Called lazily by interval'); 83 | 84 | await fakeClock.tickAsync(ACTUAL_DELAY); 85 | store.pruneSessions.callCount.should.equal(2, 'Called by interval'); 86 | store.close(); 87 | 88 | await fakeClock.tickAsync(ACTUAL_DELAY); 89 | 90 | store.pruneSessions.callCount.should.equal(2, 'Not called after close'); 91 | mock.verify(); 92 | }); 93 | 94 | it('should use custom delay method when provided', async () => { 95 | const ACTUAL_DELAY = 10000; 96 | 97 | delete options.pruneSessionInterval; 98 | options.pruneSessionRandomizedInterval = sinon.stub().returns(ACTUAL_DELAY); 99 | 100 | // Mocks and setup 101 | 102 | const store = new PGStore(options); 103 | sinon.spy(store, 'pruneSessions'); 104 | 105 | const mock = sinon.mock(store); 106 | mock.expects('query').thrice().yields(); 107 | 108 | // Execution 109 | 110 | await fakeClock.tickAsync(1); 111 | 112 | store.pruneSessions.callCount.should.equal(0, 'Called from constructor'); 113 | 114 | await fakeClock.tickAsync(ACTUAL_DELAY); 115 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 116 | 117 | await promisify(store.set.bind(store))('123', { cookie: { foo: 'bar' } }); 118 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 119 | 120 | await fakeClock.tickAsync(ACTUAL_DELAY); 121 | store.pruneSessions.callCount.should.equal(1, 'Called lazily by interval'); 122 | 123 | await fakeClock.tickAsync(ACTUAL_DELAY); 124 | store.pruneSessions.callCount.should.equal(2, 'Called by interval'); 125 | store.close(); 126 | mock.verify(); 127 | 128 | options.pruneSessionRandomizedInterval.should.have.been.calledThrice; 129 | }); 130 | 131 | it('should run on exactly the default interval and close when no randomness', async () => { 132 | delete options.pruneSessionInterval; 133 | options.pruneSessionRandomizedInterval = false; 134 | 135 | // Mocks and setup 136 | 137 | const store = new PGStore(options); 138 | sinon.spy(store, 'pruneSessions'); 139 | 140 | const mock = sinon.mock(store); 141 | mock.expects('query').thrice().yields(); 142 | 143 | // Execution 144 | 145 | await fakeClock.tickAsync(1); 146 | 147 | store.pruneSessions.callCount.should.equal(0, 'Not called from constructor'); 148 | 149 | await fakeClock.tickAsync(DEFAULT_DELAY); 150 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 151 | 152 | await promisify(store.set.bind(store))('123', { cookie: { foo: 'bar' } }); 153 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 154 | 155 | await fakeClock.tickAsync(DEFAULT_DELAY); 156 | store.pruneSessions.callCount.should.equal(1, 'Called lazily by interval'); 157 | 158 | await fakeClock.tickAsync(DEFAULT_DELAY); 159 | store.pruneSessions.callCount.should.equal(2, 'Called by interval'); 160 | store.close(); 161 | mock.verify(); 162 | }); 163 | 164 | it('should run on configurable interval', async () => { 165 | options.pruneSessionInterval = 1; 166 | options.pruneSessionRandomizedInterval = false; 167 | 168 | const store = new PGStore(options); 169 | 170 | sinon.spy(store, 'pruneSessions'); 171 | 172 | const mock = sinon.mock(store); 173 | 174 | mock.expects('query').thrice().yields(); 175 | 176 | await fakeClock.tickAsync(1); 177 | store.pruneSessions.callCount.should.equal(0, 'Not called from constructor'); 178 | 179 | await promisify(store.set.bind(store))('123', { cookie: { foo: 'bar' } }); 180 | store.pruneSessions.callCount.should.equal(0, 'Still not called'); 181 | 182 | await fakeClock.tickAsync(1000); 183 | store.pruneSessions.callCount.should.equal(1, 'Called lazily by custom interval'); 184 | 185 | await fakeClock.tickAsync(1000); 186 | store.pruneSessions.callCount.should.equal(2, 'Called by custom interval'); 187 | store.close(); 188 | mock.verify(); 189 | }); 190 | 191 | it('should not run when interval is disabled', async () => { 192 | const store = new PGStore(options); 193 | 194 | sinon.spy(store, 'pruneSessions'); 195 | 196 | const mock = sinon.mock(store); 197 | 198 | mock.expects('query').once().yields(); 199 | 200 | await fakeClock.tickAsync(1); 201 | store.pruneSessions.called.should.equal(false, 'Not called from constructor'); 202 | 203 | await fakeClock.tickAsync(60000); 204 | store.pruneSessions.called.should.equal(false, 'Not called by interval'); 205 | 206 | await promisify(store.set.bind(store))('123', { cookie: { foo: 'bar' } }); 207 | store.pruneSessions.called.should.equal(false, 'Not called lazily'); 208 | 209 | await fakeClock.tickAsync(60000); 210 | store.pruneSessions.called.should.equal(false, 'Not called by interval'); 211 | 212 | store.close(); 213 | mock.verify(); 214 | }); 215 | 216 | afterEach(() => { 217 | fakeClock.restore(); 218 | }); 219 | }); 220 | 221 | describe('quotedTable', () => { 222 | it('should not include a schema by default', () => { 223 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"session"'); 224 | }); 225 | 226 | it('should have an overrideable table', () => { 227 | options.tableName = 'foobar'; 228 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"foobar"'); 229 | }); 230 | 231 | it('should have a definable schema', () => { 232 | options.schemaName = 'barfoo'; 233 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"barfoo"."session"'); 234 | }); 235 | 236 | it('should accept custom schema and table', () => { 237 | options.tableName = 'foobar'; 238 | options.schemaName = 'barfoo'; 239 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"barfoo"."foobar"'); 240 | }); 241 | 242 | it('should accept legacy definition of schemas', () => { 243 | options.tableName = 'barfoo"."foobar'; 244 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"barfoo"."foobar"'); 245 | }); 246 | 247 | it('should not care about dots in names', () => { 248 | options.tableName = 'barfoo.foobar'; 249 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"barfoo.foobar"'); 250 | }); 251 | 252 | it('should escape table name', () => { 253 | options.tableName = 'foo"ba"r'; 254 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"foo""ba""r"'); 255 | }); 256 | 257 | it('should escape schema name', () => { 258 | options.schemaName = 'b""ar"foo'; 259 | (new PGStore(options)).quotedTable().should.be.a('string').that.equals('"b""""ar""foo"."session"'); 260 | }); 261 | }); 262 | 263 | describe('configSetup', () => { 264 | /** @type {import('sinon').SinonStub} */ 265 | let poolStub; 266 | /** @type {import('..').ExpressSessionStore} */ 267 | let ProxiedPGStore; 268 | /** @type {import('..').PGStoreOptions} */ 269 | let baseOptions; 270 | 271 | beforeEach(() => { 272 | delete process.env['DATABASE_URL']; 273 | 274 | poolStub = sinon.stub(); 275 | poolStub.prototype.on = () => {}; 276 | 277 | const PGMock = { Pool: poolStub }; 278 | const proxiedConnectPgSimple = proxyquire('../', { pg: PGMock }); 279 | 280 | ProxiedPGStore = proxiedConnectPgSimple({ 281 | Store: class FakeStore {}, 282 | }); 283 | 284 | baseOptions = { pruneSessionInterval: false }; 285 | }); 286 | 287 | it('should support basic conString', () => { 288 | should.not.throw(() => { 289 | return new ProxiedPGStore(Object.assign(baseOptions, { 290 | conString: 'postgres://user:pass@localhost:1234/connect_pg_simple_test', 291 | })); 292 | }); 293 | 294 | poolStub.should.have.been.calledOnce; 295 | poolStub.firstCall.args.should.have.lengthOf(1); 296 | poolStub.firstCall.args[0].should.deep.equal({ 297 | connectionString: 'postgres://user:pass@localhost:1234/connect_pg_simple_test', 298 | }); 299 | }); 300 | 301 | it('should support basic conObject', () => { 302 | should.not.throw(() => { 303 | return new ProxiedPGStore(Object.assign(baseOptions, { 304 | conObject: { 305 | user: 'user', 306 | password: 'pass', 307 | host: 'localhost', 308 | port: 1234, 309 | database: 'connect_pg_simple_test', 310 | }, 311 | })); 312 | }); 313 | 314 | poolStub.should.have.been.calledOnce; 315 | poolStub.firstCall.args.should.have.lengthOf(1); 316 | poolStub.firstCall.args[0].should.deep.equal({ 317 | user: 'user', 318 | password: 'pass', 319 | host: 'localhost', 320 | port: 1234, 321 | database: 'connect_pg_simple_test', 322 | }); 323 | }); 324 | }); 325 | 326 | describe('queries', () => { 327 | /** @type {import('sinon').SinonStub} */ 328 | let poolStub; 329 | /** @type {import('sinon').SinonStub} */ 330 | let queryStub; 331 | /** @type {import('..').ExpressSessionStore} */ 332 | let store; 333 | 334 | beforeEach(() => { 335 | delete process.env['DATABASE_URL']; 336 | 337 | queryStub = sinon.stub(); 338 | 339 | poolStub = sinon.stub(); 340 | poolStub.prototype.on = () => {}; 341 | poolStub.prototype.query = queryStub; 342 | 343 | const PGMock = { Pool: poolStub }; 344 | const proxiedConnectPgSimple = proxyquire('../', { pg: PGMock }); 345 | 346 | const ProxiedPGStore = proxiedConnectPgSimple({ 347 | Store: class FakeStore {}, 348 | }); 349 | 350 | const baseOptions = { pruneSessionInterval: false }; 351 | 352 | store = new ProxiedPGStore(Object.assign(baseOptions, { 353 | conString: 'postgres://user:pass@localhost:1234/connect_pg_simple_test', 354 | })); 355 | }); 356 | 357 | it('should properly handle successfull callback queries', done => { 358 | queryStub.resolves({ rows: ['hej'] }); 359 | 360 | // @ts-ignore 361 | store.query('SELECT * FROM faketable', [], (err, value) => { 362 | // eslint-disable-next-line unicorn/no-null 363 | should.equal(err, null); 364 | should.equal(value, 'hej'); 365 | done(); 366 | }); 367 | }); 368 | 369 | it('should properly handle failing callback queries', done => { 370 | const queryError = new Error('Fail'); 371 | 372 | queryStub.rejects(queryError); 373 | 374 | // @ts-ignore 375 | store.query('SELECT * FROM faketable', [], (err, value) => { 376 | should.equal(err, queryError); 377 | should.not.exist(value); 378 | done(); 379 | }); 380 | }); 381 | 382 | it('should properly handle param less query shorthand', done => { 383 | queryStub.resolves({ rows: ['hej'] }); 384 | 385 | // @ts-ignore 386 | store.query('SELECT * FROM faketable', (err, value) => { 387 | // eslint-disable-next-line unicorn/no-null 388 | should.equal(err, null); 389 | should.equal(value, 'hej'); 390 | done(); 391 | }); 392 | }); 393 | 394 | it('should throw on two callbacks set at once', () => { 395 | // @ts-ignore 396 | should.Throw(() => { 397 | store.query('', () => {}, () => {}); 398 | }); 399 | }); 400 | 401 | it('should handle successfull destroy call', done => { 402 | queryStub.resolves({ rows: ['hej'] }); 403 | 404 | // @ts-ignore 405 | store.destroy('foo', (err) => { 406 | // eslint-disable-next-line unicorn/no-null 407 | should.equal(err, null); 408 | done(); 409 | }); 410 | }); 411 | 412 | it('should handle failing destroy call', done => { 413 | const queryError = new Error('Fail'); 414 | 415 | queryStub.rejects(queryError); 416 | 417 | // @ts-ignore 418 | store.destroy('foo', (err) => { 419 | should.equal(err, queryError); 420 | done(); 421 | }); 422 | }); 423 | }); 424 | 425 | describe('pgPromise', () => { 426 | /** @type {import('sinon').SinonStub} */ 427 | let poolStub; 428 | /** @type {import('..').ExpressSessionStore} */ 429 | let ProxiedPGStore; 430 | /** @type {import('..').PGStoreOptions} */ 431 | let baseOptions; 432 | 433 | beforeEach(() => { 434 | delete process.env['DATABASE_URL']; 435 | 436 | poolStub = sinon.stub(); 437 | 438 | const PGMock = { Pool: poolStub }; 439 | const proxiedConnectPgSimple = proxyquire('../', { pg: PGMock }); 440 | 441 | ProxiedPGStore = proxiedConnectPgSimple({ 442 | Store: class FakeStore {}, 443 | }); 444 | 445 | baseOptions = { pruneSessionInterval: false }; 446 | }); 447 | 448 | it('should support pgPromise config', () => { 449 | should.not.throw(() => { 450 | return new ProxiedPGStore(Object.assign(baseOptions, { 451 | pgPromise: { 452 | any: () => {}, 453 | }, 454 | })); 455 | }); 456 | }); 457 | 458 | it('should throw on bad pgPromise', () => { 459 | should.throw(() => { 460 | return new ProxiedPGStore(Object.assign(baseOptions, { 461 | pgPromise: {}, 462 | })); 463 | }); 464 | }); 465 | 466 | it('should pass parameters to pgPromise', async () => { 467 | const queryStub = sinon.stub().resolves(true); 468 | const pgPromiseStub = { 469 | any: queryStub, 470 | }; 471 | 472 | const store = new ProxiedPGStore(Object.assign(baseOptions, { 473 | pgPromise: pgPromiseStub, 474 | })); 475 | 476 | await store._asyncQuery('select', [1, 2]); 477 | 478 | queryStub.should.have.been.calledOnce; 479 | queryStub.firstCall.args[0].should.equal('select'); 480 | queryStub.firstCall.args[1].should.deep.equal([1, 2]); 481 | }); 482 | 483 | it('should properly handle successfull callback queries', done => { 484 | const queryStub = sinon.stub().resolves(['hej']); 485 | const pgPromiseStub = { 486 | any: queryStub, 487 | }; 488 | 489 | const store = new ProxiedPGStore(Object.assign(baseOptions, { 490 | pgPromise: pgPromiseStub, 491 | })); 492 | 493 | // @ts-ignore 494 | store.query('SELECT * FROM faketable', [], (err, value) => { 495 | // eslint-disable-next-line unicorn/no-null 496 | should.equal(err, null); 497 | should.equal(value, 'hej'); 498 | done(); 499 | }); 500 | }); 501 | 502 | it('should properly handle failing callback queries', done => { 503 | const queryError = new Error('Fail'); 504 | 505 | const queryStub = sinon.stub().rejects(queryError); 506 | const pgPromiseStub = { 507 | any: queryStub, 508 | }; 509 | 510 | const store = new ProxiedPGStore(Object.assign(baseOptions, { 511 | pgPromise: pgPromiseStub, 512 | })); 513 | 514 | // @ts-ignore 515 | store.query('SELECT * FROM faketable', [], (err, value) => { 516 | should.equal(err, queryError); 517 | should.not.exist(value); 518 | done(); 519 | }); 520 | }); 521 | }); 522 | }); 523 | --------------------------------------------------------------------------------