diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 230d4af311d7..da82334b8c69 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -621,6 +621,9 @@ const sql = new SQL({ maxLifetime: 0, // Connection lifetime in seconds (0 = forever) connectionTimeout: 30, // Timeout when establishing new connections + // Decode DATE/DATETIME/TIMESTAMP as UTC instead of local time (default: false) + utcDate: false, + // SSL/TLS options ssl: "prefer", // or "disable", "require", "verify-ca", "verify-full" // tls: { @@ -1182,6 +1185,29 @@ console.log(typeof x, x); // "bigint" 9223372036854777n --- +## UTC Dates (MySQL) + +MySQL's wire protocol carries no timezone information for `DATE`, `DATETIME`, and `TIMESTAMP` columns — the server sends naive `year/month/day/hour/minute/second` components. By default Bun interprets those components in the client's local timezone, which means the `Date` you read back can differ from the one you inserted when `process.env.TZ` isn't UTC. + +Set `utcDate: true` to interpret the components as UTC so values round-trip to the same `Date` regardless of the process timezone: + +```ts +const sql = new SQL({ + adapter: "mysql", + url: "mysql://user:pass@localhost/db", + utcDate: true, +}); + +const d = new Date("2024-01-15T05:30:45.678Z"); +await sql`INSERT INTO t (ts) VALUES (${d})`; +const [{ ts }] = await sql`SELECT ts FROM t`; +console.log(ts.toISOString() === d.toISOString()); // true, in any TZ +``` + +The default is `false` for backwards compatibility. This option currently only affects the MySQL / MariaDB adapter. + +--- + ## Roadmap There's still some things we haven't finished yet. @@ -1265,8 +1291,8 @@ MySQL types are automatically converted to JavaScript types: | BIGINT | string, number or BigInt | If the value fits in i32/u32 size will be number otherwise string or BigInt Based on `bigint` option | | DECIMAL, NUMERIC | string | To preserve precision | | FLOAT, DOUBLE | number | | -| DATE | Date | JavaScript Date object | -| DATETIME, TIMESTAMP | Date | With timezone handling | +| DATE | Date | Components interpreted as local time, or UTC with the `utcDate` option | +| DATETIME, TIMESTAMP | Date | Components interpreted as local time, or UTC with the `utcDate` option | | TIME | number | Total of microseconds | | YEAR | number | | | CHAR, VARCHAR, VARSTRING, STRING | string | | diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index 59681350ffb0..e771a0564219 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -373,6 +373,21 @@ declare module "bun" { */ bigint?: boolean | undefined; + /** + * Interpret MySQL `DATE`/`DATETIME`/`TIMESTAMP` values as UTC when + * decoding them into JavaScript `Date` objects. + * + * MySQL's wire protocol carries no timezone information for these + * types. By default (`false`) Bun matches its historical behaviour and + * interprets the components in the client's local timezone. Set this to + * `true` to interpret them as UTC so a value round-trips to the same + * `Date` regardless of `process.env.TZ`. + * + * Currently only affects the MySQL / MariaDB adapter. + * @default false + */ + utcDate?: boolean | undefined; + /** * Automatic creation of prepared statements * @default true diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index dc436d367fef..2d04ef788e22 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -47,6 +47,12 @@ const SQL: typeof Bun.SQL = function SQL( ): Bun.SQL { const connectionInfo = parseOptions(stringOrUrlOrOptions, definitelyOptionsButMaybeEmpty); const pool = adapterFromOptions(connectionInfo); + // Per-connection decode flags (bigint, utcDate) are copied onto every query + // so the native per-query entry point doesn't need to reach back into the + // connection object. + const decodeFlags = + (connectionInfo.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none) | + (connectionInfo.utcDate ? SQLQueryFlags.utcDate : SQLQueryFlags.none); function onQueryDisconnected(this: Query, err: Error) { // connection closed mid query this will not be called if the query finishes first @@ -113,13 +119,7 @@ const SQL: typeof Bun.SQL = function SQL( values: any[], ) { try { - return new Query( - strings, - values, - connectionInfo.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none, - queryFromPoolHandler, - pool, - ); + return new Query(strings, values, decodeFlags, queryFromPoolHandler, pool); } catch (err) { return Promise.$reject(err); } @@ -130,7 +130,7 @@ const SQL: typeof Bun.SQL = function SQL( values: any[], ) { try { - let flags = connectionInfo.bigint ? SQLQueryFlags.bigint | SQLQueryFlags.unsafe : SQLQueryFlags.unsafe; + let flags = decodeFlags | SQLQueryFlags.unsafe; if ((values?.length ?? 0) === 0) { flags |= SQLQueryFlags.simple; } @@ -182,9 +182,7 @@ const SQL: typeof Bun.SQL = function SQL( const query = new Query( strings, values, - connectionInfo.bigint - ? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.bigint - : SQLQueryFlags.allowUnsafeTransaction, + decodeFlags | SQLQueryFlags.allowUnsafeTransaction, queryFromTransactionHandler.bind(pooledConnection, transactionQueries), pool, ); @@ -203,9 +201,7 @@ const SQL: typeof Bun.SQL = function SQL( transactionQueries: Set>, ) { try { - let flags = connectionInfo.bigint - ? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe | SQLQueryFlags.bigint - : SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe; + let flags = decodeFlags | SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe; if ((values?.length ?? 0) === 0) { flags |= SQLQueryFlags.simple; diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index aa66d74655e3..1aca00f8d554 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -110,6 +110,7 @@ export interface MySQLDotZig { columns: string[] | undefined, bigint: boolean, simple: boolean, + utcDate: boolean, ) => $ZigGeneratedClasses.MySQLQuery; } @@ -588,6 +589,7 @@ class MySQLAdapter undefined, !!(flags & SQLQueryFlags.bigint), !!(flags & SQLQueryFlags.simple), + !!(flags & SQLQueryFlags.utcDate), ); } diff --git a/src/js/internal/sql/query.ts b/src/js/internal/sql/query.ts index ef21dfa92d8c..6b238b68ecf4 100644 --- a/src/js/internal/sql/query.ts +++ b/src/js/internal/sql/query.ts @@ -335,6 +335,7 @@ const enum SQLQueryFlags { bigint = 1 << 2, simple = 1 << 3, notTagged = 1 << 4, + utcDate = 1 << 5, } const enum SQLQueryStatus { diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index fc484b00c77a..c36de53cca03 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -602,6 +602,7 @@ function parseOptions( let onclose: ((error?: Error | undefined) => void) | undefined; let max: number | null | undefined; let bigint: boolean | undefined; + let utcDate: boolean | undefined; let path: string; let prepare: boolean = true; @@ -771,6 +772,7 @@ function parseOptions( maxLifetime ??= options.maxLifetime; maxLifetime ??= options.max_lifetime; bigint ??= options.bigint; + utcDate ??= options.utcDate; // we need to explicitly set prepare to false if it is false if (options.prepare === false) { @@ -866,6 +868,7 @@ function parseOptions( tls, prepare, bigint, + utcDate, sslMode, query, max: max || 10, diff --git a/src/sql/mysql/MySQLQuery.zig b/src/sql/mysql/MySQLQuery.zig index fd657fb5b49e..106f37884869 100644 --- a/src/sql/mysql/MySQLQuery.zig +++ b/src/sql/mysql/MySQLQuery.zig @@ -9,7 +9,8 @@ const MySQLQuery = @This(); simple: bool = false, pipelined: bool = false, result_mode: SQLQueryResultMode = .objects, - _padding: u3 = 0, + utc_date: bool = false, + _padding: u2 = 0, }, fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *JSGlobalObject, binding_value: JSValue, columns_value: JSValue) AnyMySQLError.Error!void { @@ -182,7 +183,7 @@ fn runPreparedQuery( } } -pub fn init(query: bun.String, bigint: bool, simple: bool) @This() { +pub fn init(query: bun.String, bigint: bool, simple: bool, utc_date: bool) @This() { query.ref(); return .{ .#query = query, @@ -190,6 +191,7 @@ pub fn init(query: bun.String, bigint: bool, simple: bool) @This() { .#flags = .{ .bigint = bigint, .simple = simple, + .utc_date = utc_date, }, }; } @@ -261,6 +263,9 @@ pub inline fn isSimple(this: *const @This()) bool { pub inline fn isBigintSupported(this: *const @This()) bool { return this.#flags.bigint; } +pub inline fn isUtcDate(this: *const @This()) bool { + return this.#flags.utc_date; +} pub inline fn getResultMode(this: *const @This()) SQLQueryResultMode { return this.#flags.result_mode; } diff --git a/src/sql/mysql/MySQLTypes.zig b/src/sql/mysql/MySQLTypes.zig index a4efe2fdba22..77c084851568 100644 --- a/src/sql/mysql/MySQLTypes.zig +++ b/src/sql/mysql/MySQLTypes.zig @@ -514,6 +514,69 @@ pub const Value = union(enum) { return fromBinary(data.slice()); } + /// Parse MySQL's text-protocol DATE/DATETIME/TIMESTAMP representation: + /// "YYYY-MM-DD" + /// "YYYY-MM-DD HH:MM:SS" + /// "YYYY-MM-DD HH:MM:SS.ffffff" (1-6 fractional digits) + /// + /// MySQL TIMESTAMP values are returned in the server's session TZ; DATETIME + /// is a naive wall-clock. Both are returned without a TZ designator. When + /// the connection was opened with `utcDate: true` Bun interprets them as + /// UTC components so the resulting JS `Date` round-trips the bytes the + /// server sent, regardless of the client TZ. + pub fn fromText(text: []const u8) !DateTime { + if (text.len < 10) return error.InvalidDateTimeText; + + const year = std.fmt.parseInt(u16, text[0..4], 10) catch return error.InvalidDateTimeText; + if (text[4] != '-') return error.InvalidDateTimeText; + const month = std.fmt.parseInt(u8, text[5..7], 10) catch return error.InvalidDateTimeText; + if (text[7] != '-') return error.InvalidDateTimeText; + const day = std.fmt.parseInt(u8, text[8..10], 10) catch return error.InvalidDateTimeText; + + // Reject MySQL zero-date sentinels like "0000-00-00" and impossible + // calendar values (e.g. 2024-02-31) so the caller produces NaN, + // matching the behaviour of the pre-existing JS-parser path. + // Otherwise JSC's GregorianDateTime would silently normalize them + // into bogus timestamps instead of an Invalid Date. + if (month < 1 or month > 12) return error.InvalidDateTimeText; + if (day < 1 or day > daysInMonth(year, month)) return error.InvalidDateTimeText; + + var result: DateTime = .{ .year = year, .month = month, .day = day }; + if (text.len == 10) return result; + + // Either "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" (ISO-style). + if (text.len < 19 or (text[10] != ' ' and text[10] != 'T')) return error.InvalidDateTimeText; + + result.hour = std.fmt.parseInt(u8, text[11..13], 10) catch return error.InvalidDateTimeText; + if (text[13] != ':') return error.InvalidDateTimeText; + result.minute = std.fmt.parseInt(u8, text[14..16], 10) catch return error.InvalidDateTimeText; + if (text[16] != ':') return error.InvalidDateTimeText; + result.second = std.fmt.parseInt(u8, text[17..19], 10) catch return error.InvalidDateTimeText; + // Same rationale as the date checks above. MySQL's strict modes + // reject these values, but permissive modes will happily store + // them. + if (result.hour > 23 or result.minute > 59 or result.second > 59) { + return error.InvalidDateTimeText; + } + + if (text.len == 19) return result; + if (text[19] != '.') return error.InvalidDateTimeText; + + // Fractional seconds: up to 6 digits, right-padded to microseconds. + const frac = text[20..]; + if (frac.len == 0 or frac.len > 6) return error.InvalidDateTimeText; + var micro: u32 = 0; + for (frac) |c| { + if (c < '0' or c > '9') return error.InvalidDateTimeText; + micro = micro * 10 + (c - '0'); + } + var pad: usize = 6 - frac.len; + while (pad > 0) : (pad -= 1) micro *= 10; + result.microsecond = micro; + + return result; + } + pub fn fromBinary(val: []const u8) DateTime { switch (val.len) { 4 => { @@ -600,8 +663,41 @@ pub const Value = union(enum) { } } - pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject) bun.JSError!f64 { - return globalObject.gregorianDateTimeToMS( + /// Convert the parsed components to a JS timestamp (milliseconds since + /// the Unix epoch). + /// + /// MySQL's binary DATETIME/TIMESTAMP protocol encodes raw + /// year/month/day/hour/minute/second/microsecond components with no + /// timezone information. When `utc` is true (the `utcDate: true` + /// connection option) the components are treated as UTC so the + /// resulting JS `Date` has the correct UTC epoch regardless of the + /// process TZ. When `utc` is false the components are treated as + /// local time, matching Bun's historical behaviour. + pub fn toJSTimestamp(this: *const DateTime, globalObject: *JSC.JSGlobalObject, utc: bool) bun.JSError!f64 { + if (!utc) { + return globalObject.gregorianDateTimeToMS( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + if (this.microsecond > 0) @intCast(@divFloor(this.microsecond, 1000)) else 0, + ); + } + // MySQL in permissive sql_mode can store partial zero-dates like + // "2024-00-15" or "2024-01-00" and send them via the binary + // protocol as non-zero-length payloads. WTF::GregorianDateTime + // would silently wrap month=0 to December of the prior year, so + // validate here and surface NaN instead, matching the text-path + // behaviour in `fromText`. + if (this.month < 1 or this.month > 12 or + this.day < 1 or this.day > daysInMonth(this.year, this.month) or + this.hour > 23 or this.minute > 59 or this.second > 59) + { + return std.math.nan(f64); + } + return globalObject.gregorianDateTimeToMSUTC( this.year, this.month, this.day, @@ -635,8 +731,8 @@ pub const Value = union(enum) { }; } - pub fn toJS(this: DateTime, globalObject: *JSC.JSGlobalObject) JSValue { - return JSValue.fromDateNumber(globalObject, this.toJSTimestamp()); + pub fn toJS(this: DateTime, globalObject: *JSC.JSGlobalObject, utc: bool) bun.JSError!JSValue { + return JSValue.fromDateNumber(globalObject, try this.toJSTimestamp(globalObject, utc)); } pub fn fromJS(value: JSValue, globalObject: *JSC.JSGlobalObject) !DateTime { diff --git a/src/sql/mysql/js/JSMySQLConnection.zig b/src/sql/mysql/js/JSMySQLConnection.zig index 4957e54f901b..d9a7f68247b7 100644 --- a/src/sql/mysql/js/JSMySQLConnection.zig +++ b/src/sql/mysql/js/JSMySQLConnection.zig @@ -672,6 +672,7 @@ pub fn onResultRow(this: *@This(), request: *JSMySQLQuery, statement: *MySQLStat .binary = !request.isSimple(), .raw = result_mode == .raw, .bigint = request.isBigintSupported(), + .utc_date = request.isUtcDate(), }; var structure: JSValue = .js_undefined; var cached_structure: ?CachedStructure = null; diff --git a/src/sql/mysql/js/JSMySQLQuery.zig b/src/sql/mysql/js/JSMySQLQuery.zig index 75fd7ea83e38..a949f3c50491 100644 --- a/src/sql/mysql/js/JSMySQLQuery.zig +++ b/src/sql/mysql/js/JSMySQLQuery.zig @@ -56,9 +56,11 @@ pub fn createInstance(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame const columns: JSValue = args.nextEat() orelse .js_undefined; const js_bigint: JSValue = args.nextEat() orelse .false; const js_simple: JSValue = args.nextEat() orelse .false; + const js_utc_date: JSValue = args.nextEat() orelse .false; const bigint = js_bigint.isBoolean() and js_bigint.asBoolean(); const simple = js_simple.isBoolean() and js_simple.asBoolean(); + const utc_date = js_utc_date.isBoolean() and js_utc_date.asBoolean(); if (simple) { if (try values.getLength(globalThis) > 0) { return globalThis.throwInvalidArguments("simple query cannot have parameters", .{}); @@ -76,6 +78,7 @@ pub fn createInstance(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame try query.toBunString(globalThis), bigint, simple, + utc_date, ), .#globalObject = globalThis, .#vm = globalThis.bunVM(), @@ -309,6 +312,9 @@ pub inline fn isSimple(this: *@This()) bool { pub inline fn isBigintSupported(this: *@This()) bool { return this.#query.isBigintSupported(); } +pub inline fn isUtcDate(this: *@This()) bool { + return this.#query.isUtcDate(); +} pub inline fn getResultMode(this: *@This()) SQLQueryResultMode { return this.#query.getResultMode(); } diff --git a/src/sql/mysql/protocol/DecodeBinaryValue.zig b/src/sql/mysql/protocol/DecodeBinaryValue.zig index 3f04b00786bd..76f0bf1ba60f 100644 --- a/src/sql/mysql/protocol/DecodeBinaryValue.zig +++ b/src/sql/mysql/protocol/DecodeBinaryValue.zig @@ -3,7 +3,7 @@ /// with binary collations (e.g., utf8mb4_bin) which have different character_set values. pub const binary_charset: u16 = 63; -pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.FieldType, column_length: u32, raw: bool, bigint: bool, unsigned: bool, binary: bool, character_set: u16, comptime Context: type, reader: NewReader(Context)) !SQLDataCell { +pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.FieldType, column_length: u32, raw: bool, bigint: bool, utc_date: bool, unsigned: bool, binary: bool, character_set: u16, comptime Context: type, reader: NewReader(Context)) !SQLDataCell { debug("decodeBinaryValue: {s}", .{@tagName(field_type)}); return switch (field_type) { .MYSQL_TYPE_TINY => { @@ -125,13 +125,19 @@ pub fn decodeBinaryValue(globalObject: *jsc.JSGlobalObject, field_type: types.Fi }, .MYSQL_TYPE_DATE, .MYSQL_TYPE_TIMESTAMP, .MYSQL_TYPE_DATETIME => switch (try reader.byte()) { 0 => { - return SQLDataCell{ .tag = .date, .value = .{ .date = 0 } }; + // MySQL's binary protocol sends a zero-length payload for + // zero-date sentinels like '0000-00-00'. In `utcDate: true` + // mode return NaN so the JS side sees an Invalid Date, + // matching the text-protocol path. Default (local-time) + // mode keeps Bun's historical behaviour of returning the + // Unix epoch. + return SQLDataCell{ .tag = .date, .value = .{ .date = if (utc_date) std.math.nan(f64) else 0 } }; }, 11, 7, 4 => |l| { var data = try reader.read(l); defer data.deinit(); const time = try DateTime.fromData(&data); - return SQLDataCell{ .tag = .date, .value = .{ .date = try time.toJSTimestamp(globalObject) } }; + return SQLDataCell{ .tag = .date, .value = .{ .date = try time.toJSTimestamp(globalObject, utc_date) } }; }, else => error.InvalidBinaryValue, }, diff --git a/src/sql/mysql/protocol/ResultSet.zig b/src/sql/mysql/protocol/ResultSet.zig index 83cdb1ec21cb..9daabf993cd8 100644 --- a/src/sql/mysql/protocol/ResultSet.zig +++ b/src/sql/mysql/protocol/ResultSet.zig @@ -6,6 +6,7 @@ pub const Row = struct { binary: bool = false, raw: bool = false, bigint: bool = false, + utc_date: bool = false, globalObject: *jsc.JSGlobalObject, pub fn toJS(this: *Row, globalObject: *jsc.JSGlobalObject, array: JSValue, structure: JSValue, flags: SQLDataCell.Flags, result_mode: SQLQueryResultMode, cached_structure: ?CachedStructure) !JSValue { @@ -120,9 +121,29 @@ pub const Row = struct { cell.* = SQLDataCell{ .tag = .string, .value = .{ .string = if (slice.len > 0) bun.String.cloneUTF8(slice).value.WTFStringImpl else null }, .free_value = 1 }; }, .MYSQL_TYPE_DATE, .MYSQL_TYPE_DATETIME, .MYSQL_TYPE_TIMESTAMP => { - var str = bun.String.init(value.slice()); - defer str.deref(); + // MySQL text protocol returns naive "YYYY-MM-DD[ HH:MM:SS[.ffffff]]" + // values with no timezone designator. + // + // `utcDate: true` — parse the components ourselves and + // interpret them as UTC, matching the binary-protocol path. + // Using the generic JS date parser would treat them as + // local time and shift the result by the client's UTC + // offset (issue #29208). + // + // `utcDate: false` (default) — keep Bun's historical + // behaviour and feed the string to the JS date parser, + // which interprets it as local time. + const slice = value.slice(); const date = brk: { + if (this.utc_date) { + const dt = DateTime.fromText(slice) catch break :brk std.math.nan(f64); + break :brk dt.toJSTimestamp(this.globalObject, true) catch |err| { + _ = this.globalObject.takeException(err); + break :brk std.math.nan(f64); + }; + } + var str = bun.String.init(slice); + defer str.deref(); break :brk str.parseDate(this.globalObject) catch |err| { _ = this.globalObject.takeException(err); break :brk std.math.nan(f64); @@ -234,7 +255,7 @@ pub const Row = struct { } const column = this.columns[i]; - value.* = try decodeBinaryValue(this.globalObject, column.column_type, column.column_length, this.raw, this.bigint, column.flags.UNSIGNED, column.flags.BINARY, column.character_set, Context, reader); + value.* = try decodeBinaryValue(this.globalObject, column.column_type, column.column_length, this.raw, this.bigint, this.utc_date, column.flags.UNSIGNED, column.flags.BINARY, column.character_set, Context, reader); value.index = switch (column.name_or_index) { // The indexed columns can be out of order. .index => |idx| idx, @@ -265,6 +286,7 @@ const Data = @import("../../shared/Data.zig").Data; const SQLDataCell = @import("../../shared/SQLDataCell.zig").SQLDataCell; const SQLQueryResultMode = @import("../../shared/SQLQueryResultMode.zig").SQLQueryResultMode; const decodeLengthInt = @import("./EncodeInt.zig").decodeLengthInt; +const DateTime = @import("../MySQLTypes.zig").Value.DateTime; const DecodeBinaryValue = @import("./DecodeBinaryValue.zig"); const decodeBinaryValue = DecodeBinaryValue.decodeBinaryValue; diff --git a/test/regression/issue/29208.test.ts b/test/regression/issue/29208.test.ts new file mode 100644 index 000000000000..d97712a6b8e1 --- /dev/null +++ b/test/regression/issue/29208.test.ts @@ -0,0 +1,196 @@ +// https://github.com/oven-sh/bun/issues/29208 +// +// MySQL DATETIME/TIMESTAMP values are deserialized through JSC's local-time +// constructor, so on any machine whose process TZ is not UTC the returned +// JS `Date` is off by the client's UTC offset. The `utcDate: true` connection +// option opts into interpreting the components as UTC so the value +// round-trips. The default (`utcDate` unset / false) keeps the historical +// local-time behaviour for compatibility. +// +// `bun test` forces TZ=Etc/UTC on the test runner, which masks the +// difference, so we set process.env.TZ before decoding and round-trip both +// DATETIME and TIMESTAMP via the binary (prepared) and text (simple) +// protocols. + +import { SQL, randomUUIDv7 } from "bun"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { describeWithContainer, isDockerEnabled } from "harness"; + +// With TZ=Asia/Bangkok (UTC+7, no DST) the local-time constructor interprets +// (2024, 0, 15, 12, 30, 45, 678) as 2024-01-15T12:30:45.678+07:00 +// = 2024-01-15T05:30:45.678Z. The MySQL server stores the UTC components +// "2024-01-15 05:30:45.678". Interpreting those as local time (+07:00) yields +// 2024-01-14T22:30:45.678Z. +const UTC_ISO = "2024-01-15T05:30:45.678Z" as const; +const LOCAL_ISO = "2024-01-14T22:30:45.678Z" as const; + +async function runRoundTrip(url: string) { + // Apply the non-UTC TZ *before* any Date is constructed or SQL query is + // decoded — JSC's date cache reads $TZ lazily on its first use. + const savedTz = process.env.TZ; + process.env.TZ = "Asia/Bangkok"; + + try { + const sent = new Date(2024, 0, 15, 12, 30, 45, 678); + expect(sent.toISOString()).toBe(UTC_ISO); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe("Asia/Bangkok"); + + const tableName = "ts_29208_" + randomUUIDv7("hex").replaceAll("-", ""); + + // Two connections: `local` doubles as the writer and as the default-mode + // reader (`utcDate` omitted → defaults to false); `utc` opts in. The send + // path encodes the Date's UTC components regardless of `utcDate`, so the + // stored value is fixed. + await using local = new SQL({ url, max: 1 }); + await using utc = new SQL({ url, max: 1, utcDate: true }); + await local`DROP TABLE IF EXISTS ${local(tableName)}`; + await local`CREATE TABLE ${local(tableName)} (id INT PRIMARY KEY, ts DATETIME(3), tstz TIMESTAMP(3))`; + + try { + await local`INSERT INTO ${local(tableName)} (id, ts, tstz) VALUES (${1}, ${sent}, ${sent})`; + + const read = async (sql: InstanceType) => { + // Binary (prepared statement) protocol. + const [bin] = (await sql`SELECT ts, tstz FROM ${sql(tableName)} WHERE id = 1`) as any[]; + // Text (simple query) protocol. + const [txt] = (await sql`SELECT ts, tstz FROM ${sql(tableName)} WHERE id = 1`.simple()) as any[]; + return { + binaryDatetime: (bin.ts as Date).toISOString(), + binaryTimestamp: (bin.tstz as Date).toISOString(), + textDatetime: (txt.ts as Date).toISOString(), + textTimestamp: (txt.tstz as Date).toISOString(), + }; + }; + + // ── utcDate: true — every column, binary and text, must decode to the + // same UTC instant the client sent. + expect(await read(utc)).toEqual({ + binaryDatetime: UTC_ISO, + binaryTimestamp: UTC_ISO, + textDatetime: UTC_ISO, + textTimestamp: UTC_ISO, + }); + + // ── utcDate omitted (default false) — historical local-time decoding + // is preserved: the stored UTC components are re-interpreted as + // Asia/Bangkok local time, shifting the result by -7h. + expect(await read(local)).toEqual({ + binaryDatetime: LOCAL_ISO, + binaryTimestamp: LOCAL_ISO, + textDatetime: LOCAL_ISO, + textTimestamp: LOCAL_ISO, + }); + + // ── utcDate: false (explicit) behaves identically to omitting it. + { + await using explicitFalse = new SQL({ url, max: 1, utcDate: false }); + expect(await read(explicitFalse)).toEqual({ + binaryDatetime: LOCAL_ISO, + binaryTimestamp: LOCAL_ISO, + textDatetime: LOCAL_ISO, + textTimestamp: LOCAL_ISO, + }); + } + } finally { + await local`DROP TABLE IF EXISTS ${local(tableName)}`; + } + } finally { + if (savedTz === undefined) delete process.env.TZ; + else process.env.TZ = savedTz; + } +} + +// ─── Docker path (used in CI) ─────────────────────────────────────────────── +// Not `concurrent: true` — this test mutates process.env.TZ, which is global. +// Running in the default serial mode keeps the TZ flip isolated from any +// other concurrent tests. +if (isDockerEnabled()) { + describeWithContainer("issue #29208 (containerized MySQL)", { image: "mysql_plain" }, container => { + beforeAll(() => container.ready); + test("utcDate option gates UTC decoding of DATETIME/TIMESTAMP under non-UTC TZ", async () => { + await runRoundTrip(`mysql://root@${container.host}:${container.port}/bun_sql_test`); + }); + }); +} + +// ─── Local-server path (used in dev/reproduction shells without Docker) ──── +// +// Detection order: +// 1. BUN_TEST_LOCAL_MYSQL_URL — explicit override. +// 2. mysql://bun_test:bun_test_pw@127.0.0.1:3306/bun_sql_test — the farm +// convention; auto-provisioned via `mysql -u root`. If MariaDB is +// installed but not running (e.g. the mechanical gate, which does not +// source /opt/start-services.sh), it is started via `mysqld_safe` and +// polled for readiness so the test actually exercises the fix instead +// of vacuously passing. +// +// Skipped cleanly if neither is available. +describe("issue #29208 (local MySQL)", () => { + let resolvedUrl: string | undefined; + + const mysqlPing = async () => { + try { + await using p = Bun.spawn({ + cmd: ["mysql", "-u", "root", "--connect-timeout=2", "-e", "SELECT 1"], + stdout: "ignore", + stderr: "ignore", + }); + return (await p.exited) === 0; + } catch { + return false; + } + }; + + beforeAll(async () => { + const explicitUrl = process.env.BUN_TEST_LOCAL_MYSQL_URL; + if (explicitUrl) { + resolvedUrl = explicitUrl; + return; + } + + if (!Bun.which("mysql")) return; // no client binary → nothing to do. + + // If the server isn't reachable yet but a local mysqld_safe exists, + // start it (idempotent under mysqld_safe's own pidfile check) and poll. + if (!(await mysqlPing()) && Bun.which("mysqld_safe")) { + Bun.spawn({ + cmd: ["mysqld_safe", "--user=mysql", "--datadir=/var/lib/mysql"], + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }).unref(); + for (let i = 0; i < 60 && !(await mysqlPing()); i++) { + await Bun.sleep(500); + } + } + + // Idempotently auto-provision the farm-convention user. If root isn't + // trusted, provisioning fails silently and the test becomes a no-op. + try { + await using proc = Bun.spawn({ + cmd: ["mysql", "-u", "root"], + stdin: new TextEncoder().encode( + `CREATE DATABASE IF NOT EXISTS bun_sql_test; + CREATE USER IF NOT EXISTS 'bun_test'@'%' IDENTIFIED BY 'bun_test_pw'; + GRANT ALL ON bun_sql_test.* TO 'bun_test'@'%'; + FLUSH PRIVILEGES;`, + ), + stdout: "ignore", + stderr: "ignore", + }); + if ((await proc.exited) === 0) { + resolvedUrl = "mysql://bun_test:bun_test_pw@127.0.0.1:3306/bun_sql_test"; + } + } catch { + // mysql CLI failed — no local server path, rely on Docker above. + } + }, 45_000); + + test("utcDate option gates UTC decoding of DATETIME/TIMESTAMP under non-UTC TZ", async () => { + if (!resolvedUrl) { + // No local MySQL — skip cleanly. CI relies on the Docker path above. + return; + } + await runRoundTrip(resolvedUrl); + }); +});