Skip to content

sql(postgres): bind a Date to a date or other text-format parameter as ISO-8601 - #41955

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/f92bb688/pg-date-param-iso
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/f92bb688/pg-date-param-iso

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With the default prepare: true, a JS Date bound to a date parameter fails with PostgresError 22007 invalid input syntax for type date: "Mon May 06 2024 07:08:09 GMT+0000 (Coordinated Universal Time)".
  • Cause: the text-format arm of write_bind (src/sql_jsc/postgres/PostgresRequest.rs:218). Bun declares OID 0 for a Date, the server answers date (1082), which has no binary encoder, and the arm serializes with Date.prototype.toString().

Fix

  • The text-format arm sends a Date as its toISOString() output. Other values (and an invalid Date) keep toString().
  • Correct because the server parses a text parameter as the target type, and ISO-8601 is valid date, timestamp, timestamptz and text input. postgres.js and pg send the same form.
  • The arm also serves text parameters, domains, and all prepare: false parameters, so a Date bound to text now stores the ISO string. This re-lands sql: serialize Date parameters as ISO 8601 in Postgres text format #29013 and supersedes the Date branch of postgres: encode Date and object parameters correctly with prepare: false #39452.
  • Verified: test/js/sql/postgres-date-param-text.test.ts (5 of 7 fail on 1.4.3), plus the other Postgres date tests and sql.test.ts. Self-reviewed: 6 concerns raised, all packaging and merge order, addressed in Notes.

Background

  • Parse declares parameter OIDs (0: the server infers). Bind sends each value as text or binary. Under prepare: true Bun binds with the described types: binary for those in Tag::is_binary_format_supported, text for the rest.
  • A date column decodes to a Date at UTC midnight. On date input the server keeps the calendar date as written, so the UTC ISO string round-trips in any time zone.

Fixes #29010. Refs #39450.

Notes

[human-review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-date-param-text.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/162] gen cpp.rs (cppbind)
[2/162] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/162] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin/clang++ --env=CARGO_HOME=/root/.cargo --env=RUSTUP_HOME=/root/.rustup --env=RUSTUP_TOOLCHAIN=nightly-2026-07-20 --env=CARGO_PROFILE_RELEASE_LTO=off --env=CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 --env=CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true --env=CARGO_ENCODED_RUSTFLAGS='-Creloca
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (0d1b0e1ab)

test/js/sql/postgres-date-param-text.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > Date bound to a date parameter [53.80ms]
(pass) postgres > Date inserted into a date column through every parameter path [125.88ms]
(pass) postgres > the calendar date is taken in UTC, whatever the session time zone [10.85ms]
(pass) postgres > Date bound to a text parameter is its ISO string [4.80ms]
(pass) postgres > an invalid Date is left for the server to reject [7.32ms]
(pass) postgres > timestamptz and timestamp parameters are unaffected [4.56ms]
(pass) postgres > prepare: false binds a Date the same way [4.40ms]

 7 pass
 0 fail
 11 expect() calls
Ran 7 tests across 1 file. [996.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-date-param-text.test.ts
bun test v1.4.3 (f42e98025)

test/js/sql/postgres-date-param-text.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > Date bound to a date parameter [234.34ms]
(pass) postgres > Date inserted into a date column through every parameter path [81.74ms]
(pass) postgres > the calendar date is taken in UTC, whatever the session time zone [47.15ms]
(pass) postgres > Date bound to a text parameter is its ISO string [24.13ms]
(pass) postgres > an invalid Date is left for the server to reject [31.26ms]
(pass) postgres > timestamptz and timestamp parameters are unaffected [23.23ms]
(pass) postgres > prepare: false binds a Date the same way [21.67ms]

 7 pass
 0 fail
 11 expect() calls
Ran 7 tests across 1 file. [3.07s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 614ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/123] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/123] gen cpp.rs (cppbind)
[2/123] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/
... (truncated)
diff hotspot
src/sql_jsc/postgres/PostgresRequest.rs      | 23 +++++--
 test/js/sql/postgres-date-param-text.test.ts | 93 ++++++++++++++++++++++++++++
 2 files changed, 110 insertions(+), 6 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                          reads  edits  tests
src/sql_jsc/postgres/PostgresRequest.rs           5     10      4
test/js/sql/postgres-date-param-text.test.ts      1      2      4

write_bind sends a parameter in text format when the server-reported
type has no binary encoder (date, text, domains, OID 0 with prepare:
false). A JS Date on that path went through BunString::from_js, that is
Date.prototype.toString(), which Postgres rejects for a date parameter
with 22007 and stores verbatim for a text one. Send toISOString() output
instead, as postgres.js and pg do. timestamp and timestamptz parameters
are bound in binary and are unchanged.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 621b7b2c-de97-486f-9495-19e156201abb

📥 Commits

Reviewing files that changed from the base of the PR and between afd7883 and 84dbb34.

