From bfeb3cb1a33619583add2f4ac756c106b4913aa1 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 30 May 2026 17:20:12 +0000 Subject: [PATCH] mysql, postgres: decode naive DATETIME/TIMESTAMP as UTC to match the write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JS Date bound to a MySQL DATETIME/TIMESTAMP (or Postgres timestamp) and read back was silently shifted by the machine's UTC offset on non-UTC machines. Encode breaks the Date's UTC epoch into Y/M/D h:m:s with pure-UTC arithmetic, but decode used the local-time conversion. Decode the stored wall-clock as UTC so both sides agree, on every protocol: - MySQL binary DATETIME/TIMESTAMP: gregorian_date_time_to_ms_utc. - MySQL text (.simple()): parse components via DateTime::from_text and convert as UTC, instead of JS Date.parse (which read them as local time). - MySQL zero / partial-zero dates ('0000-00-00', month/day 0) surface as Invalid Date on both paths instead of the Unix epoch / a wrapped Date. - Postgres 'timestamp' (WITHOUT TIME ZONE) text: decode as UTC to match its binary path; 'timestamptz' (explicit offset) and 'date' keep using Date.parse. Tested against real MySQL and Postgres servers (docker-compose in CI, or a local instance) — binary vs text consistency and zero-dates across UTC / America/New_York / Asia/Tokyo. --- docs/runtime/sql.mdx | 4 +- src/sql_jsc/mysql/MySQLValue.rs | 96 ++++++++++++++++++- src/sql_jsc/mysql/MySQLValue.zig | 5 +- .../mysql/protocol/DecodeBinaryValue.rs | 5 +- src/sql_jsc/mysql/protocol/ResultSet.rs | 20 ++-- src/sql_jsc/postgres/DataCell.rs | 19 +++- src/sql_jsc/postgres/types/date.rs | 63 ++++++++++++ .../sql/sql-mysql-datetime-roundtrip.test.ts | 86 +++++++++++++++++ test/js/sql/sql-mysql-datetime-tz-fixture.ts | 83 ++++++++++++++++ .../sql-postgres-datetime-roundtrip.test.ts | 86 +++++++++++++++++ .../sql/sql-postgres-datetime-tz-fixture.ts | 74 ++++++++++++++ 11 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 test/js/sql/sql-mysql-datetime-roundtrip.test.ts create mode 100644 test/js/sql/sql-mysql-datetime-tz-fixture.ts create mode 100644 test/js/sql/sql-postgres-datetime-roundtrip.test.ts create mode 100644 test/js/sql/sql-postgres-datetime-tz-fixture.ts diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 230d4af311d..d7615333043 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1266,7 +1266,7 @@ MySQL types are automatically converted to JavaScript types: | DECIMAL, NUMERIC | string | To preserve precision | | FLOAT, DOUBLE | number | | | DATE | Date | JavaScript Date object | -| DATETIME, TIMESTAMP | Date | With timezone handling | +| DATETIME, TIMESTAMP | Date | Decoded as UTC (see note below); `0000-00-00` becomes an Invalid Date | | TIME | number | Total of microseconds | | YEAR | number | | | CHAR, VARCHAR, VARSTRING, STRING | string | | @@ -1276,6 +1276,8 @@ MySQL types are automatically converted to JavaScript types: | BIT(1) | boolean | BIT(1) in MySQL | | GEOMETRY | string | Geometry data | +`DATETIME` and `TIMESTAMP` values have no timezone on the wire, so Bun reads them back as **UTC** — the `Date` you get has the same UTC wall-clock that was stored, regardless of the machine's timezone. This matches how values are written (a bound `Date` stores its UTC components). The same applies to PostgreSQL's `timestamp` (without time zone); `timestamptz` carries an explicit offset and is unaffected. + #### Differences from PostgreSQL While the API is unified, there are some behavioral differences: diff --git a/src/sql_jsc/mysql/MySQLValue.rs b/src/sql_jsc/mysql/MySQLValue.rs index 5479d4b9204..5feede1ddef 100644 --- a/src/sql_jsc/mysql/MySQLValue.rs +++ b/src/sql_jsc/mysql/MySQLValue.rs @@ -563,6 +563,82 @@ impl DateTime { } } + /// Parse a MySQL text-protocol DATE/DATETIME/TIMESTAMP string + /// (`YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS`, or with `.ffffff` fractional + /// seconds) into components so the text path can treat them as UTC, the + /// same way the binary path does. Returns `None` for MySQL zero-date + /// sentinels (`0000-00-00`), impossible calendar values (`2024-02-31`), and + /// malformed input, so the caller surfaces `Invalid Date` — matching what + /// the previous `Date.parse` path produced for those. + pub fn from_text(text: &[u8]) -> Option { + 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; + } + n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?; + } + Some(n) + } + + if text.len() < 10 || text[4] != b'-' || text[7] != b'-' { + return None; + } + let year = u16::try_from(parse_u(&text[0..4])?).ok()?; + let month = u8::try_from(parse_u(&text[5..7])?).ok()?; + let day = u8::try_from(parse_u(&text[8..10])?).ok()?; + if month < 1 || month > 12 || day < 1 || day > days_in_month(year, month) { + return None; + } + + let mut result = DateTime { + year, + month, + day, + ..Default::default() + }; + if text.len() == 10 { + return Some(result); + } + + // Either "YYYY-MM-DD HH:MM:SS" or the ISO "YYYY-MM-DDTHH:MM:SS". + if text.len() < 19 + || (text[10] != b' ' && text[10] != b'T') + || text[13] != b':' + || text[16] != b':' + { + return None; + } + result.hour = u8::try_from(parse_u(&text[11..13])?).ok()?; + result.minute = u8::try_from(parse_u(&text[14..16])?).ok()?; + result.second = u8::try_from(parse_u(&text[17..19])?).ok()?; + if result.hour > 23 || result.minute > 59 || result.second > 59 { + return None; + } + + if text.len() == 19 { + return Some(result); + } + if text[19] != b'.' { + return None; + } + // Fractional seconds: up to 6 digits, right-padded to microseconds. + let frac = &text[20..]; + if frac.is_empty() || frac.len() > 6 { + return None; + } + let mut micro = parse_u(frac)?; + for _ in 0..(6 - frac.len()) { + micro *= 10; + } + result.microsecond = micro; + Some(result) + } + pub fn to_binary(&self, field_type: FieldType, buffer: &mut [u8]) -> u8 { match field_type { FieldType::MYSQL_TYPE_YEAR => { @@ -597,7 +673,25 @@ impl DateTime { } pub fn to_js_timestamp(&self, global_object: &JSGlobalObject) -> JsResult { - global_object.gregorian_date_time_to_ms( + // MySQL in permissive sql_mode can store zero / partial-zero dates like + // "0000-00-00" or "2024-00-15" and send them over the binary protocol. + // WTF::GregorianDateTime would silently wrap month=0 to December of the + // prior year, so validate here and surface NaN instead — matching the + // Invalid Date the text path produces via from_text(). + if self.month < 1 + || self.month > 12 + || self.day < 1 + || self.day > days_in_month(self.year, self.month) + || self.hour > 23 + || self.minute > 59 + || self.second > 59 + { + return Ok(f64::NAN); + } + // from_unix_timestamp() breaks a Date's UTC epoch into Y/M/D h:m:s with + // pure-UTC arithmetic, so decode must also treat the stored wall-clock + // as UTC — otherwise a Date round-trips shifted by the local UTC offset. + global_object.gregorian_date_time_to_ms_utc( i32::from(self.year), i32::from(self.month), i32::from(self.day), diff --git a/src/sql_jsc/mysql/MySQLValue.zig b/src/sql_jsc/mysql/MySQLValue.zig index 642b2d9a99f..e0777aee927 100644 --- a/src/sql_jsc/mysql/MySQLValue.zig +++ b/src/sql_jsc/mysql/MySQLValue.zig @@ -371,7 +371,10 @@ pub const Value = union(enum) { } pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject) bun.JSError!f64 { - return globalObject.gregorianDateTimeToMS( + // fromUnixTimestamp() breaks a Date's UTC epoch into Y/M/D h:m:s with + // pure-UTC arithmetic, so decode must also treat the stored wall-clock + // as UTC — otherwise a Date round-trips shifted by the local UTC offset. + return globalObject.gregorianDateTimeToMSUTC( this.year, this.month, this.day, diff --git a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs index 962b3324bf1..fe0cf0addf2 100644 --- a/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs +++ b/src/sql_jsc/mysql/protocol/DecodeBinaryValue.rs @@ -298,9 +298,12 @@ pub fn decode_binary_value( FieldType::MYSQL_TYPE_DATE | FieldType::MYSQL_TYPE_TIMESTAMP | FieldType::MYSQL_TYPE_DATETIME => match reader.byte()? { + // A zero-length binary DATETIME is MySQL's "0000-00-00 00:00:00" + // sentinel — surface it as Invalid Date (NaN), not the Unix epoch, + // so it agrees with the text path's from_text(). 0 => Ok(SQLDataCell { tag: CellTag::Date, - value: CellValue { date: 0.0 }, + value: CellValue { date: f64::NAN }, ..Default::default() }), l @ (11 | 7 | 4) => { diff --git a/src/sql_jsc/mysql/protocol/ResultSet.rs b/src/sql_jsc/mysql/protocol/ResultSet.rs index 58c848a18e2..f0a37bfe6f5 100644 --- a/src/sql_jsc/mysql/protocol/ResultSet.rs +++ b/src/sql_jsc/mysql/protocol/ResultSet.rs @@ -1,6 +1,7 @@ use core::ptr; use crate::jsc::{ExternColumnIdentifier, JSGlobalObject, JSValue}; +use crate::mysql::my_sql_value::DateTime; use bun_core::String as BunString; use bun_core::parse_int; @@ -232,16 +233,15 @@ impl<'a> Row<'a> { }; } MYSQL_TYPE_DATE | MYSQL_TYPE_DATETIME | MYSQL_TYPE_TIMESTAMP => { - let mut str = BunString::init(value.slice()); - // `str` derefs on Drop. - let date = 'brk: { - match crate::jsc::bun_string_jsc::parse_date(&mut str, self.global_object) { - Ok(d) => break 'brk d, - Err(err) => { - let _ = self.global_object.take_exception(err); - break 'brk f64::NAN; - } - } + // MySQL's DATE/DATETIME/TIMESTAMP text has no timezone, so parse + // the components directly and convert them as UTC — matching the + // binary path. Routing through JS Date.parse here would instead + // read "2024-06-15 12:00:00" as local time and make the text and + // binary protocols disagree on non-UTC hosts. Zero/invalid dates + // fall through to NaN (Invalid Date). + let date = match DateTime::from_text(value.slice()) { + Some(dt) => dt.to_js_timestamp(self.global_object).unwrap_or(f64::NAN), + None => f64::NAN, }; *cell = SQLDataCell { tag: Tag::Date, diff --git a/src/sql_jsc/postgres/DataCell.rs b/src/sql_jsc/postgres/DataCell.rs index e31e6c994d3..3fa54915b78 100644 --- a/src/sql_jsc/postgres/DataCell.rs +++ b/src/sql_jsc/postgres/DataCell.rs @@ -1069,10 +1069,25 @@ pub(crate) fn from_bytes( ..Default::default() }); } - let mut str = BunString::init(bytes); + // `timestamp` (without time zone) text carries no offset, so + // decode its components as UTC to match the binary path. `date` + // (UTC midnight) and `timestamptz` (explicit offset) already + // parse correctly via Date.parse, so only redirect `timestamp`. + 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 mut str = BunString::init(bytes); + crate::jsc::bun_string_jsc::parse_date(&mut str, global_object) + .map_err(crate::jsc::js_error_to_postgres)? + } + }; Ok(SQLDataCell { tag: Tag::Date, - value: Value { date: crate::jsc::bun_string_jsc::parse_date(&mut str, global_object).map_err(crate::jsc::js_error_to_postgres)? }, + value: Value { date }, ..Default::default() }) } diff --git a/src/sql_jsc/postgres/types/date.rs b/src/sql_jsc/postgres/types/date.rs index 6fa167ded97..72849f2ea84 100644 --- a/src/sql_jsc/postgres/types/date.rs +++ b/src/sql_jsc/postgres/types/date.rs @@ -19,6 +19,69 @@ pub fn from_binary(bytes: &[u8]) -> f64 { (double_microseconds / US_PER_MS as f64) + POSTGRES_EPOCH_DATE as f64 } +/// Decode a Postgres `timestamp` (WITHOUT TIME ZONE) text value as UTC, so the +/// text/simple-query path agrees with the binary path (which is already UTC). +/// Postgres emits these as `YYYY-MM-DD HH:MM:SS[.ffffff]` with no offset; +/// 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. +pub fn timestamp_text_to_ms_utc(global_object: &JSGlobalObject, bytes: &[u8]) -> Option { + fn parse_u(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + let mut n: i32 = 0; + for &c in bytes { + if !c.is_ascii_digit() { + return None; + } + n = n.checked_mul(10)?.checked_add(i32::from(c - b'0'))?; + } + Some(n) + } + + if bytes.len() < 19 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b' ' + || bytes[13] != b':' + || bytes[16] != b':' + { + return None; + } + let year = parse_u(&bytes[0..4])?; + let month = parse_u(&bytes[5..7])?; + let day = parse_u(&bytes[8..10])?; + let hour = parse_u(&bytes[11..13])?; + let minute = parse_u(&bytes[14..16])?; + let second = parse_u(&bytes[17..19])?; + + let millisecond = if bytes.len() > 19 { + if bytes[19] != b'.' { + return None; + } + let frac = &bytes[20..]; + if frac.is_empty() || frac.len() > 6 || !frac.iter().all(u8::is_ascii_digit) { + return None; + } + // Fractional seconds → milliseconds (JS Date is ms-precision, like the + // binary path's f64 truncation). + let mut micro = parse_u(frac)?; + for _ in 0..(6 - frac.len()) { + micro *= 10; + } + micro / 1000 + } else { + 0 + }; + + global_object + .gregorian_date_time_to_ms_utc(year, month, day, hour, minute, second, millisecond) + .ok() +} + pub fn from_js(global_object: &JSGlobalObject, value: JSValue) -> JsResult { let double_value = if value.is_date() { value.get_unix_timestamp() diff --git a/test/js/sql/sql-mysql-datetime-roundtrip.test.ts b/test/js/sql/sql-mysql-datetime-roundtrip.test.ts new file mode 100644 index 00000000000..c390368ba24 --- /dev/null +++ b/test/js/sql/sql-mysql-datetime-roundtrip.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, describeWithContainer, isDockerEnabled } from "harness"; +import path from "path"; + +// A JS Date bound to a MySQL DATETIME/TIMESTAMP and read back must be the same +// instant regardless of the process timezone. Encode breaks the Date's epoch-ms +// into Y/M/D h:m:s via pure-UTC arithmetic, so decode has to treat those +// components as UTC too — if it interprets them as local time, the round-trip +// silently shifts by the machine's UTC offset. +// +// The fixture runs against a real MySQL server (docker-compose in CI, or a +// MYSQL_URL/local instance otherwise) and prints "OK TZ= offsetMin=" +// only when every Date round-trips to the same instant. + +const TIMEZONES = ["Etc/UTC", "America/New_York", "Asia/Tokyo"]; +const fixture = path.join(import.meta.dir, "sql-mysql-datetime-tz-fixture.ts"); + +function runFixture(url: string, TZ: string, caPath = "") { + return Bun.spawnSync([bunExe(), fixture], { + env: { ...bunEnv, MYSQL_URL: url, CA_PATH: caPath, TZ }, + stdout: "pipe", + stderr: "pipe", + }); +} + +function assertRoundTrip(stdout: string, stderr: string, TZ: string) { + // On a round-trip mismatch the fixture writes `FAIL TZ=… offsetMin=…` plus the + // per-row `diffMin` breakdown to stderr, then exits 1. Assert it's empty so a + // CI failure surfaces *which* dates drifted and by how much, not just a bare + // "CONNECTED" vs "OK" mismatch. (ASAN emits a harmless interposition warning.) + const diagnostics = stderr + .split(/\r?\n/) + .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); + expect(diagnostics).toBe(""); + // "OK TZ=" only prints when all three Dates round-trip to the same instant. + expect(stdout).toContain(`OK TZ=${TZ}`); + // And the child runtime must actually have adopted the injected timezone — + // a non-zero offset for the non-UTC zones — otherwise a silently-unapplied TZ + // would degenerate all three runs into the UTC case and stop exercising the + // local-time decode bug. + expect(stdout).toMatch(TZ === "Etc/UTC" ? /offsetMin=0\b/ : /offsetMin=-?[1-9]/); +} + +if (isDockerEnabled()) { + // CI: run against the docker-compose MySQL service. + describeWithContainer("mysql", { image: "mysql_plain" }, container => { + describe.each(TIMEZONES)("TZ=%s", TZ => { + test("DATETIME Date round-trip is the identity", async () => { + await container.ready; + const url = `mysql://root@${container.host}:${container.port}/bun_sql_test`; + const { stdout, stderr, exitCode } = runFixture(url, TZ); + const out = String(stdout); + expect(out).toContain("CONNECTED"); + assertRoundTrip(out, String(stderr), TZ); + expect(exitCode).toBe(0); + }); + }); + }); +} else { + // No docker daemon (e.g. local/sandboxed environments). If a MySQL server is + // reachable at MYSQL_URL or the conventional local address, exercise the + // fixture there so the round-trip is still covered without a mock. + const url = process.env.MYSQL_URL || "mysql://bun@127.0.0.1:3306/bun_sql_test"; + + describe.each(TIMEZONES)("mysql (local) TZ=%s", TZ => { + test("DATETIME Date round-trip is the identity", () => { + const { stdout, stderr, exitCode } = runFixture(url, TZ); + const out = String(stdout); + // The fixture prints "CONNECTED" once it reaches the server. If it never + // got that far, there's no MySQL to talk to here; the docker-gated branch + // above provides the CI coverage. + if (!out.includes("CONNECTED")) { + if (process.env.MYSQL_URL) { + throw new Error( + `sql-mysql-datetime-roundtrip: MYSQL_URL was provided but fixture never reached CONNECTED\nstdout:\n${out}\nstderr:\n${String(stderr)}`, + ); + } + console.warn("sql-mysql-datetime-roundtrip: no MySQL reachable at " + url + "; skipping assertions"); + return; + } + assertRoundTrip(out, String(stderr), TZ); + expect(exitCode).toBe(0); + }); + }); +} diff --git a/test/js/sql/sql-mysql-datetime-tz-fixture.ts b/test/js/sql/sql-mysql-datetime-tz-fixture.ts new file mode 100644 index 00000000000..f057c5573a1 --- /dev/null +++ b/test/js/sql/sql-mysql-datetime-tz-fixture.ts @@ -0,0 +1,83 @@ +// A JS Date bound to a MySQL DATETIME and read back must be the same instant +// regardless of the process timezone. Encode breaks the Date's epoch-ms into +// Y/M/D h:m:s via pure-UTC arithmetic, so decode has to treat those components +// as UTC too — if it interprets them as local time, the round-trip silently +// shifts by the machine's UTC offset. This must hold on BOTH the prepared +// (binary) and simple (text) protocols, and MySQL zero-dates must surface as +// Invalid Date on both. +// +// The driving test spawns this fixture under several TZ values against a real +// MySQL server and asserts the identity holds for each. + +import { SQL, randomUUIDv7 } from "bun"; + +const tls = process.env.CA_PATH ? { ca: Bun.file(process.env.CA_PATH) } : undefined; +await using sql = new SQL({ + url: process.env.MYSQL_URL, + tls, + max: 1, + allowPublicKeyRetrieval: true, +}); + +const t = "dt_tz_" + randomUUIDv7("hex").replaceAll("-", ""); +await sql`CREATE TEMPORARY TABLE ${sql(t)} (id INT PRIMARY KEY, dt DATETIME)`; +// Signal a live connection so the driving test can tell "no MySQL here" +// (soft-skip in local/sandboxed runs) apart from an actual decode failure. +console.log("CONNECTED"); + +const inputs = [ + new Date("2024-06-15T12:00:00.000Z"), // summer (DST active in zones that observe it) + new Date("2024-01-15T00:30:00.000Z"), // winter, near midnight UTC — local-time misread crosses the day boundary + new Date("2024-12-31T23:45:00.000Z"), // year boundary +]; + +for (let i = 0; i < inputs.length; i++) { + await sql`INSERT INTO ${sql(t)} (id, dt) VALUES (${i}, ${inputs[i]})`; +} + +const failures: string[] = []; + +function checkRoundTrip(protocol: string, rows: Array<{ dt: Date }>) { + for (let i = 0; i < inputs.length; i++) { + const got: Date = rows[i].dt; + if (!(got instanceof Date)) { + failures.push(`${protocol} id=${i}: expected Date, got ${Object.prototype.toString.call(got)}`); + continue; + } + const want = inputs[i].getTime(); + const have = got.getTime(); + if (want !== have) { + const diffMin = (have - want) / 60000; + failures.push(`${protocol} id=${i}: in=${inputs[i].toISOString()} out=${got.toISOString()} diffMin=${diffMin}`); + } + } +} + +// Prepared (binary) and simple (text) protocols must agree — both decode the +// naive wall-clock as UTC. +checkRoundTrip("binary", await sql`SELECT id, dt FROM ${sql(t)} ORDER BY id`); +checkRoundTrip("text", await sql`SELECT id, dt FROM ${sql(t)} ORDER BY id`.simple()); + +// MySQL's permissive sql_mode stores "0000-00-00 00:00:00"; it must read back +// as Invalid Date (not the Unix epoch / a wrapped date) on both protocols. +const zt = "dt_zero_" + randomUUIDv7("hex").replaceAll("-", ""); +await sql`SET SESSION sql_mode=''`.simple(); +await sql`CREATE TEMPORARY TABLE ${sql(zt)} (id INT PRIMARY KEY, dt DATETIME)`.simple(); +await sql.unsafe(`INSERT INTO ${zt} (id, dt) VALUES (1, '0000-00-00 00:00:00')`); +for (const [protocol, [row]] of [ + ["binary", await sql`SELECT dt FROM ${sql(zt)} WHERE id = 1`], + ["text", await sql`SELECT dt FROM ${sql(zt)} WHERE id = 1`.simple()], +] as const) { + const got: Date = row.dt; + if (!(got instanceof Date) || !Number.isNaN(got.getTime())) { + failures.push(`${protocol} zero-date: expected Invalid Date, got ${String(got)}`); + } +} + +if (failures.length) { + console.error(`FAIL TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`); + for (const f of failures) console.error(" " + f); + process.exit(1); +} + +console.log(`OK TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`); diff --git a/test/js/sql/sql-postgres-datetime-roundtrip.test.ts b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts new file mode 100644 index 00000000000..b9320d5898a --- /dev/null +++ b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, describeWithContainer, isDockerEnabled } from "harness"; +import path from "path"; + +// A Postgres `timestamp` (WITHOUT TIME ZONE) carries no offset, so the binary +// 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. +// +// The fixture runs against a real Postgres server (docker-compose in CI, or a +// DATABASE_URL/local instance otherwise) and prints "OK TZ= offsetMin=" +// only when binary and text decode to the same instant for every column. + +const TIMEZONES = ["Etc/UTC", "America/New_York", "Asia/Tokyo"]; +const fixture = path.join(import.meta.dir, "sql-postgres-datetime-tz-fixture.ts"); + +function runFixture(url: string, TZ: string, caPath = "") { + return Bun.spawnSync([bunExe(), fixture], { + env: { ...bunEnv, DATABASE_URL: url, CA_PATH: caPath, TZ }, + stdout: "pipe", + stderr: "pipe", + }); +} + +function assertRoundTrip(stdout: string, stderr: string, TZ: string) { + // On a mismatch the fixture writes `FAIL TZ=… offsetMin=…` plus a per-column + // breakdown to stderr, then exits 1. Assert it's empty so a CI failure + // surfaces *which* value drifted, not just a bare "CONNECTED" vs "OK" + // mismatch. (ASAN emits a harmless interposition warning.) + const diagnostics = stderr + .split(/\r?\n/) + .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); + expect(diagnostics).toBe(""); + // "OK TZ=" only prints when binary and text agree for every column. + expect(stdout).toContain(`OK TZ=${TZ}`); + // And the child runtime must actually have adopted the injected timezone — + // a non-zero offset for the non-UTC zones — otherwise a silently-unapplied TZ + // would degenerate all three runs into the UTC case and stop exercising the + // local-time decode bug. + expect(stdout).toMatch(TZ === "Etc/UTC" ? /offsetMin=0\b/ : /offsetMin=-?[1-9]/); +} + +if (isDockerEnabled()) { + // CI: run against the docker-compose Postgres service. + describeWithContainer("postgres", { image: "postgres_plain" }, container => { + describe.each(TIMEZONES)("TZ=%s", TZ => { + test("TIMESTAMP decode is UTC on both protocols", async () => { + await container.ready; + const url = `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; + const { stdout, stderr, exitCode } = runFixture(url, TZ); + const out = String(stdout); + expect(out).toContain("CONNECTED"); + assertRoundTrip(out, String(stderr), TZ); + expect(exitCode).toBe(0); + }); + }); + }); +} else { + // No docker daemon (e.g. local/sandboxed environments). If a Postgres server + // is reachable at DATABASE_URL or the conventional local address, exercise + // the fixture there so the round-trip is still covered. + const url = process.env.DATABASE_URL || "postgres://bun_sql_test@127.0.0.1:5432/bun_sql_test"; + + describe.each(TIMEZONES)("postgres (local) TZ=%s", TZ => { + test("TIMESTAMP decode is UTC on both protocols", () => { + const { stdout, stderr, exitCode } = runFixture(url, TZ); + const out = String(stdout); + // The fixture prints "CONNECTED" once it reaches the server. If it never + // got that far, there's no Postgres to talk to here; the docker-gated + // branch above provides the CI coverage. + if (!out.includes("CONNECTED")) { + if (process.env.DATABASE_URL) { + throw new Error( + `sql-postgres-datetime-roundtrip: DATABASE_URL was provided but fixture never reached CONNECTED\nstdout:\n${out}\nstderr:\n${String(stderr)}`, + ); + } + console.warn("sql-postgres-datetime-roundtrip: no Postgres reachable at " + url + "; skipping assertions"); + return; + } + assertRoundTrip(out, String(stderr), TZ); + expect(exitCode).toBe(0); + }); + }); +} diff --git a/test/js/sql/sql-postgres-datetime-tz-fixture.ts b/test/js/sql/sql-postgres-datetime-tz-fixture.ts new file mode 100644 index 00000000000..7fb82063064 --- /dev/null +++ b/test/js/sql/sql-postgres-datetime-tz-fixture.ts @@ -0,0 +1,74 @@ +// A Postgres `timestamp` (WITHOUT TIME ZONE) stores a naive wall-clock with no +// offset. Bun decodes the binary form as UTC (µs since 2000-01-01), so the +// 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. +// +// 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. + +import { SQL, randomUUIDv7 } from "bun"; + +const tls = process.env.CA_PATH ? { ca: Bun.file(process.env.CA_PATH) } : undefined; +await using sql = new SQL({ + url: process.env.DATABASE_URL, + tls, + max: 1, +}); + +// Pin the server session to UTC so the stored/echoed text is unambiguous +// regardless of the client process TZ; the bug under test is purely client-side +// decode of the naive wall-clock. +await sql.unsafe("SET TIME ZONE 'UTC'"); + +const t = "dt_tz_" + randomUUIDv7("hex").replaceAll("-", ""); +await sql`CREATE TEMPORARY TABLE ${sql(t)} (id INT PRIMARY KEY, ts TIMESTAMP, tstz TIMESTAMPTZ, d DATE)`; +// Signal a live connection so the driving test can tell "no Postgres here" +// (soft-skip in local/sandboxed runs) apart from an actual decode failure. +console.log("CONNECTED"); + +// Fixed wall-clock strings so the stored values don't depend on the session TZ. +const rowsIn = [ + { id: 0, ts: "2024-06-15 12:00:00", tstz: "2024-06-15 12:00:00+00", d: "2024-06-15" }, + { id: 1, ts: "2024-01-15 00:30:00", tstz: "2024-01-15 00:30:00+00", d: "2024-01-15" }, + { id: 2, ts: "2024-12-31 23:45:00", tstz: "2024-12-31 23:45:00+00", d: "2024-12-31" }, +]; +for (const r of rowsIn) { + await sql.unsafe(`INSERT INTO ${t} (id, ts, tstz, d) VALUES (${r.id}, '${r.ts}', '${r.tstz}', '${r.d}')`); +} + +// What each column should decode to, as a UTC instant (identical on both paths). +const expected = [ + { ts: "2024-06-15T12:00:00.000Z", tstz: "2024-06-15T12:00:00.000Z", d: "2024-06-15T00:00:00.000Z" }, + { ts: "2024-01-15T00:30:00.000Z", tstz: "2024-01-15T00:30:00.000Z", d: "2024-01-15T00:00:00.000Z" }, + { ts: "2024-12-31T23:45:00.000Z", tstz: "2024-12-31T23:45:00.000Z", d: "2024-12-31T00:00:00.000Z" }, +]; + +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()}`); + } + } + } +} + +checkRows("binary", await sql`SELECT ts, tstz, d FROM ${sql(t)} ORDER BY id`); +checkRows("text", await sql`SELECT ts, tstz, d FROM ${sql(t)} ORDER BY id`.simple()); + +if (failures.length) { + console.error(`FAIL TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`); + for (const f of failures) console.error(" " + f); + process.exit(1); +} + +console.log(`OK TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`);