From 97c9f19c0c033341cd73369d7f5ecc2628ba446e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:20:08 +0000 Subject: [PATCH 1/2] sql(postgres): parse timestamptz text components instead of Date.parse Postgres emits timestamptz text as 'YYYY-MM-DD HH:MM:SS[.ffffff]+HH[:MM[:SS]]'. The text path (simple queries, and timestamptz[] / timestamp[] array elements on both protocols) handed that string to JS Date.parse, which gets it wrong in two ways: - When the session zone's rule for the instant is local mean time (most zones before roughly 1880-1920), the offset carries a seconds field, e.g. '1883-11-18 07:03:58-04:56:02' for America/New_York. That is not a JS date format, so the row value was an Invalid Date with no error. - The space separator sends JSC down its non-ISO heuristic parser, which windows years 0001..0099 into 1900..2099 and misreads 0001 entirely. The binary path decodes microseconds since 2000-01-01 and was unaffected, so the two protocols silently returned different Dates for the same value. Parse the ISO components and the explicit offset directly, the way the naive timestamp decoder already does for the zoneless form, and apply the offset to the UTC wall-clock instant. The quoted elements of timestamptz[] and timestamp[] arrays go through the same helper; timestamp[] elements were still being read by Date.parse as host-local time, so they now decode as UTC like the scalar does. date text is the date-only ISO form, which Date.parse handles, and is left as-is; shapes the component parser does not cover (BC dates, 5+ digit years) still fall back to Date.parse. --- src/sql_jsc/postgres/DataCell.rs | 61 +++--- src/sql_jsc/postgres/types/date.rs | 20 +- src/sql_jsc/shared/datetime_text.rs | 69 +++--- test/js/sql/postgres-timestamptz-text.test.ts | 196 ++++++++++++++++++ .../sql-postgres-datetime-roundtrip.test.ts | 3 +- .../sql/sql-postgres-datetime-tz-fixture.ts | 85 ++++++-- 6 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 test/js/sql/postgres-timestamptz-text.test.ts diff --git a/src/sql_jsc/postgres/DataCell.rs b/src/sql_jsc/postgres/DataCell.rs index bb1281b6d3f8..6322d77e9311 100644 --- a/src/sql_jsc/postgres/DataCell.rs +++ b/src/sql_jsc/postgres/DataCell.rs @@ -18,6 +18,32 @@ type Result = core::result::Result; bun_core::declare_scope!(Postgres, visible); bun_core::declare_scope!(PostgresDataCell, visible); +/// Text-format `date` / `timestamp` / `timestamptz` (scalar or array element) to epoch ms. +fn parse_date_time_text( + tag: types::Tag, + bytes: &[u8], + global_object: &JSGlobalObject, +) -> Result { + use crate::postgres::types::date; + // The StartupMessage pins DateStyle=ISO, so the server only ever sends these shapes. + let ms = match tag { + types::Tag::timestamp | types::Tag::timestamp_array => { + date::timestamp_text_to_ms_utc(global_object, bytes) + } + types::Tag::timestamptz | types::Tag::timestamptz_array => { + date::timestamptz_text_to_ms_utc(global_object, bytes) + } + _ => None, + }; + if let Some(ms) = ms { + return Ok(ms); + } + // `date` (date-only ISO form), BC dates and 5+ digit years fall back to `Date.parse`. + let str = BunString::init(bytes); + crate::jsc::bun_string_jsc::parse_date(&str, global_object) + .map_err(crate::jsc::js_error_to_postgres) +} + fn parse_bytea(hex: &[u8]) -> Result { let len = hex.len() / 2; let mut buf: Vec = Vec::new(); @@ -214,11 +240,11 @@ fn parse_array( | types::Tag::timestamp_array | types::Tag::date_array => { let date_str = &slice[1..current_idx]; - let str = BunString::init(date_str); - array.push(SQLDataCell::date( - crate::jsc::bun_string_jsc::parse_date(&str, global_object) - .map_err(crate::jsc::js_error_to_postgres)?, - )); + array.push(SQLDataCell::date(parse_date_time_text( + array_type, + date_str, + global_object, + )?)); slice = try_slice(slice, current_idx + 1); continue; @@ -841,26 +867,11 @@ fn from_bytes( if let Some(inf) = crate::postgres::types::date::parse_infinity(bytes) { return Ok(SQLDataCell::date(inf)); } - // DateStyle is pinned to ISO in the startup packet, so the - // server always emits `YYYY-MM-DD[...]` here regardless of - // postgresql.conf / ALTER DATABASE / ALTER ROLE defaults. - // `timestamp` (no offset) is decoded as UTC components to - // agree with the binary path; `date` (UTC midnight) and - // `timestamptz` (explicit offset) go through Date.parse, - // which handles the ISO form unambiguously. - let date = match tag { - T::timestamp => crate::postgres::types::date::timestamp_text_to_ms_utc(global_object, bytes), - _ => None, - }; - let date = match date { - Some(d) => d, - None => { - let str = BunString::init(bytes); - crate::jsc::bun_string_jsc::parse_date(&str, global_object) - .map_err(crate::jsc::js_error_to_postgres)? - } - }; - Ok(SQLDataCell::date(date)) + Ok(SQLDataCell::date(parse_date_time_text( + tag, + bytes, + global_object, + )?)) } } tag @ (T::time | T::timetz) => { diff --git a/src/sql_jsc/postgres/types/date.rs b/src/sql_jsc/postgres/types/date.rs index c5d79c4cba18..2b3220fd0492 100644 --- a/src/sql_jsc/postgres/types/date.rs +++ b/src/sql_jsc/postgres/types/date.rs @@ -42,13 +42,29 @@ pub(crate) fn parse_infinity(bytes: &[u8]) -> Option { /// without this they'd go through JS `Date.parse` and be read as local time on /// non-UTC hosts. Returns `None` for anything that isn't this exact shape /// (e.g. `infinity`, BC dates, 5+ digit years), so the caller falls back to -/// `Date.parse`. `timestamptz` and `date` already decode correctly via -/// `Date.parse` and must NOT be routed here. +/// `Date.parse`. pub(crate) fn timestamp_text_to_ms_utc( global_object: &JSGlobalObject, bytes: &[u8], ) -> Option { let parsed = crate::shared::datetime_text::parse_postgres_timestamp(bytes)?; + components_to_ms_utc(global_object, &parsed) +} + +/// Same for `timestamptz` text, whose trailing `±HH[:MM[:SS]]` offset is applied here. +pub(crate) fn timestamptz_text_to_ms_utc( + global_object: &JSGlobalObject, + bytes: &[u8], +) -> Option { + let (parsed, offset_seconds) = crate::shared::datetime_text::parse_postgres_timestamptz(bytes)?; + let wall_clock_as_utc = components_to_ms_utc(global_object, &parsed)?; + Some(wall_clock_as_utc - f64::from(offset_seconds) * 1000.0) +} + +fn components_to_ms_utc( + global_object: &JSGlobalObject, + parsed: &crate::shared::datetime_text::DateTimeText, +) -> Option { global_object .gregorian_date_time_to_ms_utc( i32::from(parsed.year), diff --git a/src/sql_jsc/shared/datetime_text.rs b/src/sql_jsc/shared/datetime_text.rs index c1113c3ba7df..232d20508c98 100644 --- a/src/sql_jsc/shared/datetime_text.rs +++ b/src/sql_jsc/shared/datetime_text.rs @@ -42,7 +42,8 @@ enum Separator { /// MySQL DATE/DATETIME/TIMESTAMP text. Accepts the 10-byte date-only form /// (`YYYY-MM-DD`) and either `' '` or `'T'` as the date/time separator. pub(crate) fn parse_mysql(text: &[u8]) -> Option { - parse(text, TimePart::Optional, Separator::SpaceOrT) + let (dt, consumed) = parse(text, TimePart::Optional, Separator::SpaceOrT)?; + (consumed == text.len()).then_some(dt) } /// Postgres `timestamp` (WITHOUT TIME ZONE) text. Requires the full @@ -50,24 +51,47 @@ pub(crate) fn parse_mysql(text: &[u8]) -> Option { /// separator, `infinity`, BC dates, 5+ digit years) returns `None` so the /// caller can fall back to `Date.parse`. pub(crate) fn parse_postgres_timestamp(text: &[u8]) -> Option { - parse(text, TimePart::Required, Separator::Space) + let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?; + (consumed == text.len()).then_some(dt) } -fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option { - fn parse_u(bytes: &[u8]) -> Option { - if bytes.is_empty() { - return None; +/// Postgres `timestamptz` text; the `±HH[:MM[:SS]]` offset is returned in seconds east of UTC. +pub(crate) fn parse_postgres_timestamptz(text: &[u8]) -> Option<(DateTimeText, i32)> { + let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?; + let (&sign, offset) = text.get(consumed..)?.split_first()?; + let sign: i32 = match sign { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let hours = parse_u(offset.get(0..2)?)?; + let (minutes, seconds) = match offset.len() { + 2 => (0, 0), + 5 if offset[2] == b':' => (parse_u(&offset[3..5])?, 0), + 8 if offset[2] == b':' && offset[5] == b':' => { + (parse_u(&offset[3..5])?, parse_u(&offset[6..8])?) } - let mut n: u32 = 0; - for &c in bytes { - if !c.is_ascii_digit() { - return None; - } - n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?; + _ => return None, + }; + let offset_seconds = i32::try_from(hours * 3600 + minutes * 60 + seconds).ok()?; + Some((dt, sign * offset_seconds)) +} + +fn parse_u(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + let mut n: u32 = 0; + for &c in bytes { + if !c.is_ascii_digit() { + return None; } - Some(n) + n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?; } + Some(n) +} +fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<(DateTimeText, usize)> { if text.len() < 10 || text[4] != b'-' || text[7] != b'-' { return None; } @@ -79,7 +103,7 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option Some(result), + TimePart::Optional => Some((result, 10)), TimePart::Required => None, }; } @@ -95,21 +119,18 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option 6 { + let frac_len = text[20..].iter().take_while(|c| c.is_ascii_digit()).count(); + if frac_len == 0 || frac_len > 6 { return None; } - let mut micro = parse_u(frac)?; - for _ in 0..(6 - frac.len()) { + let mut micro = parse_u(&text[20..20 + frac_len])?; + for _ in 0..(6 - frac_len) { micro *= 10; } result.microsecond = micro; - Some(result) + Some((result, 20 + frac_len)) } diff --git a/test/js/sql/postgres-timestamptz-text.test.ts b/test/js/sql/postgres-timestamptz-text.test.ts new file mode 100644 index 000000000000..cf9e34f2fc99 --- /dev/null +++ b/test/js/sql/postgres-timestamptz-text.test.ts @@ -0,0 +1,196 @@ +// Postgres emits `timestamptz` text as `YYYY-MM-DD HH:MM:SS[.ffffff]±HH[:MM[:SS]]` +// (DateStyle=ISO, pinned in the startup packet). Routing that through JS +// `Date.parse` was wrong in two ways: +// - the `±HH:MM:SS` offset width, which Postgres prints for instants governed +// by local mean time (most zones before ~1880-1920; America/New_York in 1883 +// is `-04:56:02`), is not a JS date format at all, so every such value came +// back as `Invalid Date` with no error; +// - the space separator makes JSC take its non-ISO heuristic parser, which +// windows years 0001..0099 into 1900..2099 (and misreads 0001 entirely). +// The binary path decodes µs since 2000-01-01 and was unaffected, so the two +// protocols silently disagreed on the same value. The text decoder must parse +// the ISO components and the explicit offset directly, like the naive +// `timestamp` decoder already does. `timestamptz[]` / `timestamp[]` are always +// sent as text (even on the extended protocol) and now share that decoder, so +// array elements are covered too, including `timestamp[]` being read as UTC +// rather than host-local time. +// +// Driven by a scripted v3 backend so the exact wire text each path sees is +// pinned and no Postgres server is required. The `±HH:MM:SS` vectors are what +// PostgreSQL 17 prints for `'1883-11-18 12:00:00+00'::timestamptz` under the +// session time zones named next to them. + +import { SQL } from "bun"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, + type PgRowDescriptionColumn, +} from "./wire-frames"; + +const OID = { + date: 1082, + timestamp: 1114, + timestamptz: 1184, + timestamp_array: 1115, + timestamptz_array: 1185, +} as const; + +// The naive `timestamp` vectors below must decode as UTC whatever the host +// zone is, so run the file under a zone with a non-zero offset: a decoder that +// reads them as local time then fails instead of coinciding with UTC. +const originalTZ = process.env.TZ; +beforeAll(() => { + process.env.TZ = "America/New_York"; + expect(new Date(Date.UTC(2024, 5, 15, 12)).getTimezoneOffset()).toBe(240); +}); +afterAll(() => { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; +}); + +// Simple-query backend: replies to every 'Q' with the latched RowDescription + +// DataRow. format: 0 (text) for every column. +let reply!: { cols: PgRowDescriptionColumn[]; row: Buffer[] }; +const mock = await listeningServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write( + Buffer.concat([ + pgRowDescription(reply.cols), + pgDataRow(reply.row), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + socket.on("error", () => {}); +}); +afterAll(() => new Promise(r => mock.server.close(() => r()))); + +/** Serves one row whose cells are `texts`, every column of type `typeOid`; returns the decoded cells in order. */ +async function decode(typeOid: number, texts: string[]): Promise { + reply = { + cols: texts.map((_, i) => ({ name: `c${i}`, typeOid })), + row: texts.map(text => Buffer.from(text)), + }; + const sql = new SQL({ url: `postgres://u@127.0.0.1:${mock.port}/db`, max: 1, connectionTimeout: 2 }); + try { + const [row]: any = await sql`select 1`.simple(); + return texts.map((_, i) => row[`c${i}`]); + } finally { + await sql.close({ timeout: 0 }).catch(() => {}); + } +} + +// toISOString() throws on an Invalid Date; render it as a marker instead so a +// failing diff names the cell that broke. +const iso = (v: unknown) => (v instanceof Date ? (Number.isNaN(v.getTime()) ? "Invalid Date" : v.toISOString()) : v); + +// --- scalar timestamptz text ---------------------------------------------- + +test("timestamptz text: every offset width Postgres emits (±HH, ±HH:MM, ±HH:MM:SS)", async () => { + const cells = await decode(OID.timestamptz, [ + "1883-11-18 12:00:00+00", + "1883-11-18 07:00:00-05", + "2024-06-01 17:45:00+05:45", + "1883-11-18 07:03:58-04:56:02", // America/New_York + "1883-11-18 12:19:32+00:19:32", // Europe/Amsterdam + "1883-11-18 11:34:39-00:25:21", // Europe/Dublin: negative offset whose hour field is 00 + "1883-11-18 17:21:10+05:21:10", // Asia/Kolkata + ]); + expect(cells.map(iso)).toEqual([ + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "2024-06-01T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + ]); +}); + +test("timestamptz text: fractional seconds combine with every offset width", async () => { + const cells = await decode(OID.timestamptz, [ + "2024-06-01 12:00:00.5+00", + "2024-06-01 12:00:00.123456-05", + "2024-06-01 12:00:00.25+05:30", + "1883-11-18 07:03:58.25-04:56:02", + ]); + expect(cells.map(iso)).toEqual([ + "2024-06-01T12:00:00.500Z", + "2024-06-01T17:00:00.123Z", + "2024-06-01T06:30:00.250Z", + "1883-11-18T12:00:00.250Z", + ]); +}); + +test("timestamptz text: years 0001..0099 decode literally (text path == binary path)", async () => { + const cells = await decode(OID.timestamptz, [ + "0001-03-15 12:00:00+00", + "0044-03-15 12:00:00+00", + "0099-03-15 12:00:00+00", + "0100-03-15 12:00:00+00", + "2024-03-15 12:00:00+00", + ]); + expect(cells.map(iso)).toEqual([ + "0001-03-15T12:00:00.000Z", + "0044-03-15T12:00:00.000Z", + "0099-03-15T12:00:00.000Z", + "0100-03-15T12:00:00.000Z", + "2024-03-15T12:00:00.000Z", + ]); +}); + +test("timestamptz text outside the fixed-width shape still falls back to Date.parse", async () => { + // Five-digit years are the one such shape Date.parse handles; it must keep doing so. + const cells = await decode(OID.timestamptz, ["10000-01-01 00:00:00+00"]); + expect(cells.map(iso)).toEqual(["+010000-01-01T00:00:00.000Z"]); +}); + +// --- array text path (arrays are text even on the extended protocol) -------- + +test("timestamptz[] text: elements get the same offset parsing as scalars", async () => { + const [arr] = await decode(OID.timestamptz_array, [ + '{"1883-11-18 07:03:58-04:56:02","2024-06-01 08:00:00.5-04","0044-03-15 12:00:00+00"}', + ]); + expect((arr as unknown[]).map(iso)).toEqual([ + "1883-11-18T12:00:00.000Z", + "2024-06-01T12:00:00.500Z", + "0044-03-15T12:00:00.000Z", + ]); +}); + +test("timestamp[] text: elements decode as UTC wall-clock, like the scalar decoder", async () => { + const [arr] = await decode(OID.timestamp_array, [ + '{"2024-06-15 12:00:00","2024-06-15 12:00:00.5","0044-03-15 12:00:00"}', + ]); + expect((arr as unknown[]).map(iso)).toEqual([ + "2024-06-15T12:00:00.000Z", + "2024-06-15T12:00:00.500Z", + "0044-03-15T12:00:00.000Z", + ]); +}); + +// --- unaffected neighbours -------------------------------------------------- + +test("date / naive timestamp scalars keep decoding as UTC", async () => { + const dates = await decode(OID.date, ["2024-06-15", "0044-03-15"]); + const timestamps = await decode(OID.timestamp, ["2024-06-15 12:00:00", "0044-03-15 12:00:00.25"]); + expect([...dates, ...timestamps].map(iso)).toEqual([ + "2024-06-15T00:00:00.000Z", + "0044-03-15T00:00:00.000Z", + "2024-06-15T12:00:00.000Z", + "0044-03-15T12:00:00.250Z", + ]); +}); diff --git a/test/js/sql/sql-postgres-datetime-roundtrip.test.ts b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts index a018c040a068..ab6652a1eaa9 100644 --- a/test/js/sql/sql-postgres-datetime-roundtrip.test.ts +++ b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts @@ -6,7 +6,8 @@ import path from "path"; // path decodes it as UTC (µs since 2000-01-01). The simple/text path must do // the same — otherwise it goes through JS Date.parse and is read as local time, // making the two protocols disagree on non-UTC hosts. `timestamptz` and `date` -// must keep decoding correctly. +// must keep decoding correctly, including the seconds-resolution offsets the +// server prints for historical instants and the always-text array types. // // The fixture runs against a real Postgres server (docker-compose in CI, or a // DATABASE_URL/local instance otherwise) and prints "OK TZ= offsetMin=" diff --git a/test/js/sql/sql-postgres-datetime-tz-fixture.ts b/test/js/sql/sql-postgres-datetime-tz-fixture.ts index 66fe2a0ca986..a4820eec42d3 100644 --- a/test/js/sql/sql-postgres-datetime-tz-fixture.ts +++ b/test/js/sql/sql-postgres-datetime-tz-fixture.ts @@ -3,7 +3,10 @@ // simple/text path must decode the same wall-clock as UTC too — otherwise it // goes through JS Date.parse and is read as local time, making the two // protocols disagree on non-UTC hosts. `timestamptz` (explicit offset) and -// `date` (UTC midnight) must keep decoding correctly. +// `date` (UTC midnight) must keep decoding correctly, including the +// `±HH:MM:SS` offsets the server prints for historical (local mean time) +// instants and the `timestamptz[]` / `timestamp[]` arrays that are sent as +// text on both protocols. // // The driving test spawns this fixture under several TZ values against a real // Postgres server and asserts binary and text decode to the same instant. @@ -63,16 +66,21 @@ const expected = [ const failures: string[] = []; -function checkRows(protocol: string, rows: Array<{ ts: Date; tstz: Date; d: Date }>) { - for (let i = 0; i < expected.length; i++) { - for (const col of ["ts", "tstz", "d"] as const) { - const got: Date = rows[i][col]; - if (!(got instanceof Date)) { - failures.push(`${protocol} id=${i} ${col}: expected Date, got ${Object.prototype.toString.call(got)}`); - continue; - } - if (got.toISOString() !== expected[i][col]) { - failures.push(`${protocol} id=${i} ${col}: want ${expected[i][col]} got ${got.toISOString()}`); +// Renders a decoded cell (Date, array of Dates, or text) for comparison with +// the expected strings; anything else is reported as its type tag. +function render(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return `[${value.map(render).join(",")}]`; + if (!(value instanceof Date)) return Object.prototype.toString.call(value); + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); +} + +function checkRows(protocol: string, rows: Array>, want: Array>) { + for (let i = 0; i < want.length; i++) { + for (const col of Object.keys(want[i])) { + const got = render(rows[i]?.[col]); + if (got !== want[i][col]) { + failures.push(`${protocol} row=${i} ${col}: want ${want[i][col]} got ${got}`); } } } @@ -95,22 +103,22 @@ const binaryRows = await sql`SELECT ts, tstz, d, 0.1::real AS fmt FROM ${sql(t)} const textRows = await sql`SELECT ts, tstz, d, 0.1::real AS fmt FROM ${sql(t)} ORDER BY id`.simple(); checkFormat("binary", binaryRows, Math.fround(0.1)); checkFormat("text", textRows, 0.1); -checkRows("binary", binaryRows); -checkRows("text", textRows); +checkRows("binary", binaryRows, expected); +checkRows("text", textRows, expected); // Sub-millisecond sweep, checked against the server's own arithmetic: // floor(extract(epoch) * 1000) is numeric (exact) on PostgreSQL 14+, and, // unlike the text path, is also an oracle where the text decoders are known to -// differ and are not this decoder's business: BC dates, 5+ digit years and, -// for timestamptz, years 1-99 fall back to Date.parse, which yields Invalid -// Date for BC, reads a 5+ digit year `timestamp` as local time, and windows -// years 1-99 into 19xx/20xx. Those literals are checked on the binary path -// only. Values beyond JS Date's +/-8.64e15 ms decode to Invalid Date. +// differ and are not this decoder's business: BC dates and 5+ digit years fall +// back to Date.parse, which yields Invalid Date for BC and reads a 5+ digit +// year `timestamp` as local time. Those literals are checked on the binary +// path only. Values beyond JS Date's +/-8.64e15 ms decode to Invalid Date. const subMsLiterals: Array<[literal: string, textToo: boolean]> = [ ["4714-11-24 00:00:00.000001 BC", false], // the Postgres minimum ["0001-12-31 23:59:59.999999 BC", false], - ["0001-01-01 00:00:00.123456", false], - ["0099-12-31 23:59:59.999999", false], + // Years 1-99 used to be windowed into 19xx/20xx by the timestamptz text path. + ["0001-01-01 00:00:00.123456", true], + ["0099-12-31 23:59:59.999999", true], ["0100-01-01 00:00:00.000999", true], // More than 2^53 µs from 2000-01-01: the old f64 conversion was lossy here // on top of truncating toward zero, on both sides of 1970. @@ -170,6 +178,43 @@ for (const type of ["timestamp", "timestamptz"] as const) { } } +// Historical instants: for a date this old the session zone's rule is local +// mean time, so the server prints the offset with a seconds field +// (`1883-11-18 07:03:58-04:56:02` for America/New_York), which only the +// component decoder understands. Arrays are sent as text on both protocols, +// so they exercise the text decoder even in the "binary" query. +await sql.unsafe("SET TIME ZONE 'America/New_York'"); +const lmt = "1883-11-18 12:00:00+00"; +const historicalExpected = [ + { + // The server's own rendering, so this block is known to be exercising the + // seconds-resolution offset and not a plain `-05`. + tstz_text: "1883-11-18 07:03:58-04:56:02", + tstz: "1883-11-18T12:00:00.000Z", + tstz_arr: "[1883-11-18T12:00:00.000Z,2024-06-15T12:00:00.000Z]", + ts_arr: "[2024-06-15T12:00:00.000Z]", + }, +]; +// The same query on both protocols: a bound parameter makes it an extended +// query (scalar tstz arrives binary); unsafe() without parameters is a simple +// query (every cell arrives as text). +const historicalBinary = await sql` + SELECT ${lmt}::timestamptz::text AS tstz_text, + ${lmt}::timestamptz AS tstz, + ARRAY[${lmt}::timestamptz, '2024-06-15 12:00:00+00'::timestamptz] AS tstz_arr, + ARRAY['2024-06-15 12:00:00'::timestamp] AS ts_arr, + 0.1::real AS fmt`; +const historicalText = await sql.unsafe(` + SELECT '${lmt}'::timestamptz::text AS tstz_text, + '${lmt}'::timestamptz AS tstz, + ARRAY['${lmt}'::timestamptz, '2024-06-15 12:00:00+00'::timestamptz] AS tstz_arr, + ARRAY['2024-06-15 12:00:00'::timestamp] AS ts_arr, + 0.1::real AS fmt`); +checkFormat("binary historical", historicalBinary, Math.fround(0.1)); +checkFormat("text historical", historicalText, 0.1); +checkRows("binary historical", historicalBinary, historicalExpected); +checkRows("text historical", historicalText, historicalExpected); + if (failures.length) { console.error(`FAIL TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`); for (const f of failures) console.error(" " + f); From 773014ea483ace07abb571149e8421087462644b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:30:44 +0000 Subject: [PATCH 2/2] sql(postgres): reject out-of-range minute/second fields in timestamptz offsets An offset such as +01:99 now falls back to Date.parse (Invalid Date) instead of being read as 99 minutes. Also let close() failures in the text-decode test propagate, and trim the test header. --- src/sql_jsc/shared/datetime_text.rs | 3 ++ test/js/sql/postgres-timestamptz-text.test.ts | 41 ++++++++----------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/sql_jsc/shared/datetime_text.rs b/src/sql_jsc/shared/datetime_text.rs index 232d20508c98..11286bafa884 100644 --- a/src/sql_jsc/shared/datetime_text.rs +++ b/src/sql_jsc/shared/datetime_text.rs @@ -73,6 +73,9 @@ pub(crate) fn parse_postgres_timestamptz(text: &[u8]) -> Option<(DateTimeText, i } _ => return None, }; + if minutes > 59 || seconds > 59 { + return None; + } let offset_seconds = i32::try_from(hours * 3600 + minutes * 60 + seconds).ok()?; Some((dt, sign * offset_seconds)) } diff --git a/test/js/sql/postgres-timestamptz-text.test.ts b/test/js/sql/postgres-timestamptz-text.test.ts index cf9e34f2fc99..58bd6cb8149e 100644 --- a/test/js/sql/postgres-timestamptz-text.test.ts +++ b/test/js/sql/postgres-timestamptz-text.test.ts @@ -1,24 +1,12 @@ -// Postgres emits `timestamptz` text as `YYYY-MM-DD HH:MM:SS[.ffffff]±HH[:MM[:SS]]` -// (DateStyle=ISO, pinned in the startup packet). Routing that through JS -// `Date.parse` was wrong in two ways: -// - the `±HH:MM:SS` offset width, which Postgres prints for instants governed -// by local mean time (most zones before ~1880-1920; America/New_York in 1883 -// is `-04:56:02`), is not a JS date format at all, so every such value came -// back as `Invalid Date` with no error; -// - the space separator makes JSC take its non-ISO heuristic parser, which -// windows years 0001..0099 into 1900..2099 (and misreads 0001 entirely). -// The binary path decodes µs since 2000-01-01 and was unaffected, so the two -// protocols silently disagreed on the same value. The text decoder must parse -// the ISO components and the explicit offset directly, like the naive -// `timestamp` decoder already does. `timestamptz[]` / `timestamp[]` are always -// sent as text (even on the extended protocol) and now share that decoder, so -// array elements are covered too, including `timestamp[]` being read as UTC -// rather than host-local time. +// Text-path decoding of `timestamptz` (`YYYY-MM-DD HH:MM:SS[.ffffff]±HH[:MM[:SS]]`) +// must yield the same instant as the binary path for every offset width +// Postgres emits, including the `±HH:MM:SS` form it prints for local-mean-time +// instants, and for years 0001..0099. JS `Date.parse` gets both wrong. // -// Driven by a scripted v3 backend so the exact wire text each path sees is -// pinned and no Postgres server is required. The `±HH:MM:SS` vectors are what -// PostgreSQL 17 prints for `'1883-11-18 12:00:00+00'::timestamptz` under the -// session time zones named next to them. +// Driven by a scripted v3 backend so the exact wire text is pinned and no +// Postgres server is needed. The `±HH:MM:SS` vectors are what PostgreSQL 17 +// prints for `'1883-11-18 12:00:00+00'::timestamptz` under the zones named +// next to them. import { SQL } from "bun"; import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -89,7 +77,7 @@ async function decode(typeOid: number, texts: string[]): Promise { const [row]: any = await sql`select 1`.simple(); return texts.map((_, i) => row[`c${i}`]); } finally { - await sql.close({ timeout: 0 }).catch(() => {}); + await sql.close({ timeout: 0 }); } } @@ -153,9 +141,14 @@ test("timestamptz text: years 0001..0099 decode literally (text path == binary p }); test("timestamptz text outside the fixed-width shape still falls back to Date.parse", async () => { - // Five-digit years are the one such shape Date.parse handles; it must keep doing so. - const cells = await decode(OID.timestamptz, ["10000-01-01 00:00:00+00"]); - expect(cells.map(iso)).toEqual(["+010000-01-01T00:00:00.000Z"]); + const cells = await decode(OID.timestamptz, [ + // Five-digit years are the one such shape Date.parse handles; it must keep doing so. + "10000-01-01 00:00:00+00", + // Out-of-range offset fields are not treated as 99 minutes / 99 seconds. + "2024-06-01 12:00:00+01:99", + "2024-06-01 12:00:00+00:00:99", + ]); + expect(cells.map(iso)).toEqual(["+010000-01-01T00:00:00.000Z", "Invalid Date", "Invalid Date"]); }); // --- array text path (arrays are text even on the extended protocol) --------