diff --git a/src/connectors/__tests__/postgres-type-parsers.test.ts b/src/connectors/__tests__/postgres-type-parsers.test.ts new file mode 100644 index 00000000..ed4cc97a --- /dev/null +++ b/src/connectors/__tests__/postgres-type-parsers.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import pg from 'pg'; +import { postgresTypeParsers, VERBATIM_DATE_TIME_OIDS } from '../postgres/type-parsers.js'; + +const OID_DATE = 1082; +const OID_TIMESTAMP = 1114; +const OID_TIMESTAMPTZ = 1184; +const OID_TIME = 1083; +const OID_INT4 = 23; +const OID_TIMESTAMP_ARRAY = 1115; + +// Regression test for https://github.com/bytebase/dbhub/issues/416: +// `timestamp without time zone` and `date` values were parsed into JavaScript +// Dates in the host's local timezone, so the JSON output was shifted by the +// host's UTC offset. The parsers below are pure functions, so the behavior can +// be checked without a database. +describe('PostgreSQL date/time type parsers', () => { + it('returns timestamp without time zone verbatim, including microseconds', () => { + const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP, 'text'); + expect(parse('2026-01-01 12:00:00')).toBe('2026-01-01 12:00:00'); + expect(parse('2026-01-01 12:00:00.123456')).toBe('2026-01-01 12:00:00.123456'); + }); + + it('returns date verbatim instead of a host-local midnight instant', () => { + const parse = postgresTypeParsers.getTypeParser(OID_DATE, 'text'); + expect(parse('2026-01-01')).toBe('2026-01-01'); + }); + + it('returns timestamp with time zone verbatim, preserving the session offset', () => { + const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMPTZ, 'text'); + expect(parse('2026-01-01 12:00:00+00')).toBe('2026-01-01 12:00:00+00'); + }); + + it('returns array element text verbatim for timestamp[]', () => { + const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP_ARRAY, 'text'); + expect(parse('{"2026-01-01 12:00:00"}')).toBe('{"2026-01-01 12:00:00"}'); + }); + + it('is independent of the host timezone', () => { + const originalTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; + try { + const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP, 'text'); + // JSON.stringify is what the response formatter applies to row values. + expect(JSON.stringify(parse('2026-01-01 12:00:00'))).toBe('"2026-01-01 12:00:00"'); + } finally { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + } + }); + + it('defaults to the text format when none is given', () => { + const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP); + expect(parse('2026-01-01 12:00:00')).toBe('2026-01-01 12:00:00'); + }); + + it('delegates every other type to the default pg-types parsers', () => { + expect(postgresTypeParsers.getTypeParser(OID_INT4, 'text')('42')).toBe(42); + expect(postgresTypeParsers.getTypeParser(OID_TIME, 'text')('12:00:00.123456')).toBe('12:00:00.123456'); + expect(postgresTypeParsers.getTypeParser(OID_INT4, 'binary')).toBe(pg.types.getTypeParser(OID_INT4, 'binary')); + }); + + it('does not mutate the process-wide pg.types registry', () => { + for (const oid of VERBATIM_DATE_TIME_OIDS) { + expect(pg.types.getTypeParser(oid, 'text')).not.toBe(postgresTypeParsers.getTypeParser(oid, 'text')); + } + expect(pg.types.getTypeParser(OID_TIMESTAMP, 'text')('2026-01-01 12:00:00')).toBeInstanceOf(Date); + }); +}); diff --git a/src/connectors/__tests__/postgres.integration.test.ts b/src/connectors/__tests__/postgres.integration.test.ts index 60c93b27..d5f9fa9e 100644 --- a/src/connectors/__tests__/postgres.integration.test.ts +++ b/src/connectors/__tests__/postgres.integration.test.ts @@ -322,6 +322,25 @@ describe('PostgreSQL Connector Integration Tests', () => { expect(result.resultSets[0].rows[0].array_val).toBeDefined(); }); + it('should return date/timestamp values verbatim, not shifted by the host timezone', async () => { + // Regression test for https://github.com/bytebase/dbhub/issues/416 + const result = await postgresTest.connector.executeSQL( + `SELECT + '2026-01-01 12:00:00.123456'::timestamp AS naive, + '2026-01-01 12:00:00+00'::timestamptz AS aware, + '2026-01-01'::date AS day, + ARRAY['2026-01-01 12:00:00'::timestamp] AS naive_arr`, + {} + ); + + const row = result.resultSets[0].rows[0]; + expect(row.naive).toBe('2026-01-01 12:00:00.123456'); + expect(row.day).toBe('2026-01-01'); + expect(typeof row.aware).toBe('string'); + expect(row.aware).toMatch(/^2026-01-01 /); + expect(row.naive_arr).toBe('{"2026-01-01 12:00:00"}'); + }); + it('should return comment for views via getTableComment', async () => { const comment = await postgresTest.connector.getTableComment!('active_users'); expect(comment).toBe('Users aged 25 or older'); diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index bf330f8c..7de24dd3 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -24,6 +24,7 @@ import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; import { FailedToReadCertificate } from "./failed-to-read-certificate.js"; +import { postgresTypeParsers } from "./type-parsers.js"; import { closeQuietly } from "../../utils/resource-cleanup.js"; const POSTGRES_CLIENT_QUERY_TIMEOUT_GRACE_MS = 5_000; @@ -182,6 +183,10 @@ export class PostgresConnector implements Connector { try { const poolConfig = await this.dsnParser.parse(dsn, config); + // Return date/timestamp values as the server's verbatim text so they are + // not shifted by the host's local timezone (see type-parsers.ts). + poolConfig.types = postgresTypeParsers; + // SDK-level readonly enforcement: Set default_transaction_read_only for the entire connection if (config?.readonly) { poolConfig.options = (poolConfig.options || '') + ' -c default_transaction_read_only=on'; diff --git a/src/connectors/postgres/type-parsers.ts b/src/connectors/postgres/type-parsers.ts new file mode 100644 index 00000000..2c22d68f --- /dev/null +++ b/src/connectors/postgres/type-parsers.ts @@ -0,0 +1,43 @@ +import pg from "pg"; + +/** + * PostgreSQL type OIDs whose values DBHub returns as the server's verbatim + * text instead of letting node-postgres convert them to JavaScript Dates. + * + * pg-types parses `timestamp without time zone` and `date` with the + * multi-argument Date constructor, i.e. in the DBHub host's local timezone. + * The JSON serializer then renders that Date via toISOString(), so a stored + * wall-clock value of `2026-01-01 12:00:00` comes back as + * `2026-01-01T18:00:00.000Z` on a host running in America/Chicago: the host + * offset is baked into the output, a `Z` suffix makes it look authoritative, + * and sub-millisecond precision is dropped. `timestamptz` is parsed via + * Date.UTC and so was never shifted, but it is included here so all three + * date/time types render consistently as the text psql would show. + * + * See https://github.com/bytebase/dbhub/issues/416 + */ +export const VERBATIM_DATE_TIME_OIDS: ReadonlySet = new Set([ + 1082, // date + 1114, // timestamp without time zone + 1184, // timestamp with time zone + 1182, // date[] + 1115, // timestamp[] + 1185, // timestamptz[] +]); + +const passthrough = (value: string): string => value; + +/** + * Type parser configuration for the connection pool. Scoped to DBHub's pool + * via `PoolConfig.types` rather than mutating the process-wide `pg.types` + * registry, so other consumers of node-postgres in the same process keep the + * default behavior. + */ +export const postgresTypeParsers: pg.CustomTypesConfig = { + getTypeParser(oid: number, format?: "text" | "binary"): any { + if (VERBATIM_DATE_TIME_OIDS.has(oid) && (format === undefined || format === "text")) { + return passthrough; + } + return pg.types.getTypeParser(oid, format as any); + }, +};