Skip to content

sql(mysql): add utcDate option for UTC DATETIME/TIMESTAMP decoding#29216

Closed
robobun wants to merge 6 commits into
mainfrom
farm/1208a173/mysql-datetime-utc
Closed

sql(mysql): add utcDate option for UTC DATETIME/TIMESTAMP decoding#29216
robobun wants to merge 6 commits into
mainfrom
farm/1208a173/mysql-datetime-utc

Conversation

@robobun

@robobun robobun commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

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() is 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

Add a utcDate: boolean connection option, defaulting to false so existing applications see no behaviour change. When true:

  • Binary protocol: DateTime.toJSTimestamp uses gregorianDateTimeToMSUTC instead of the local-time variant.
  • Text protocol: parse YYYY-MM-DD[ HH:MM:SS[.ffffff]] directly via a new DateTime.fromText and route through the same UTC conversion (replacing the generic JS date parser for MySQL's text date/datetime rows).
  • Zero-dates / partial zero-dates ('0000-00-00', '2024-00-15', etc.) surface as Invalid Date instead of the Unix epoch / wrapped months.
const sql = new SQL({ url: "mysql://...", utcDate: true });

The option is threaded exactly like bigint: parseOptionsSQLQueryFlags.utcDatecreateQueryMySQLQuery.#flags.utc_dateResultSet.Row.utc_date / decodeBinaryValue. It currently only affects the MySQL/MariaDB adapter.

Verification

test/regression/issue/29208.test.ts flips process.env.TZ to Asia/Bangkok and round-trips a Date through DATETIME and TIMESTAMP columns on both binary (prepared) and text (simple) paths:

Connection Expected per column
utcDate: true 2024-01-15T05:30:45.678Z (matches what was sent)
utcDate omitted (default) 2024-01-14T22:30:45.678Z (historical local-time decode)
utcDate: false (explicit) 2024-01-14T22:30:45.678Z (same as omitted)

Fail-before (src/ reverted to main, test file kept):

  {
-   "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 (local MySQL) > utcDate option gates UTC decoding …

Pass-after: all green, 5 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 — so the utcDate option is MySQL-only for now.

@robobun

robobun commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:34 AM PT - May 1st, 2026

@robobun, your commit b1740ee has 4 failures in Build #49704 (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

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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
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.
Title check ✅ Passed The title accurately reflects the main objective of the PR—adding UTC date decoding for MySQL DATETIME/TIMESTAMP via a new utcDate option—and is clear, concise, and directly related to the primary changes.
Description check ✅ Passed The description is comprehensive and complete, covering what the PR does, the root cause, the fix, verification approach, and relevant notes. It exceeds the basic template requirements with detailed explanations and test results.

✏️ 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

Comment thread test/regression/issue/29208.test.ts
Comment thread src/sql/mysql/MySQLTypes.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

Comment thread test/regression/issue/29208.test.ts
Comment thread src/sql/mysql/MySQLTypes.zig
@robobun robobun force-pushed the farm/1208a173/mysql-datetime-utc branch from c624d3a to 094d9c0 Compare April 12, 2026 09:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

Comment thread src/sql/mysql/MySQLTypes.zig

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

Comment thread src/sql/mysql/protocol/ResultSet.zig
Comment thread src/sql/mysql/protocol/DecodeBinaryValue.zig
Comment thread src/sql/mysql/MySQLTypes.zig

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun resume. Add an option defaulting to false for compatibility. Rebase off main

MySQL's DATETIME/TIMESTAMP wire format carries no timezone — the server
returns naive year/month/day/hour/minute/second components. Bun decodes
these via JSC's local-time constructor, so under a non-UTC process TZ
the resulting JS Date is off by the client's UTC offset (the send path
already encodes UTC components, so a Date does not round-trip).

Add a `utcDate` connection option, defaulting to `false` for
compatibility with the existing local-time decoding. When `true`:

  * Binary protocol: components are fed to gregorianDateTimeToMSUTC
    instead of the local-time variant.
  * Text protocol: the naive 'YYYY-MM-DD[ HH:MM:SS[.ffffff]]' string is
    parsed directly by a new DateTime.fromText instead of the generic
    JS date parser, and converted as UTC.
  * Zero-date sentinels ('0000-00-00') and impossible calendar values
    surface as Invalid Date instead of wrapping or the Unix epoch.

The option is threaded like `bigint`: parseOptions -> SQLQueryFlags ->
createQuery -> MySQLQuery.#flags -> ResultSet.Row / decodeBinaryValue.

Fixes #29208.
@robobun robobun force-pushed the farm/1208a173/mysql-datetime-utc branch from a570c95 to d9524b0 Compare May 1, 2026 09:09
@robobun robobun requested a review from alii as a code owner May 1, 2026 09:09
@robobun robobun changed the title sql(mysql): decode DATETIME/TIMESTAMP as UTC sql(mysql): add utcDate option for UTC DATETIME/TIMESTAMP decoding May 1, 2026
Comment thread test/regression/issue/29208.test.ts Outdated
Comment thread packages/bun-types/sql.d.ts
robobun and others added 3 commits May 1, 2026 10:38
The mechanical gate runs the test without sourcing /opt/start-services.sh,
so MariaDB is installed but not running. The local-path beforeAll would
then fail to provision and early-return, making the test pass vacuously
(0 expect() calls) on both with-fix and without-fix legs.

Detect an installed-but-stopped server via `mysql -u root -e 'SELECT 1'`,
launch `mysqld_safe` if present, and poll for readiness before
provisioning. On environments without a local mysql/mysqld_safe (most CI
lanes) this is a no-op and the Docker path is used instead.
@robobun

robobun commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #31212 addresses the same root cause (#29208) by changing the default MySQL DATETIME/TIMESTAMP decode to UTC so it matches the UTC-based encode, rather than adding an opt-in flag, and is based on the current source layout.

@alii

alii commented May 30, 2026

Copy link
Copy Markdown
Member

Superseded by #31212

@alii alii closed this May 30, 2026
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

3 participants