diff --git a/CHANGELOG.md b/CHANGELOG.md index 731c1a1..e56eb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it back in. With the permissions the startup check and invite URL ask for, every new ticket failed with *Missing Access* and the user got "an internal error has occurred". The bot now adds an overwrite for itself. +- **Transcripts work on PostgreSQL.** pg returns `BIGINT` columns as strings, + so the ticket timestamps reached the transcript as text and every transcript + failed with `RangeError: Invalid time value` (no file in the log channel, + nothing stored). The PostgreSQL pool now returns `BIGINT` as a number, like + SQLite and MySQL do. ### Added diff --git a/src/database/drivers/postgres.js b/src/database/drivers/postgres.js index cfb23b4..6f39051 100644 --- a/src/database/drivers/postgres.js +++ b/src/database/drivers/postgres.js @@ -7,9 +7,20 @@ * @param {{ options: object }} descriptor */ function createPostgresDriver(descriptor) { - const { Pool } = require('pg'); + const { Pool, types } = require('pg'); let pool; + // pg returns BIGINT (int8, OID 20) as a string, SQLite and mysql2 return a + // number. Every BIGINT column here is a millisecond timestamp or a row id, + // well inside Number.MAX_SAFE_INTEGER (Discord ids are VARCHAR), so parse + // them as numbers for this pool only. As strings, new Date(created_at) was + // an Invalid Date and every transcript failed to build. + const INT8_OID = 20; + const typeParsers = { + getTypeParser: (oid, format) => + (oid === INT8_OID && format !== 'binary') ? Number : types.getTypeParser(oid, format), + }; + const norm = (params) => (params || []).map(p => (p === undefined ? null : p)); // ? → $1, $2, $3 … @@ -22,7 +33,7 @@ function createPostgresDriver(descriptor) { async connect() { // connectionTimeoutMillis: without it a connect to an unreachable host can // hang indefinitely instead of failing the query. - pool = new Pool({ connectionTimeoutMillis: 10_000, ...descriptor.options, max: 10 }); + pool = new Pool({ connectionTimeoutMillis: 10_000, ...descriptor.options, max: 10, types: typeParsers }); // An idle client that loses its connection (Postgres restart, network blip) // emits 'error' on the pool. Unhandled, that event crashes the process; the diff --git a/tests/postgres-int8.test.js b/tests/postgres-int8.test.js new file mode 100644 index 0000000..0e6c46c --- /dev/null +++ b/tests/postgres-int8.test.js @@ -0,0 +1,30 @@ +/** + * pg returns BIGINT as a string. The timestamps are BIGINT, and a string + * timestamp is an Invalid Date (every transcript failed on PostgreSQL), so the + * driver's pool parses int8 as a number. No server needed: pg is replaced + * with a fake that records the pool config. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const realPg = require('pg'); +let poolConfig; +class FakePool { + constructor(config) { poolConfig = config; } + on() {} + async connect() { return { release() {} }; } +} +require.cache[require.resolve('pg')].exports = { ...realPg, Pool: FakePool }; + +const { createPostgresDriver } = require('../src/database/drivers/postgres'); + +test('BIGINT (int8) comes back as a number, other types are unchanged', async () => { + await createPostgresDriver({ options: {} }).connect(); + + const parse = (oid) => poolConfig.types.getTypeParser(oid, 'text'); + assert.equal(parse(20)('1792781224429'), 1792781224429); + assert.ok(!Number.isNaN(new Date(parse(20)('1792781224429')).getTime())); + // NUMERIC (AVG results) and TEXT keep pg's defaults. + assert.equal(parse(1700)('4.50'), '4.50'); + assert.equal(parse(25)('1558357487801999400'), '1558357487801999400'); +});