diff --git a/src/bun.js/bindings/JSValue.zig b/src/bun.js/bindings/JSValue.zig index 9d6716389a3..fcd87e4e1c7 100644 --- a/src/bun.js/bindings/JSValue.zig +++ b/src/bun.js/bindings/JSValue.zig @@ -405,8 +405,15 @@ pub const JSValue = enum(i64) { return bun.jsc.fromJSHostCallGeneric(globalObject, @src(), JSC__JSValue__push, .{ value, globalObject, out }); } - extern fn JSC__JSValue__toISOString(*jsc.JSGlobalObject, jsc.JSValue, *[28]u8) c_int; - pub fn toISOString(this: JSValue, globalObject: *jsc.JSGlobalObject, buf: *[28]u8) []const u8 { + /// Buffer type for `toISOString` / `getDateNowISOString`. Must match the + /// `char in[64]` parameter of `Bun::toISOString` in `wtf-bindings.cpp`. + pub const ISOStringBuffer = [64]u8; + + extern fn JSC__JSValue__toISOString(*jsc.JSGlobalObject, jsc.JSValue, *ISOStringBuffer) c_int; + /// Serializes a JavaScript `Date` value as an ISO 8601 / RFC 3339 string + /// (the same format as `Date.prototype.toISOString`). Returns an empty + /// slice on failure (e.g. the value is not a finite `Date`). + pub fn toISOString(this: JSValue, globalObject: *jsc.JSGlobalObject, buf: *ISOStringBuffer) []const u8 { const count = JSC__JSValue__toISOString(globalObject, this, buf); if (count < 0) { return ""; @@ -414,8 +421,10 @@ pub const JSValue = enum(i64) { return buf[0..@as(usize, @intCast(count))]; } - extern fn JSC__JSValue__DateNowISOString(*JSGlobalObject, f64) JSValue; - pub fn getDateNowISOString(globalObject: *jsc.JSGlobalObject, buf: *[28]u8) []const u8 { + extern fn JSC__JSValue__DateNowISOString(*jsc.JSGlobalObject, *ISOStringBuffer) c_int; + /// Writes `new Date(Date.now()).toISOString()` into `buf` and returns + /// the written slice. Empty slice on failure. + pub fn getDateNowISOString(globalObject: *jsc.JSGlobalObject, buf: *ISOStringBuffer) []const u8 { const count = JSC__JSValue__DateNowISOString(globalObject, buf); if (count < 0) { return ""; diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index ee42eaa398e..4dd943f8f3b 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -5801,7 +5801,6 @@ extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNullTerminatedString(JSC // this is largely copied from dateProtoFuncToISOString extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, EncodedJSValue dateValue, char* buf) { - char buffer[64]; JSC::DateInstance* thisDateObj = dynamicDowncast(JSC::JSValue::decode(dateValue)); if (!thisDateObj) return -1; @@ -5811,41 +5810,18 @@ extern "C" int JSC__JSValue__toISOString(JSC::JSGlobalObject* globalObject, Enco auto& vm = JSC::getVM(globalObject); - return static_cast(Bun::toISOString(vm, thisDateObj->internalNumber(), buffer)); + return static_cast(Bun::toISOString(vm, thisDateObj->internalNumber(), buf)); } extern "C" int JSC__JSValue__DateNowISOString(JSC::JSGlobalObject* globalObject, char* buf) { - char buffer[29]; - JSC::DateInstance* thisDateObj = JSC::DateInstance::create(globalObject->vm(), globalObject->dateStructure(), globalObject->jsDateNow()); - - if (!std::isfinite(thisDateObj->internalNumber())) - return -1; - auto& vm = JSC::getVM(globalObject); + double now = globalObject->jsDateNow(); - const GregorianDateTime* gregorianDateTime = thisDateObj->gregorianDateTimeUTC(vm.dateCache); - if (!gregorianDateTime) - return -1; - - // If the year is outside the bounds of 0 and 9999 inclusive we want to use the extended year format (ES 15.9.1.15.1). - int ms = static_cast(fmod(thisDateObj->internalNumber(), msPerSecond)); - if (ms < 0) - ms += msPerSecond; - - int charactersWritten; - if (gregorianDateTime->year() > 9999 || gregorianDateTime->year() < 0) - charactersWritten = snprintf(buffer, sizeof(buffer), "%+07d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime->year(), gregorianDateTime->month() + 1, gregorianDateTime->monthDay(), gregorianDateTime->hour(), gregorianDateTime->minute(), gregorianDateTime->second(), ms); - else - charactersWritten = snprintf(buffer, sizeof(buffer), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", gregorianDateTime->year(), gregorianDateTime->month() + 1, gregorianDateTime->monthDay(), gregorianDateTime->hour(), gregorianDateTime->minute(), gregorianDateTime->second(), ms); - - memcpy(buf, buffer, charactersWritten); - - ASSERT(charactersWritten > 0 && static_cast(charactersWritten) < sizeof(buffer)); - if (static_cast(charactersWritten) >= sizeof(buffer)) + if (!std::isfinite(now)) return -1; - return charactersWritten; + return static_cast(Bun::toISOString(vm, now, buf)); } #pragma mark - WebCore::DOMFormData diff --git a/src/s3/credentials.zig b/src/s3/credentials.zig index 6dc24d50c1a..afd9627ea38 100644 --- a/src/s3/credentials.zig +++ b/src/s3/credentials.zig @@ -347,7 +347,7 @@ pub const S3Credentials = struct { fn getAMZDate(allocator: std.mem.Allocator) DateResult { // We can also use Date.now() but would be slower and would add jsc dependency - // var buffer: [28]u8 = undefined; + // var buffer: jsc.JSValue.ISOStringBuffer = undefined; // the code bellow is the same as new Date(Date.now()).toISOString() // jsc.JSValue.getDateNowISOString(globalObject, &buffer); diff --git a/src/sql/postgres/PostgresRequest.zig b/src/sql/postgres/PostgresRequest.zig index 9eb5085d045..6dbc06527b2 100644 --- a/src/sql/postgres/PostgresRequest.zig +++ b/src/sql/postgres/PostgresRequest.zig @@ -151,14 +151,34 @@ pub fn writeBind( }, else => { - const str = try String.fromJS(value, globalObject); - if (str.tag == .Dead) return error.OutOfMemory; - defer str.deref(); - const slice = str.toUTF8WithoutRef(bun.default_allocator); - defer slice.deinit(); - const l = try writer.length(); - try writer.write(slice.slice()); - try l.writeExcludingSelf(); + // JS `Date` objects reach this branch whenever the server + // hasn't told us the parameter type is `.timestamp` / + // `.timestamptz` — e.g. under `prepare: false`, or on the + // first execution of a fresh prepared statement before + // Describe returns. `bun.String.fromJS(date)` would emit + // `Date.prototype.toString()` output (a locale-dependent + // string like "Mon Jan 15 2024 12:30:45 GMT+0000 ..."), + // which PostgreSQL rejects. Serialize as ISO 8601 instead. + // On failure propagate a bind error rather than falling + // back to the legacy text path — the fallback would just + // re-emit the same broken locale string. + if (value.isDate()) { + var iso_buf: JSValue.ISOStringBuffer = undefined; + const iso = value.toISOString(globalObject, &iso_buf); + if (iso.len == 0) return error.InvalidQueryBinding; + const l = try writer.length(); + try writer.write(iso); + try l.writeExcludingSelf(); + } else { + const str = try String.fromJS(value, globalObject); + if (str.tag == .Dead) return error.OutOfMemory; + defer str.deref(); + const slice = str.toUTF8WithoutRef(bun.default_allocator); + defer slice.deinit(); + const l = try writer.length(); + try writer.write(slice.slice()); + try l.writeExcludingSelf(); + } }, } } diff --git a/src/sql/postgres/PostgresSQLConnection.zig b/src/sql/postgres/PostgresSQLConnection.zig index c234236a1ea..a8a80d7223e 100644 --- a/src/sql/postgres/PostgresSQLConnection.zig +++ b/src/sql/postgres/PostgresSQLConnection.zig @@ -1017,6 +1017,35 @@ pub const Writer = struct { } }; +/// Snapshot of `write_buffer` length for rollback on a failed write. +/// +/// `PostgresRequest.*` helpers append directly to `write_buffer` and can +/// fail mid-message — e.g. `writeBind` returns `error.InvalidQueryBinding` +/// for `new Date(NaN)` *after* emitting the 'B' tag, a zeroed length +/// placeholder, names, format codes and earlier parameter values. If that +/// partial frame is left in the buffer, the next query's bytes are appended +/// after it and `flushData()` ships a protocol stream PostgreSQL rejects +/// with "invalid message length", killing the connection. +/// +/// Capture with `.take(connection)` immediately before a `PostgresRequest` +/// call and `.restore(connection)` at the top of its `catch |err|` block so +/// the connection remains usable after a bind error the user catches. +/// `head` is not captured — it only advances in `flushData()`, which is not +/// called between the snapshot and the catch. +pub const WriteBufferSnapshot = struct { + len: u32, + + pub fn take(connection: *const PostgresSQLConnection) WriteBufferSnapshot { + return .{ .len = connection.write_buffer.byte_list.len }; + } + + pub fn restore(self: WriteBufferSnapshot, connection: *PostgresSQLConnection) void { + bun.debugAssert(self.len <= connection.write_buffer.byte_list.len); + bun.debugAssert(connection.write_buffer.head <= self.len); + connection.write_buffer.byte_list.len = self.len; + } +}; + pub fn writer(this: *PostgresSQLConnection) protocol.NewWriter(Writer) { return .{ .wrapped = .{ @@ -1139,7 +1168,9 @@ fn advance(this: *PostgresSQLConnection) void { var query_str = req.query.toUTF8(bun.default_allocator); defer query_str.deinit(); debug("execute simple query: {s}", .{query_str.slice()}); + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.executeQuery(query_str.slice(), PostgresSQLConnection.Writer, this.writer()) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1202,7 +1233,9 @@ fn advance(this: *PostgresSQLConnection) void { debug("parse, bind and execute unnamed stmt", .{}); var query_str = req.query.toUTF8(bun.default_allocator); defer query_str.deinit(); + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.parseAndBindAndExecute(this.globalObject, query_str.slice(), statement, binding_value, columns_value, false, PostgresSQLConnection.Writer, this.writer()) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1221,7 +1254,9 @@ fn advance(this: *PostgresSQLConnection) void { }; } else { debug("binding and executing stmt", .{}); + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.bindAndExecute(this.globalObject, statement, binding_value, columns_value, PostgresSQLConnection.Writer, this.writer()) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1280,7 +1315,9 @@ fn advance(this: *PostgresSQLConnection) void { // prepareAndQueryWithSignature will write + bind + execute, it will change to running after binding is complete const binding_value = PostgresSQLQuery.js.bindingGetCached(thisValue) orelse .zero; debug("prepareAndQueryWithSignature", .{}); + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.prepareAndQueryWithSignature(this.globalObject, query_str.slice(), binding_value, PostgresSQLConnection.Writer, this.writer(), &statement.signature) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1323,7 +1360,9 @@ fn advance(this: *PostgresSQLConnection) void { const binding_value = PostgresSQLQuery.js.bindingGetCached(thisValue) orelse .zero; const columns_value = PostgresSQLQuery.js.columnsGetCached(thisValue) orelse .zero; debug("parseAndBindAndExecute (unnamed, first execution)", .{}); + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.parseAndBindAndExecute(this.globalObject, query_str.slice(), statement, binding_value, columns_value, true, PostgresSQLConnection.Writer, this.writer()) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1353,7 +1392,9 @@ fn advance(this: *PostgresSQLConnection) void { const connection_writer = this.writer(); debug("writing query", .{}); // write query and wait for it to be prepared + const write_start = WriteBufferSnapshot.take(this); PostgresRequest.writeQuery(query_str.slice(), statement.signature.prepared_statement_name, statement.signature.fields, PostgresSQLConnection.Writer, connection_writer) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { @@ -1368,6 +1409,7 @@ fn advance(this: *PostgresSQLConnection) void { continue; }; connection_writer.write(&protocol.Sync) catch |err| { + write_start.restore(this); if (this.globalObject.tryTakeException()) |err_| { req.onJSError(err_, this.globalObject); } else { diff --git a/src/sql/postgres/PostgresSQLQuery.zig b/src/sql/postgres/PostgresSQLQuery.zig index 535f7f16e83..24c051ffdea 100644 --- a/src/sql/postgres/PostgresSQLQuery.zig +++ b/src/sql/postgres/PostgresSQLQuery.zig @@ -296,7 +296,9 @@ pub fn doRun(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callfra const can_execute = !connection.hasQueryRunning(); if (can_execute) { + const write_start = PostgresSQLConnection.WriteBufferSnapshot.take(connection); PostgresRequest.executeQuery(query_str.slice(), PostgresSQLConnection.Writer, writer) catch |err| { + write_start.restore(connection); // fail to run do cleanup this.statement = null; bun.default_allocator.destroy(stmt); @@ -371,7 +373,9 @@ pub fn doRun(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callfra debug("bindAndExecute", .{}); // bindAndExecute will bind + execute, it will change to running after binding is complete + const write_start = PostgresSQLConnection.WriteBufferSnapshot.take(connection); PostgresRequest.bindAndExecute(globalObject, this.statement.?, binding_value, columns_value, PostgresSQLConnection.Writer, writer) catch |err| { + write_start.restore(connection); // fail to run do cleanup this.statement = null; stmt.deref(); @@ -402,7 +406,9 @@ pub fn doRun(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callfra if (!has_params) { debug("prepareAndQueryWithSignature", .{}); // prepareAndQueryWithSignature will write + bind + execute, it will change to running after binding is complete + const write_start = PostgresSQLConnection.WriteBufferSnapshot.take(connection); PostgresRequest.prepareAndQueryWithSignature(globalObject, query_str.slice(), binding_value, PostgresSQLConnection.Writer, writer, &signature) catch |err| { + write_start.restore(connection); if (connection_entry_value != null) { _ = connection.statements.remove(signature_hash); } @@ -425,7 +431,9 @@ pub fn doRun(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callfra // for ParameterDescription before sending Bind+Execute in advance(). debug("writeQuery", .{}); + const write_start = PostgresSQLConnection.WriteBufferSnapshot.take(connection); PostgresRequest.writeQuery(query_str.slice(), signature.prepared_statement_name, signature.fields, PostgresSQLConnection.Writer, writer) catch |err| { + write_start.restore(connection); if (connection_entry_value != null) { _ = connection.statements.remove(signature_hash); } @@ -440,6 +448,7 @@ pub fn doRun(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callfra return error.JSError; }; writer.write(&protocol.Sync) catch |err| { + write_start.restore(connection); if (connection_entry_value != null) { _ = connection.statements.remove(signature_hash); } diff --git a/src/sql/postgres/types/date.zig b/src/sql/postgres/types/date.zig index 8a5ec361442..bdfe5c76f75 100644 --- a/src/sql/postgres/types/date.zig +++ b/src/sql/postgres/types/date.zig @@ -11,7 +11,7 @@ pub fn fromBinary(bytes: []const u8) f64 { return (double_microseconds / std.time.us_per_ms) + POSTGRES_EPOCH_DATE; } -pub fn fromJS(globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!i64 { +pub fn fromJS(globalObject: *jsc.JSGlobalObject, value: JSValue) AnyPostgresError!i64 { const double_value = if (value.isDate()) value.getUnixTimestamp() else if (value.isNumber()) @@ -22,6 +22,14 @@ pub fn fromJS(globalObject: *jsc.JSGlobalObject, value: JSValue) bun.JSError!i64 break :brk try str.parseDate(globalObject); } else return 0; + // `@intFromFloat` on a non-finite value is Illegal Behavior. Invalid + // `Date` objects (e.g. `new Date("bad")` / `new Date(NaN)`) are real + // `DateInstance`s whose internal value is NaN, so `getUnixTimestamp()` + // — and likewise `parseDate` on a bad string or `asNumber` on `NaN` — + // can return NaN / ±Infinity here. The text path (`toISOString`) already + // rejects these via `std::isfinite`; mirror that for the binary path. + if (!std.math.isFinite(double_value)) return error.InvalidQueryBinding; + const unix_timestamp: i64 = @intFromFloat(double_value); return (unix_timestamp - POSTGRES_EPOCH_DATE) * std.time.us_per_ms; } @@ -46,6 +54,7 @@ pub fn toJS( const bun = @import("bun"); const std = @import("std"); +const AnyPostgresError = @import("../AnyPostgresError.zig").AnyPostgresError; const Data = @import("../../shared/Data.zig").Data; const int_types = @import("./int_types.zig"); diff --git a/test/regression/issue/29010.test.ts b/test/regression/issue/29010.test.ts new file mode 100644 index 00000000000..346f39f6ad4 --- /dev/null +++ b/test/regression/issue/29010.test.ts @@ -0,0 +1,212 @@ +// Regression test for https://github.com/oven-sh/bun/issues/29010 +// +// Bun.SQL must serialize JavaScript `Date` parameters as ISO 8601 / RFC 3339 +// (`Date.prototype.toISOString()`), not the locale-dependent output of +// `Date.prototype.toString()`. PostgreSQL-compatible databases reject the +// latter (`"Mon Jan 15 2024 12:30:45 GMT+0000 (Coordinated Universal Time)"`) +// with an "invalid input syntax for type timestamp" error. +// +// The bug was specific to text-format serialization: with `prepare: false` +// (and more generally whenever the parameter type tag is 0 / server-decided), +// `writeBind` fell through to `bun.String.fromJS(value)`, which returns the +// JS `toString()` representation. The binary-format path for +// `.timestamp` / `.timestamptz` was already correct because it goes through +// `types.date.fromJS` → `getUnixTimestamp()`. + +import { SQL } from "bun"; +import { describe, expect, test } from "bun:test"; +import * as dockerCompose from "../../docker/index.ts"; + +// Resolve a reachable PostgreSQL instance. Prefer the docker-compose +// `postgres_plain` service (what CI uses); fall back to a local +// PostgreSQL listening on 127.0.0.1:5432 with the same credentials +// as the init script (`bun_sql_test` / `bun_sql_test`). +// +// This test is a *consumer* of the shared `bun-test-services` compose +// project — it must never call `dockerCompose.down()`, because that +// would tear down every service in the project and break other suites +// running concurrently against postgres_tls / mysql_* / redis_* / etc. +async function resolvePostgres(): Promise<{ host: string; port: number } | null> { + try { + const info = await dockerCompose.ensure("postgres_plain"); + return { host: info.host, port: info.ports[5432] }; + } catch {} + + try { + await using probe = new SQL({ + host: "127.0.0.1", + port: 5432, + username: "bun_sql_test", + db: "bun_sql_test", + max: 1, + idleTimeout: 1, + connectionTimeout: 2, + }); + await probe`SELECT 1`; + return { host: "127.0.0.1", port: 5432 }; + } catch {} + + return null; +} + +describe("issue #29010 — Date parameters serialize as ISO 8601", async () => { + const target = await resolvePostgres(); + if (!target) { + test.skip("PostgreSQL not available", () => {}); + return; + } + + const baseOptions = { + db: "bun_sql_test", + username: "bun_sql_test", + host: target.host, + port: target.port, + max: 1, + }; + + // With `prepare: false` (unnamed prepared statements), parameter types + // are not learned from Describe responses, so the Bind message sends + // parameters in text format with the server-decided type. This is where + // the bug lived: the fallthrough branch called `String.fromJS` on a + // `Date`, which produces the locale string rather than ISO 8601. + describe("prepare: false (text format, server-decided type)", () => { + const options = { ...baseOptions, prepare: false }; + const t = new Date("2024-01-15T12:30:45.000Z"); + + test("Date in SELECT parameter does not produce a parse error", async () => { + await using db = new SQL(options); + // Casting to ::timestamptz forces the server to parse the parameter + // as a timestamp. The old locale-string serialization fails this + // parse with an "invalid input syntax for type timestamp" error. + const [{ x }] = await db`SELECT ${t}::timestamptz AS x`; + expect(x).toEqual(t); + }); + + test("Date in INSERT via sql(rows) does not produce a parse error", async () => { + await using db = new SQL(options); + const table = `issue_29010_rows_${Date.now()}`; + try { + await db`CREATE TABLE ${db(table)} (id SERIAL PRIMARY KEY, created_at TIMESTAMPTZ)`; + await db`INSERT INTO ${db(table)} ${db([{ created_at: t }])}`; + const rows = await db`SELECT created_at FROM ${db(table)}`; + expect(rows).toEqual([{ created_at: t }]); + } finally { + await db`DROP TABLE IF EXISTS ${db(table)}`; + } + }); + + test("Date in INSERT as a plain parameter does not produce a parse error", async () => { + await using db = new SQL(options); + const table = `issue_29010_param_${Date.now()}`; + try { + await db`CREATE TABLE ${db(table)} (id SERIAL PRIMARY KEY, created_at TIMESTAMPTZ)`; + await db`INSERT INTO ${db(table)} (created_at) VALUES (${t})`; + const rows = await db`SELECT created_at FROM ${db(table)}`; + expect(rows).toEqual([{ created_at: t }]); + } finally { + await db`DROP TABLE IF EXISTS ${db(table)}`; + } + }); + + test("Date with a timezone offset also round-trips as UTC", async () => { + // A Date constructed from a non-UTC ISO string is stored as a UTC + // instant. The serializer must emit the UTC instant with a trailing + // `Z`, not the local-time string that `toString()` would emit. + await using db = new SQL(options); + const localDate = new Date("2024-07-04T16:00:00.000-04:00"); // 20:00:00Z + const [{ x }] = await db`SELECT ${localDate}::timestamptz AS x`; + expect(x).toEqual(localDate); + }); + }); + + // Sanity check: the default (prepared) path was already correct, make + // sure we didn't regress it. On the *first* execution of a prepared + // statement, `statement.parameters` is still empty (the server hasn't + // sent a ParameterDescription yet) so `writeBind` uses the ISO text + // path added in this change. On the *second* execution the cached OID + // (`timestamptz` = 1184) is present and `writeBind` takes the binary + // `types.date.fromJS` path. Exercise both. + describe("prepare: true", () => { + const options = { ...baseOptions, prepare: true }; + const t = new Date("2024-01-15T12:30:45.000Z"); + + test("Date round-trips on first and subsequent executions", async () => { + await using db = new SQL(options); + // First execution: OID 0 (server-decided) → text-format ISO 8601. + const [{ x: first }] = await db`SELECT ${t}::timestamptz AS x`; + expect(first).toEqual(t); + // Second execution of the same prepared statement: OID 1184 → + // binary microseconds-since-2000 via `types.date.fromJS`. + const [{ x: second }] = await db`SELECT ${t}::timestamptz AS x`; + expect(second).toEqual(t); + }); + }); + + // Invalid `Date` objects (`new Date(NaN)`, `new Date("bad")`) are real + // `DateInstance`s whose internal value is NaN. Both serialization paths + // must reject them cleanly rather than crashing or sending garbage: + // - text path: `toISOString()` returns "" for non-finite dates → + // `error.InvalidQueryBinding` in the new `writeBind` `else` branch. + // - binary path: `types.date.fromJS` previously did an unguarded + // `@intFromFloat(NaN)` (Illegal Behavior — panic in safe builds, + // silent UB in release). Now guarded with `std.math.isFinite`. + describe("invalid Date (NaN internal value)", () => { + const invalid = new Date("this is not a date"); + + test("prepare: false rejects with a bind error, not a server parse error", async () => { + await using db = new SQL({ ...baseOptions, prepare: false }); + expect(invalid.getTime()).toBeNaN(); + // `.execute()` returns a real Promise; the bare tagged-template + // query is a lazy thenable that `expect().rejects` won't drive. + await expect(db`SELECT ${invalid}::timestamptz AS x`.execute()).rejects.toThrow( + expect.objectContaining({ code: "ERR_POSTGRES_INVALID_QUERY_BINDING" }), + ); + }); + + test("prepare: true binary path rejects without crashing", async () => { + await using db = new SQL({ ...baseOptions, prepare: true }); + // Prime the statement so `statement.parameters` is populated with + // OID 1184 and the second execution takes the binary path. + const good = new Date("2024-01-15T12:30:45.000Z"); + const [{ x }] = await db`SELECT ${good}::timestamptz AS x`; + expect(x).toEqual(good); + // Second execution with an invalid Date reaches `types.date.fromJS`, + // which must reject the non-finite timestamp rather than hitting + // `@intFromFloat(NaN)`. + await expect(db`SELECT ${invalid}::timestamptz AS x`.execute()).rejects.toThrow( + expect.objectContaining({ code: "ERR_POSTGRES_INVALID_QUERY_BINDING" }), + ); + }); + + // The `error.InvalidQueryBinding` return above fires *mid* `writeBind` + // — after the 'B' tag, a zeroed length placeholder, names, format codes + // and any earlier parameter values are already in `write_buffer`. The + // catch blocks in `advance()` / `doRun` must roll the buffer back so + // catching the rejection and issuing another query on the same + // connection doesn't ship a truncated Bind frame that desyncs the + // protocol and kills the connection. + test("connection survives a caught bind error (prepare: false)", async () => { + await using db = new SQL({ ...baseOptions, prepare: false }); + await expect(db`SELECT ${invalid}::timestamptz AS x`.execute()).rejects.toThrow( + expect.objectContaining({ code: "ERR_POSTGRES_INVALID_QUERY_BINDING" }), + ); + // Without rollback the partial Parse + Describe + 'B'… bytes are still + // in `write_buffer`, so this SELECT's frames are appended after them + // and the server drops the connection with "invalid message length". + const [{ y }] = await db`SELECT 1 AS y`; + expect(y).toBe(1); + }); + + test("connection survives a caught bind error (prepare: true, binary path)", async () => { + await using db = new SQL({ ...baseOptions, prepare: true }); + const good = new Date("2024-01-15T12:30:45.000Z"); + // Prime so the next bind takes the binary path in `bindAndExecute`. + await db`SELECT ${good}::timestamptz AS x`; + await expect(db`SELECT ${invalid}::timestamptz AS x`.execute()).rejects.toThrow( + expect.objectContaining({ code: "ERR_POSTGRES_INVALID_QUERY_BINDING" }), + ); + const [{ y }] = await db`SELECT 1 AS y`; + expect(y).toBe(1); + }); + }); +});