Skip to content

sql(mysql): decode DATETIME/TIMESTAMP as UTC#29216

Open
robobun wants to merge 6 commits intomainfrom
farm/1208a173/mysql-datetime-utc
Open

sql(mysql): decode DATETIME/TIMESTAMP as UTC#29216
robobun wants to merge 6 commits intomainfrom
farm/1208a173/mysql-datetime-utc

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 12, 2026

Fixes #29208.

Repro

import { SQL } from "bun";
const sql = new SQL("mysql://root@localhost/test");
await sql\`CREATE TABLE t (id INT PRIMARY KEY, ts DATETIME(3))\`;
const d = new Date(2024, 0, 15, 12, 30, 45, 678); // local ≠ UTC
await sql\`INSERT INTO t VALUES (\${1}, \${d})\`;
const [row] = await sql\`SELECT ts FROM t WHERE id = 1\`;
console.log(row.ts.toISOString()); // expected: d.toISOString()

Under TZ=Asia/Bangkok, d.toISOString() is 2024-01-15T05:30:45.678Z but row.ts.toISOString() was 2024-01-14T22:30:45.678Z — the client's UTC offset (−7h) subtracted.

Root cause

MySQL's DATETIME/TIMESTAMP wire format carries no timezone — the server returns naive year/month/day/hour/minute/second[/microsecond] components. Bun fed those components through JSC's local-time constructor on both protocol paths:

  • src/sql/mysql/MySQLTypes.zig DateTime.toJSTimestamp called gregorianDateTimeToMS (which passes localTime = true).
  • src/sql/mysql/protocol/ResultSet.zig text-protocol branch called bun.String.parseDate, which runs the naive "2024-01-15 12:30:45" through JSC's date parser; without a Z/offset that's interpreted as local.

The send path (DateTime.fromUnixTimestamp) was already UTC-correct, so writes stored the right value and only the read path was wrong — matching the reporter's SELECT DATE_FORMAT(ts,...) check.

Fix

  • Binary protocol: swap gregorianDateTimeToMS for gregorianDateTimeToMSUTC in DateTime.toJSTimestamp.
  • Text protocol: parse YYYY-MM-DD[ HH:MM:SS[.ffffff]] directly into a DateTime via a new DateTime.fromText and route through the same UTC toJSTimestamp. This replaces the generic JS date parser for MySQL's text date/datetime rows.

Both paths now produce the same UTC epoch, independent of the client TZ, and match the server's stored value.

Verification

test/regression/issue/29208.test.ts flips process.env.TZ to Asia/Bangkok mid-process and round-trips a Date through:

Column Protocol
DATETIME binary
TIMESTAMP binary
DATETIME text
TIMESTAMP text

All four must decode to 2024-01-15T05:30:45.678Z. The test auto-wires against a containerized MySQL when Docker is available and falls back to auto-provisioning a bun_test user against a locally-running MariaDB otherwise.

Fail-before (git stash src/, rebuild, run):

  {
-   "binaryDatetime": "2024-01-15T05:30:45.678Z",
-   "binaryTimestamp": "2024-01-15T05:30:45.678Z",
-   "textDatetime":    "2024-01-15T05:30:45.678Z",
-   "textTimestamp":   "2024-01-15T05:30:45.678Z",
+   "binaryDatetime": "2024-01-14T22:30:45.678Z",
+   "binaryTimestamp": "2024-01-14T22:30:45.678Z",
+   "textDatetime":    "2024-01-14T22:30:45.678Z",
+   "textTimestamp":   "2024-01-14T22:30:45.678Z",
  }
(fail) issue #29208 > DATETIME/TIMESTAMP decode as UTC under non-UTC TZ

Pass-after: all green, 3 expect() calls.

Notes

The report also mentions PostgreSQL TIMESTAMP WITHOUT TIME ZONE being off by the client offset in the same scenario. The Bun-side PG binary path (src/sql/postgres/types/date.zig fromBinary) decodes microseconds-since-2000 directly into UTC ms, and both timestamp and timestamptz tags go through that same function. Any residual PG discrepancy is most likely server-side (how the driver sends Date as timestamptz OID 1184 and how the server coerces that into a TZ-less column under the session timezone) and is better investigated separately with a live PG instance.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Updated 9:35 AM PT - Apr 12th, 2026

@robobun, your commit a570c95 has 1 failures in Build #45332 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29216

That installs a local version of the PR into your bun-29216 executable, so you can run:

bun-29216 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Parses MySQL text-protocol date/time strings, enforces component validation, interprets components as UTC when converting to JS timestamps, updates result-set text-path to use the new parser, maps binary zero-date sentinels to NaN, and adds a regression test covering DATETIME/TIMESTAMP round-trips under a non-UTC process timezone.

Changes

Cohort / File(s) Summary
MySQL date/time types & JS conversion
src/sql/mysql/MySQLTypes.zig
Added pub fn fromText(text: []const u8) !DateTime to strictly parse YYYY-MM-DD[ T]HH:MM:SS[.fraction] (1–6 fractional digits → microseconds) with validation. toJSTimestamp validates binary components, returns NaN on invalid ranges, and uses gregorianDateTimeToMSUTC(...). toJS now returns bun.JSError!JSValue and propagates conversion errors.
Result set text-protocol handling
src/sql/mysql/protocol/ResultSet.zig
Text-protocol DATE/DATETIME/TIMESTAMP values now parse raw text via DateTime.fromText(slice) and convert with dt.toJSTimestamp(this.globalObject); parse/convert failures yield std.math.nan(f64) and conversion errors are forwarded to the global object.
Binary zero-date handling
src/sql/mysql/protocol/DecodeBinaryValue.zig
For .MYSQL_TYPE_DATE/.MYSQL_TYPE_TIMESTAMP/.MYSQL_TYPE_DATETIME when the first payload byte is 0, decoder now returns a .date cell with std.math.nan(f64) instead of 0, changing the JS-visible zero-date sentinel to NaN.
Regression test (non-UTC round-trip)
test/regression/issue/29208.test.ts
Added test that sets TZ=Asia/Bangkok, inserts a Date into DATETIME(3) and TIMESTAMP(3), and verifies binary/text decoding produce identical ISO strings; supports Docker or local MySQL and handles setup/teardown.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing MySQL DATETIME/TIMESTAMP decoding to use UTC instead of local time.
Description check ✅ Passed The description includes a repro, root cause analysis, fix explanation, and verification section covering all required context, though it exceeds the template's two-section structure.
Linked Issues check ✅ Passed The PR directly addresses issue #29208 by fixing both MySQL binary protocol (gregorianDateTimeToMSUTC) and text protocol (DateTime.fromText) to decode timezone-naive date components as UTC instead of local time.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing MySQL DATETIME/TIMESTAMP UTC decoding: DateTime.fromText parsing, toJSTimestamp validation, text-protocol ResultSet routing, and binary-protocol DecodeBinaryValue updates for zero-date handling.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — the one-line binary-protocol swap and the new text-protocol parser both correctly interpret MySQL's naive datetime components as UTC.

Extended reasoning...

Overview

This PR fixes a timezone bug in MySQL DATETIME/TIMESTAMP decoding across two protocol paths in and , plus adds a regression test in .

Security risks

None. This is a pure data-decoding correctness fix with no authentication, permission, or injection surface changes.

Level of scrutiny

The changes are mechanical and focused: a one-function-name swap ( → ) for the binary protocol path, and a new ~50-line parser for the text protocol path. Both target the same clear root cause (naive datetime components being interpreted as local time instead of UTC). The binding was already present in JSGlobalObject.zig and only differs by passing . The parser is straightforward positional parsing with correct fractional-second right-padding logic. The regression test explicitly flips and round-trips through all four protocol×column-type combinations.

Other factors

The two bugs flagged as inline comments: (1) the pre-existing dead-code compile error predates this PR and has no callers, so it has zero runtime impact; (2) the unused import is a trivial nit. Neither warrants blocking the fix. Test coverage for the new behavior is solid.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29208.test.ts`:
- Around line 66-74: Test toggles process.env.TZ and is currently marked
concurrent: true which can cause race conditions; remove the concurrent: true
flag from the describeWithContainer invocation (the block using
describeWithContainer("issue `#29208` (containerized MySQL)", { image:
"mysql_plain", concurrent: true }, ...)) or replace it with concurrent: false to
ensure TZ isolation, or alternatively wrap runRoundTrip to acquire a global TZ
mutex before changing process.env.TZ; update the describeWithContainer options
or add a TZ-lock around runRoundTrip to prevent concurrent execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 38916f63-753a-4c13-9bd7-7aa2a45c2f94

