From c928be746bb9330bd5e433b0389971dbf6766cbd Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 21 Aug 2026 11:00:40 +0200 Subject: [PATCH] fix(postgres): encode timestamptz parameters as UTC ISO strings (TML-3224) pg/timestamptz@1 handed the Date to the pg driver, whose dateToString writes the local wall clock beside a whole-minute offset suffix. Zones whose historical rule is local mean time carry a seconds component, so the stored instant drifted by that remainder (Europe/Berlin, year 120: +28s). Serializing the instant ourselves as a Z-suffixed ISO string has no offset to lose, mirroring the JSON lane and the pg/date@1 fix. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-targets/postgres/src/core/codec-helpers.ts | 14 +++++++++++++- .../3-targets/postgres/src/core/codecs.ts | 7 ++++--- .../3-targets/postgres/test/codecs-class.test.ts | 4 ++-- .../3-targets/postgres/test/codecs.test.ts | 15 ++++++++++++--- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts index 7be43f7feefd..74e9a2befd83 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-helpers.ts @@ -262,8 +262,20 @@ export const pgTimestampDecodeJson = (json: JsonValue): Date => { return date; }; +/** + * Serializes a `timestamptz` parameter as the instant's UTC ISO-8601 string, + * bypassing the pg driver's own `Date` serialization (`dateToString`). That + * serializer writes the *local* wall clock (`getHours`..`getSeconds`) next to an + * offset suffix derived from `getTimezoneOffset()`, which is whole minutes. + * Zones whose historical rule is local mean time carry a seconds component + * (Europe/Berlin before 1893 is +00:53:28), so the wall clock includes seconds + * the suffix cannot express and the stored instant drifts by that remainder. + * A `Z`-suffixed UTC string has no such offset to lose. + */ +export const pgTimestamptzEncode = (value: Date): string => value.toISOString(); + export const pgTimestamptzEncodeJson = (value: Date): JsonValue => - value.toISOString().replace(/Z$/, '+00:00'); + pgTimestamptzEncode(value).replace(/Z$/, '+00:00'); export const pgTimestamptzDecodeJson = (json: JsonValue): Date => { if (typeof json !== 'string') { throw postgresError( diff --git a/packages/3-targets/3-targets/postgres/src/core/codecs.ts b/packages/3-targets/3-targets/postgres/src/core/codecs.ts index 257abd51e62c..6d33c4e289dd 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codecs.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codecs.ts @@ -76,6 +76,7 @@ import { pgTimestampDecodeJson, pgTimestampEncodeJson, pgTimestamptzDecodeJson, + pgTimestamptzEncode, pgTimestamptzEncodeJson, pgUnboundedIntDecode, renderLength, @@ -1149,11 +1150,11 @@ pgTimestampColumn satisfies ColumnHelperForStrict; export class PgTimestamptzCodec extends CodecImpl< typeof PG_TIMESTAMPTZ_CODEC_ID, readonly ['equality', 'order'], - Date, + Date | string, Date > { - async encode(value: Date, _ctx: CodecCallContext): Promise { - return value; + async encode(value: Date, _ctx: CodecCallContext): Promise { + return pgTimestamptzEncode(value); } async decode(wire: Date, _ctx: CodecCallContext): Promise { return wire; diff --git a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts index 5edee2a42519..a5a3eedf7ece 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs-class.test.ts @@ -283,9 +283,9 @@ describe('codecs-class', () => { expect(codec.id).toBe(PG_TIMESTAMPTZ_CODEC_ID); }); - it('round-trips Date values', async () => { + it('encodes the instant as a UTC ISO string and passes the parsed Date through on decode', async () => { const instant = new Date('2024-01-15T10:30:00Z'); - expect(await codec.encode(instant, callCtx)).toBe(instant); + expect(await codec.encode(instant, callCtx)).toBe('2024-01-15T10:30:00.000Z'); expect(await codec.decode(instant, callCtx)).toBe(instant); }); diff --git a/packages/3-targets/3-targets/postgres/test/codecs.test.ts b/packages/3-targets/3-targets/postgres/test/codecs.test.ts index cfb7c9adb206..570e02194999 100644 --- a/packages/3-targets/3-targets/postgres/test/codecs.test.ts +++ b/packages/3-targets/3-targets/postgres/test/codecs.test.ts @@ -161,13 +161,22 @@ describe('adapter-postgres codecs', () => { describe('timestamptz codec', () => { const timestamptzCodec = codecForScalar('timestamptz') as { - encode: (value: Date, ctx: SqlCodecCallContext) => Promise; + encode: (value: Date, ctx: SqlCodecCallContext) => Promise; decode: (wire: Date, ctx: SqlCodecCallContext) => Promise; }; - it('round-trips Date values', async () => { + it('encodes the instant as a UTC ISO string, independent of process timezone', async () => { + const date = new Date('2024-01-15T10:30:00Z'); + expect(await timestamptzCodec.encode(date, {})).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('encodes historical instants exactly (no local-mean-time offset rounding)', async () => { + const date = new Date('0120-01-01T00:00:00Z'); + expect(await timestamptzCodec.encode(date, {})).toBe('0120-01-01T00:00:00.000Z'); + }); + + it('passes the driver-parsed Date through on decode', async () => { const date = new Date('2024-01-15T10:30:00Z'); - expect(await timestamptzCodec.encode(date, {})).toBe(date); expect(await timestamptzCodec.decode(date, {})).toBe(date); }); });