📒 Files selected for processing (2)
  • src/sql_jsc/postgres/PostgresRequest.rs
  • test/js/sql/postgres-date-param-text.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

The Postgres text-format binder now serializes valid JavaScript Date values with toISOString(). Tests cover date, text, timestamp, and timestamptz parameters across prepared, unprepared, and multiple parameter paths.

Postgres Date binding

Layer / File(s) Summary
Date text serialization
src/sql_jsc/postgres/PostgresRequest.rs
Valid Date values use ISO-8601 text. Non-date and invalid-date values retain the previous fallback behavior.
Date binding validation
test/js/sql/postgres-date-param-text.test.ts
Tests cover UTC date binding, text serialization, invalid dates, round-tripping, statement preparation, session time zones, and tagged, object, and positional parameters.

Suggested reviewers: dylan-conway, jarred-sumner

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 84dbb

PostgreSQL Date parameters now use stable ISO text serialization, fixing locale-dependent date parsing while preserving invalid-date rejection and existing timestamp behavior. The covered binding paths are ready to merge.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation satisfies issue [#29010] by serializing Date parameters with toISOString() on the prepare:false text-binding path, including template parameters and row-object inserts. The added te…
Out of Scope Changes check ✅ Passed The changes remain related to PostgreSQL Date parameter serialization. The additional prepare:true, text, domain, timezone, invalid-date, and regression coverage support the same binding behavior and …
Title check ✅ Passed The title clearly identifies the main change: PostgreSQL Date binding now uses ISO-8601 serialization for date and other text-format parameters.
Description check ✅ Passed The description explains the problem, cause, fix, scope, linked issues, edge cases, and verification results. It does not use the template headings exactly, but it provides the required change summary…

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

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Bun 1.4.3 against PostgreSQL 17 with the default prepare: true:

import { SQL } from "bun";
const sql = new SQL({ url: "postgres://bun_sql_test@127.0.0.1:5432/bun_sql_test", max: 1 });
await sql`create temp table w3 (d date)`;
await sql`insert into w3 (d) values (${new Date("2024-05-06T07:08:09.123Z")})`;
// PostgresError 22007: invalid input syntax for type date: "Mon May 06 2024 07:08:09 GMT+0000 (Coordinated Universal Time)"

test/js/sql/postgres-date-param-text.test.ts fails 5 of 7 on 1.4.3 and passes with this branch on every CI lane.

The diff is green. The red lanes in builds #112805 and #112842 are unrelated to this change: test-crypto-dh-leak.js (an RSS threshold on the ASAN lane), bun install tests that got 504 from api.github.com for GitHub tarball dependencies, and a macOS x64 agent whose test runner exited with uv_os_get_passwd returned ENOENT after 11 files. Ready for review.

@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 — focused fix, well-covered.

Checked the lifetime of the borrowed slice in the text arm: iso_buf, str, and utf8 are all hoisted above the if, so both branches' backing storage outlives the writer.write(slice) call (borrow-checker enforced, no unsafe). to_iso_string returning None for NaN time falls through to the existing BunString::from_js path, so no partial Bind is written mid-error. The new test file follows the per-topic postgres-*.test.ts convention already established in test/js/sql/, uses the container harness, and pins the exact 22007 message plus a timestamptz/timestamp regression guard for the untouched binary path.

Extended reasoning...

Overview

The PR touches two files: a ~15-line change to the text-format catch-all arm of write_bind in src/sql_jsc/postgres/PostgresRequest.rs, and a new 93-line test file. The Rust change detects a JS Date via value.is_date() and writes toISOString() output into a 64-byte stack buffer instead of falling through to BunString::from_js (which yields Date.prototype.toString() — rejected by Postgres for date/domain columns with SQLSTATE 22007). Non-Date values and invalid Dates keep the existing path unchanged.

Security risks

None. No untrusted input parsing is added; the change only alters which string representation of a client-side Date is written to the outgoing Bind message. No unsafe blocks, no allocation, no FFI surface change — to_iso_string is an existing helper that writes into a caller-owned stack buffer and returns a lifetime-tied slice.

Level of scrutiny

Low-to-moderate. The diff is small, mechanical, and in safe Rust where the borrow checker enforces the deferred-init lifetime pattern (iso_buf/str/utf8 declared before the branching if let, so the resulting &[u8] cannot dangle). The one user-visible behavior change — a Date bound to ::text now yields the ISO string rather than the locale toString() form — is intentional, matches postgres.js and pg, and is pinned by a test. The PR description explicitly reasons through invalid dates (no mid-write early return, avoiding the partial-buffer hazard until #34732 lands), domains, prepare: false, and the untouched binary timestamp/timestamptz path.

Other factors

Test coverage is strong for the size of the change: prepared and cached-statement runs, all three insert parameter paths (positional, sql({..}), unsafe), a non-UTC session time zone round-trip, ::text binding, invalid-Date rejection with exact errno and message, a regression guard on the binary timestamp path, and prepare: false. The new file sits alongside ~40 sibling single-topic postgres-*.test.ts files, so placement matches local convention rather than violating the "add to existing file" default. No CODEOWNERS entry covers src/sql_jsc/ or test/js/sql/. Exit reason was dry_streak with no findings and no outstanding third-party reviews on the timeline.

Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:45 AM PT - Sep 8th, 2026

❌ @robobun, your commit 9899625 has 4 failures in Build #112842 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41955

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

bun-41955 --bun

@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 — focused fix with the variant matrix covered.

Checked the iso_buf/str/utf8 deferred-init borrows in the text-format arm — all outlive slice through writer.write. to_iso_string returning None on NaN correctly falls through to toString(), which the invalid-Date test pins to a server-side 22007. Tests use the container harness (hermetic), exercise both prepared-statement passes plus prepare: false, and confirm the timestamptz/timestamp binary paths are untouched. New test file follows the existing test/js/sql/postgres-*.test.ts split.

Extended reasoning...

Overview

The PR changes the fallback text-format arm of write_bind in src/sql_jsc/postgres/PostgresRequest.rs (lines 218-238) so that a JS Date bound to a Postgres parameter type without a binary encoder (notably date, but also text, domains, and everything under prepare: false) is serialized as ISO-8601 via the existing JSValue::to_iso_string helper instead of Date.prototype.toString(). The change is ~15 lines of Rust: a 64-byte stack buffer, an if let-chain that tries to_iso_string for valid Dates, and the original BunString::from_js path for everything else (including new Date(NaN), since to_iso_string returns None for a NaN time value). A new 93-line test file exercises seven scenarios against the containerized Postgres harness.

Security risks

None identified. This is output serialization of a parameter value inside the wire-protocol Bind message; the value is still length-prefixed via writer.length()/write_excluding_self() exactly as before, so there is no change to framing or any injection surface. The ISO string is produced by JSC's own date formatter into a fixed stack buffer — no user-controlled length drives an allocation. No auth, crypto, or permission code is touched.

Level of scrutiny

Low-to-moderate. The Rust change is small, uses an existing in-tree helper (to_iso_string), and preserves the original code path verbatim for the non-Date case. The deferred-initialization pattern (let str; let utf8;) keeps the BunString and its to_utf8() borrow alive for the whole block, satisfying the src/CLAUDE.md rule that a to_utf8() result borrows the String. The iso_buf stack array likewise outlives the slice borrow through the single writer.write(slice) call. I checked JSValue::to_iso_string at src/jsc/JSValue.rs:1044 — it returns None for non-Date or NaN, so the fallback ordering is sound and is_date() before it is a cheap guard rather than a correctness requirement.

Other factors

Test coverage is strong for a fix of this size: prepared-statement first and cached passes, all three insert parameter paths (literal, object helper, sql.unsafe), UTC calendar-date semantics under a +14 session time zone with a round-trip, ::text yielding the ISO string, invalid Date pinned to the server's 22007 with the exact message, timestamptz/timestamp binary paths asserted unchanged, and prepare: false. Tests use describeWithContainer (hermetic, no public network) and await using for cleanup. The new file follows the established test/js/sql/postgres-*.test.ts convention (dozens of sibling files already exist), so it does not violate the "add to existing file" default. No CODEOWNERS entry covers src/sql_jsc/ or test/js/sql/. The PR's embedded CI evidence shows 5/7 tests failing on the base release build and all 7 passing on the PR's ASAN and release builds. No outstanding human CHANGES_REQUESTED reviews; the resolved inline threads were from bots and the author. Exit reason was dry_streak.

robobun added a commit that referenced this pull request Sep 8, 2026
…ind bytes with mock-server tests

FieldDescription::type_tag() truncated a result column's OID to 16 bits
for the Bind result format code, while DataCell mapped an OID above
65535 to text. A user-defined type whose OID aliases a binary-decoded
builtin modulo 65536 was requested in binary and read as text.
Tag::from_oid() is now the one mapping, for parameters, result format
codes and DataCell.

wire-frames.ts gains pgNoData() and pgDecodeBind(). The new mock-server
tests in postgres-bind-parameter-format.test.ts check the exact format
codes and value bytes of the Bind message without a database. The
container tests from #41912 and the Date test file from #41955 are
carried over.
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #41976 includes this change (a Date that is bound in text format goes out as toISOString()) on top of the Bind format-code fix, and carries over test/js/sql/postgres-date-param-text.test.ts unchanged. If #41976 merges this one can close.

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #41976. With its text fallback a Date reaches the text arm for date, text and domain parameters and for every parameter under prepare: false, and that arm sends toISOString(), the same change as here (an Invalid Date still goes as "Invalid Date" and the server answers 22007). test/js/sql/postgres-date-param-text.test.ts was carried over unchanged and passes there. #41976 carries Fixes #29010.

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 serializes Date parameters via .toString() instead of .toISOString() for prepare:false

1 participant