📥 Commits

Reviewing files that changed from the base of the PR and between 9617eaa and c624d3a.

📒 Files selected for processing (2)
  • src/sql/mysql/protocol/ResultSet.zig
  • test/regression/issue/29208.test.ts

robobun and others added 2 commits April 12, 2026 09:23
MySQL's DATETIME and TIMESTAMP wire format carries no timezone — the
server returns naive year/month/day/hour/minute/second components. Bun
fed those components through JSC's local-time constructor, so on any
machine whose process TZ was not UTC the returned JS Date was off by
the client's UTC offset.

- Binary protocol: swap gregorianDateTimeToMS (local) for the UTC
  variant in DateTime.toJSTimestamp.
- Text protocol: parse 'YYYY-MM-DD[ HH:MM:SS[.ffffff]]' directly into
  a DateTime and route through the same UTC path, replacing the JS
  Date parser which also treats TZ-less strings as local time.

The send path (DateTime.fromUnixTimestamp) was already UTC-correct, so
writes stored the right value and only reads were wrong, matching the
report.

Fixes #29208
@robobun robobun force-pushed the farm/1208a173/mysql-datetime-utc branch from c624d3a to 094d9c0 Compare April 12, 2026 09:30
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/sql/mysql/MySQLTypes.zig`:
- Around line 529-568: The fromText() parser currently only enforces month ∈
1..12 and day ∈ 1..31 so malformed dates like 2024-02-31 or times like 24:00:00
slip through and are later normalized by gregorianDateTimeToMSUTC(); after
parsing year/month/day/hour/minute/second (use the local variables year, month,
day, result.hour, result.minute, result.second) add strict validation: reject if
hour >= 24 or minute >= 60 or second >= 60, and validate day against the
month/Year (Apr/Jun/Sep/Nov max 30, Feb max 28 or 29 when leap year: leap =
(year % 4 == 0) and (year % 100 != 0 or year % 400 == 0)); return
error.InvalidDateTimeText on invalid values so invalid-but-storable MySQL rows
produce Invalid Date instead of being normalized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5ca67cef-c2dd-4234-b110-ffee671cea09

📥 Commits

Reviewing files that changed from the base of the PR and between c624d3a and 6f8720f.

📒 Files selected for processing (3)
  • src/sql/mysql/MySQLTypes.zig
  • src/sql/mysql/protocol/ResultSet.zig
  • test/regression/issue/29208.test.ts

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all previously flagged issues resolved in 094d9c0.

Extended reasoning...

Overview

This PR fixes MySQL DATETIME/TIMESTAMP deserialization to use UTC instead of local time on both the binary protocol path (swap gregorianDateTimeToMSgregorianDateTimeToMSUTC) and the text protocol path (new DateTime.fromText parser replacing the generic JS date parser). Three files changed: MySQLTypes.zig, ResultSet.zig, and a new regression test.

Security risks

None. This is a pure date-decoding correctness fix with no auth, crypto, or permission surface.

Level of scrutiny

Medium — touches production MySQL wire-protocol decoding on both binary and text paths, but the changes are narrow and mechanical. The fix is well-reasoned: the root cause (local-time constructor) is clearly identified, the fix (UTC constructor) is the right approach, and the regression test exercises all four affected scenarios.

Other factors

All three issues I raised in prior reviews were fixed in autofix.ci commit 094d9c0: (1) the zero-date regression (month < 1 or month > 12 / day < 1 or day > 31 guards added to fromText), (2) the dead-code DateTime.toJS signature mismatch, and (3) the unused afterAll import. The one remaining bug report (binary-protocol zero-date returning epoch 0 instead of NaN) is explicitly pre-existing — it predates this PR and lives in DecodeBinaryValue.zig which was not modified here.

- fromText now rejects impossible calendar values (Feb 30, hour >= 24,
  etc.) via daysInMonth + bounds checks, so permissive MySQL modes that
  store garbage rows produce Invalid Date rather than normalized epoch
  offsets.
- DecodeBinaryValue zero-length date payload (MySQL's '0000-00-00'
  sentinel in the binary protocol) now maps to NaN, matching the
  text-protocol path.
MySQL permissive sql_mode can store partial zero-dates like '2024-00-15'
and send them via the binary protocol as non-zero-length payloads,
bypassing the zero-length NaN guard. Validate the components inside
toJSTimestamp itself so both the binary (DateTime.fromData) and text
(DateTime.fromText) paths surface NaN instead of WTF silently wrapping
month=0 into December of the prior year.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all issues from prior rounds have been addressed and the fix is correct.

Extended reasoning...

Overview

This PR fixes MySQL DATETIME/TIMESTAMP decoding to interpret wire-format date components as UTC instead of local time (issue #29208). It modifies four files: MySQLTypes.zig (adds fromText, moves to gregorianDateTimeToMSUTC, fixes toJS signature, adds validation in toJSTimestamp), DecodeBinaryValue.zig (zero-length payload → NaN instead of epoch), ResultSet.zig (text path now uses DateTime.fromText + UTC conversion), and a new regression test.

Security risks

No security-sensitive code is touched. This is purely a data-decoding correctness fix with no auth, crypto, or permissions involvement.

Level of scrutiny

Medium — this is protocol-level code, but the changes are narrowly scoped, well-commented, and all edge cases (zero-dates, partial zero-dates, impossible calendar values) have been explicitly addressed through the review iteration. The fix is mechanically straightforward: swap local-time interpretation for UTC interpretation on both text and binary paths, with validation that mirrors the pre-existing behavior.

Other factors

The iterative review process was thorough: zero-date NaN consistency across protocols, partial zero-date validation in both fromText and toJSTimestamp, DateTime.toJS signature correctness, and test TZ isolation were all raised and fixed. The one pre-existing issue declined (pre-1970 date handling in gregorianDate) is genuinely out of scope for this PR. The bug hunting system found no new issues in the final state.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all review feedback has been addressed.

Extended reasoning...

Overview

The PR fixes MySQL DATETIME/TIMESTAMP decoding on both the binary (prepared-statement) and text (simple-query) protocol paths to interpret naive server-sent date components as UTC rather than local time. Changes span four files: MySQLTypes.zig (new fromText parser, UTC switch in toJSTimestamp, component validation, toJS compile fix), DecodeBinaryValue.zig (zero-date NaN), ResultSet.zig (text path routing through DateTime.fromText), and a new regression test.

Security risks

None. The change is confined to date/time decoding logic in the MySQL SQL driver. No auth, crypto, or permission paths are touched.

Level of scrutiny

Medium — the fix is targeted and well-motivated, touches only the MySQL date decoding path, and includes a round-trip regression test covering both protocols under a non-UTC timezone. The validation logic (month/day/hour/minute/second range checks, leap-year-aware daysInMonth) is straightforward and consistent with the pre-existing helpers already in the file.

Other factors

All substantive inline comments raised in earlier review iterations were addressed: zero-date NaN consistency across protocols, component range validation including partial zero-dates (2024-00-15), time-field bounds, the latent toJS compile error, and the concurrent: true TZ-mutation race in the test. The one declined item (gregorianDate() pre-1970 bug on the write path) is genuinely out of scope for a read-path fix and was correctly deferred. CI failures visible in the robobun comment are pre-existing infra flakes unrelated to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Bun.SQL Date values returned from DATETIME/TIMESTAMP columns deserialized as local time instead of UTC

1 participant