Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions packages/3-targets/3-targets/postgres/src/core/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
pgTimestampDecodeJson,
pgTimestampEncodeJson,
pgTimestamptzDecodeJson,
pgTimestamptzEncode,
pgTimestamptzEncodeJson,
pgUnboundedIntDecode,
renderLength,
Expand Down Expand Up @@ -1149,11 +1150,11 @@ pgTimestampColumn satisfies ColumnHelperForStrict<PgTimestampDescriptor>;
export class PgTimestamptzCodec extends CodecImpl<
typeof PG_TIMESTAMPTZ_CODEC_ID,
readonly ['equality', 'order'],
Date,
Date | string,
Date
> {
async encode(value: Date, _ctx: CodecCallContext): Promise<Date> {
return value;
async encode(value: Date, _ctx: CodecCallContext): Promise<string> {
return pgTimestamptzEncode(value);
}
async decode(wire: Date, _ctx: CodecCallContext): Promise<Date> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically unsound if typescript accepts this, our types say that TWire is now Date | string without distinguishing between the directions in which the value travels. The first argument of this method is supposed to accept TWire. So in principle this should now be

Suggested change
async decode(wire: Date, _ctx: CodecCallContext): Promise<Date> {
async decode(wire: Date | string, _ctx: CodecCallContext): Promise<Date> {

with the corresponding branch for handling string values, based on the types.

Whether this is possible at run time is a separate issue. I'd prefer the implementation to match the types, otherwise this can blow up later when composing the abstractions in a different way, even if this branch is not going to be reachable at run time right now. The codec might be used for something other than decoding the values that come from the pg driver, and having it declare that it can parse string values but not actually parse them is not ideal.

return wire;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
15 changes: 12 additions & 3 deletions packages/3-targets/3-targets/postgres/test/codecs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,22 @@ describe('adapter-postgres codecs', () => {

describe('timestamptz codec', () => {
const timestamptzCodec = codecForScalar('timestamptz') as {
encode: (value: Date, ctx: SqlCodecCallContext) => Promise<Date>;
encode: (value: Date, ctx: SqlCodecCallContext) => Promise<string>;
decode: (wire: Date, ctx: SqlCodecCallContext) => Promise<Date>;
};

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);
});
});
Expand Down
Loading