Skip to content

sql(postgres): pin DateStyle=ISO in StartupMessage so server defaults cannot corrupt dates - #35112

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/09b2dd8f/postgres-datestyle
Jul 22, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/09b2dd8f/postgres-datestyle

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Bun.SQL decodes text-format date/timestamp/timestamptz values via JS Date.parse, which only handles ISO input unambiguously. Postgres emits those values in whatever DateStyle the session has, and a server, database, or role can default to a non-ISO style such as 'SQL, DMY' (a common EU configuration). Bun never set DateStyle on connect, so the server-side default leaked through and dates were silently corrupted:

// against a database with: ALTER DATABASE ... SET datestyle = 'SQL, DMY'
await sql`select '2026-04-03'::date as d, '2026-07-22'::date as d2`.simple();
// [{ d: 2026-03-04T00:00:00.000Z, d2: null }]
//   3 April read as 4 March; 22 July -> null (day > 12)

The extended protocol on the same connection is equally affected because date uses text format there too. This is silent data corruption: no error is raised, the values are just wrong.

Fix

Send DateStyle = ISO, MDY in the StartupMessage alongside the existing client_encoding = UTF8. A startup-packet parameter has GUC source PGC_S_CLIENT, which outranks postgresql.conf, ALTER DATABASE, and ALTER ROLE defaults, so the server always emits the ISO form the decoder expects regardless of how the cluster is configured. This matches what node-postgres and postgres.js do.

The text-decode comment in DataCell.rs is updated to document the invariant the startup packet now establishes.

Verification

  • test/js/sql/postgres-datestyle.test.ts
    • mock-server test: asserts the StartupMessage carries DateStyle = ISO (fails on main: the parameter is absent).
    • real-server test: ALTER DATABASE bun_sql_test SET datestyle = 'SQL, DMY', reconnect, read '2026-04-03'::date and '2026-07-22'::date on both the simple and extended paths. On main the session reports SQL, DMY and the dates come back wrong/null; with the fix the session reports ISO, MDY and both dates round-trip correctly.
  • Existing test/js/sql/sql-postgres-datetime-roundtrip.test.ts, wire-frames.test.ts, postgres-* protocol tests all pass.

Not in this PR

'infinity'::date / '-infinity'::date still decode to Invalid Date on both the text and binary paths (JSC's DateInstance constructor applies timeClip, which maps non-finite values to NaN). That is a separate design question (node-postgres returns the Number Infinity, not a Date) and is unchanged here.


[review] gate passed · iteration 3 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-datestyle.test.ts
bun test v1.4.0 (4118b7e4e)

test/js/sql/postgres-datestyle.test.ts:
34 |     const params: Record<string, string> = {};
35 |     const parts = body.split("\0");
36 |     for (let i = 0; i + 1 < parts.length && parts[i] !== ""; i += 2) params[parts[i]] = parts[i + 1];
37 |     // client_encoding was already pinned; DateStyle must be too.
38 |     expect(params.client_encoding).toBe("UTF8");
39 |     expect(params.DateStyle).toMatch(/^ISO\b/);
                                  ^
error: Received value must be a string: undefined
      at <anonymous> (/workspace/bun/test/js/sql/postgres-datestyle.test.ts:39:30)
(fail) StartupMessage pins DateStyle=ISO so server-side datestyle defaults cannot corrupt dates [640.05ms]
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
62 |         sql`select current_setting('datestyle') as ds`.simple(),
63 |         sql`select '2026-04-03'::date as d, '2026-07-22'::date as d2`.simple(),
64 |         sql`select '2026-04-03'::date as d`,
65 |       ]);

... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (4118b7e4e)

test/js/sql/postgres-datestyle.test.ts:
(pass) StartupMessage pins DateStyle=ISO so server-side datestyle defaults cannot corrupt dates [9.28ms]
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > database-level non-ISO DateStyle default does not corrupt date values [13.26ms]

 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 1 file. [169.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/mechgate.xml" test/js/sql/postgres-datestyle.test.ts
bun test v1.4.0 (4118b7e4e)

test/js/sql/postgres-datestyle.test.ts:
(pass) StartupMessage pins DateStyle=ISO so server-side datestyle defaults cannot corrupt dates [634.08ms]
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > database-level non-ISO DateStyle default does not corrupt date values [175.48ms]

 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 1 file. [3.28s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 635ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_windows_sys v0.0.0 (/workspace/bun/src/windows_sys)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m bun_sys v0.0.0 (/workspace/bun/src/sys)
�[1m�[92m   Compiling�[0m bun_url v0.0.0 (/workspace/bun/src/url)
�[1m�[92m   Compiling�[0m bun_http_types v0.0.0 (/workspace/bun/src/http_types)
�[1m�[92m   Compiling�[0m bun_perf v0.0.0 (/workspace/bun/src/perf)
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src/threading)
�[1m�[92m   Compiling�[0m bun_analytics v0.0.0 (/workspace/bun/src/analytics)
�[1m�[92m   Compiling�[0m bun_which v0.0.0 (
... (truncated)
diff hotspot
src/sql/postgres/protocol/StartupMessage.rs |  9 ++++
 src/sql_jsc/postgres/DataCell.rs            | 11 ++--
 test/js/sql/postgres-datestyle.test.ts      | 82 +++++++++++++++++++++++++++++
 test/js/sql/wire-frames.ts                  |  5 ++
 4 files changed, 103 insertions(+), 4 deletions(-)

gate history · 2 passed · 0 rejected · iteration 3

evidence per changed file
file                                         reads  edits  tests
src/sql/postgres/protocol/StartupMessage.rs      1      1      0
src/sql_jsc/postgres/DataCell.rs                 6      4      0
test/js/sql/postgres-datestyle.test.ts           2      6      0
test/js/sql/wire-frames.ts                       2      1      0

Bun decoded text-format date/timestamp values via JS Date.parse, which
assumes ISO input. Postgres emits date text in whatever DateStyle the
session has, and a database or role can default to a non-ISO style such
as 'SQL, DMY'. With that default, '2026-04-03'::date came over the wire
as '03/04/2026', Date.parse read it as MDY, and Bun silently returned
4 March instead of 3 April (or null for any day > 12).

Send DateStyle=ISO, MDY in the startup packet alongside client_encoding.
A startup-packet parameter has GUC source PGC_S_CLIENT, which outranks
postgresql.conf / ALTER DATABASE / ALTER ROLE defaults, so the server
always emits the ISO form the decoder expects. node-postgres and
postgres.js pin DateStyle the same way.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dfb5706e-2d78-4884-87d0-4038012caea3

📥 Commits

Reviewing files that changed from the base of the PR and between c36479c and 4118b7e.

📒 Files selected for processing (4)
  • src/sql/postgres/protocol/StartupMessage.rs
  • src/sql_jsc/postgres/DataCell.rs
  • test/js/sql/postgres-datestyle.test.ts
  • test/js/sql/wire-frames.ts

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 PM PT - Jul 22nd, 2026

@robobun, your commit 4118b7e is building: #78210

Comment thread test/js/sql/postgres-datestyle.test.ts Outdated

@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 — thanks for moving pgParameterStatus into wire-frames.ts.

What was reviewed:

  • StartupMessage.rs: the z_field_count addition and the two writer.string() calls mirror the client_encoding pair one line above, so the length prefix stays correct.
  • Confirmed ISO, MDY matches what node-postgres/postgres.js send; a later user SET datestyle still overrides it, so no user-visible regression.
  • Mock-server test parses the startup packet and asserts the param; container test forces SQL, DMY at the database level and round-trips both an ambiguous (04-03) and a day>12 (07-22) date on simple + extended paths, with cleanup in finally.
Extended reasoning...

Overview

The runtime change is two lines in src/sql/postgres/protocol/StartupMessage.rs: add z_field_count(b"DateStyle", b"ISO, MDY") to the length computation and emit the corresponding writer.string() pair. This is byte-for-byte the same pattern as the existing client_encoding = UTF8 entry immediately above it, so the message length stays consistent with what is written. src/sql_jsc/postgres/DataCell.rs is a comment-only change documenting the invariant the startup packet now establishes. test/js/sql/wire-frames.ts gains a pgParameterStatus frame builder (my earlier nit, now addressed), and test/js/sql/postgres-datestyle.test.ts is a new test file with a mock-server protocol assertion plus a describeWithContainer end-to-end test.

Security risks

None. The new bytes are a compile-time constant in the outbound startup packet; no user input flows into it and no parsing changes.

Level of scrutiny

Low-to-medium. The fix is mechanical (mirrors an adjacent, already-proven line) and matches the behavior of node-postgres and postgres.js, so ecosystem compatibility is well-established. It corrects silent date corruption without changing what users observe (they receive JS Date objects, not the wire text). A user who explicitly runs SET datestyle in-session still wins because SET has higher GUC precedence than PGC_S_CLIENT, so this cannot break intentional overrides. I checked z_field_count in zHelpers.rs — it returns z_count(prefix) + z_count(slice) for a non-empty value, which matches the two writer.string() calls emitted, so the Int32 length prefix is correct.

Other factors

The tests are solid: the mock-server test asserts the actual bytes in the StartupMessage (fails on main because the parameter is absent), and the container test uses the established describeWithContainer("postgres", { image: "postgres_plain" }) harness pattern seen in sibling files, forces a hostile SQL, DMY database default, and asserts exact ISO strings on both simple and extended query paths — including a day-22 date that would decode to null on main. Cleanup runs in finally. My previous nit about pgParameterStatus placement was resolved in 48a5621. No outstanding reviewer comments remain.

@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 prior nit (moving pgParameterStatus into wire-frames.ts) is addressed.

What was reviewed:

  • StartupMessage.rs: the DateStyle/ISO, MDY pair mirrors the existing client_encoding/UTF8 pattern exactly, and z_field_count counts both key and value so the length prefix stays correct.
  • DataCell.rs change is comment-only.
  • Container test: ALTER DATABASE ... SET datestyle is reset in finally, and with the fix in place the startup-packet override means concurrent Bun connections against the shared postgres_plain container aren't affected by the temporary DB default.
Extended reasoning...

Overview

Two lines added to the StartupMessage length calculation and two writer.string() calls appending DateStyle = ISO, MDY to the Postgres startup packet, immediately after and identically shaped to the existing client_encoding = UTF8 pair. The DataCell.rs change is a comment rewrite documenting the invariant now established. wire-frames.ts gains a pgParameterStatus helper (my prior nit, now addressed), and a new test file covers both a protocol-level assertion on the StartupMessage bytes and an end-to-end container test that forces a non-ISO database default and verifies dates round-trip on both simple and extended query paths.

Security risks

None. This adds a fixed literal key/value pair to an outbound protocol message; no user input flows into it. The length arithmetic uses the same z_field_count helper as the neighboring client_encoding line, so the declared packet length stays consistent with what's written.

Level of scrutiny

Low-to-moderate. The runtime change is mechanical — copy the line above, change two string literals — and the approach (pin DateStyle in the startup packet, GUC source PGC_S_CLIENT) is exactly what node-postgres and postgres.js do. I confirmed z_field_count(b"DateStyle", b"ISO, MDY") returns z_count(key) + z_count(value) for a non-empty value, matching the two writer.string() calls. No CODEOWNERS cover src/sql/.

Other factors

Test coverage is solid: the mock-server test parses the actual StartupMessage bytes and asserts params.DateStyle matches /^ISO\b/ (fails on main where the key is absent), and the container test exercises both an ambiguous date (2026-04-03, swaps under MDY heuristics) and a day-> 12 date (2026-07-22, becomes null) on both .simple() and extended paths. The ALTER DATABASE mutation is reset in finally, and since the fix makes every Bun connection override the DB default anyway, it can't leak into concurrent tests sharing the container. The describeWithContainer("postgres", { image: "postgres_plain" }) + await container.ready shape matches sibling tests in test/js/sql/.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green on everything this diff touches. test/js/sql/postgres-datestyle.test.ts passes on all lanes.

The remaining red on build 77752 is unrelated to this change:

  • test/js/node/test/parallel/test-net-connect-memleak.js (ubuntu x64): GC collection assertion in node:net, reproduces on main.
  • The other four are marked flaky (passed on retry): bun-install-registry, terminal-platform-gaps, webview-chrome, in-process-cron.

Ready for review.

Jarred-Sumner pushed a commit that referenced this pull request Jul 22, 2026
…ity (#35121)

## Problem

Postgres represents unbounded dates and timestamps as the special values
`'infinity'` / `'-infinity'`. On every decode path Bun returned them as
`Invalid Date` (a `Date` whose `getTime()` is `NaN`):

```js
await sql`select 'infinity'::date as a, '-infinity'::date as b`
// [{ a: Invalid Date, b: Invalid Date }]   sign lost, indistinguishable from a parse failure
```

That is data loss: `+infinity`, `-infinity`, and a genuinely unparseable
value all collapse to the same `NaN`, so user code cannot tell whether a
row means "no upper bound" or "no lower bound".

## Cause

Three independent paths, all ending at the same NaN:

- **scalar text** (`date` always, `timestamp`/`timestamptz` on
`.simple()`): routed through JS `Date.parse('infinity')` → `NaN`.
- **scalar binary** (`timestamp`/`timestamptz` on the extended
protocol): Postgres sends `PG_INT64_MAX` / `PG_INT64_MIN` (`DT_NOEND` /
`DT_NOBEGIN`). `from_binary` does `i64::MAX as f64 / 1000 +
946684800000` ≈ 9.224e15 ms, past JS Date's ±8.64e15 range, and
`DateInstance::create`'s `timeClip` maps it to `NaN`.
- **array text** (`{infinity,-infinity}::timestamp[]`): the parser
already produced `SQLDataCell::date(f64::INFINITY)`, but `SQLClient.cpp`
hands that to `DateInstance::create`, whose `timeClip` maps every
non-finite input to `NaN`. So the existing code that tried to preserve
±Infinity never took effect.

## Fix

Return the JS Number `±Infinity` for these values, matching
node-postgres (`pg-types` via `postgres-date`, for oids 1082/1114/1184).
`postgres.js` does not special-case this and returns `Invalid Date`, but
that loses information; node-postgres' behaviour is the one that
round-trips.

- `types/date.rs` `from_binary`: recognise `i64::MAX` / `i64::MIN` and
return `±f64::INFINITY`.
- `types/date.rs` `parse_infinity`: shared helper for the `infinity` /
`-infinity` text spellings.
- `types/date.rs` `from_js`: map `±Infinity` back to `i64::MAX` /
`i64::MIN` so the encode/decode pair is symmetric and the `(ms - epoch)
* 1000` arithmetic never overflows on the value the decoder now
produces.
- `DataCell.rs`: check `parse_infinity` before `Date.parse` on the
scalar text path and the unquoted-`date[]` array path (the
`timestamp[]`/`timestamptz[]` array path already produced
`date(±INFINITY)`).
- `SQLClient.cpp` `toJS`: when the Date cell carries ±Infinity, return
`jsDoubleNumber(±Infinity)` instead of a `timeClip`'d `DateInstance`.

Finite dates are unchanged and remain `Date` instances. MySQL shares the
`toJS` path but never produces ±Infinity there (zero DATETIME is `NaN`,
which `std::isinf` is false for).

This is a behaviour change (the result type for these two values changes
from `Date` to `number`), but the previous value was unusable:
`.toISOString()` throws on it, `.getTime()` is `NaN`, and the sign is
gone.

## Verification

`test/js/sql/postgres-infinity-date.test.ts` drives a scripted v3
backend and covers every path:

- scalar text: `date` / `timestamp` / `timestamptz`
- scalar binary: `timestamp` / `timestamptz` (`PG_INT64_MAX` /
`PG_INT64_MIN` on the wire)
- array text: `date[]` / `timestamp[]` / `timestamptz[]`
- bind: `±Infinity` bound to a `timestamp` / `timestamptz` parameter
writes `PG_INT64_MAX` / `PG_INT64_MIN` in the `Bind` body

Each case asserts `±Infinity` for the special values and a `Date`
instance for an adjacent finite value. All ten fail on main; all pass
here. The three `sql.test.ts` array tests and
`test/regression/issue/21311.test.ts` that pinned the old `Invalid Date`
behaviour are updated. `sql-postgres-datetime-roundtrip.test.ts`,
`postgres-datarow-overrun.test.ts`,
`postgres-binary-float-nan-box.test.ts`, and `wire-frames.test.ts` are
unchanged and pass.

Follows on from the note at the bottom of #35112.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main; conflict was with #35121 in DataCell.rs (same comment block). Kept the parse_infinity check from that PR and the updated comment from this one. postgres-datestyle.test.ts, postgres-infinity-date.test.ts, wire-frames.test.ts, and sql-postgres-datetime-roundtrip.test.ts all pass locally.

@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 earlier nit (moving pgParameterStatus into wire-frames.ts) is addressed and the merge with #35121 kept parse_infinity intact.

What was reviewed:

  • StartupMessage.rs: length count and write are symmetric — z_field_count(b"DateStyle", b"ISO, MDY") matches the two writer.string() calls, same pattern as client_encoding.
  • DataCell.rs: comment-only after the merge; parse_infinity guard from #35121 is still present before the ISO-decode path.
  • Container test's ALTER DATABASE ... SET datestyle is reset in finally, and even if it leaked the startup-packet pin now overrides it for every other Bun.SQL connection, so no cross-test contamination.
Extended reasoning...

Overview

Three-line production change in src/sql/postgres/protocol/StartupMessage.rs adding DateStyle = ISO, MDY to the Postgres startup packet (one z_field_count term in the length calculation plus two writer.string() calls), mirroring the existing client_encoding = UTF8 pinning immediately above it. src/sql_jsc/postgres/DataCell.rs is a comment-only change documenting the invariant the startup packet now establishes; after the merge with #35121 the parse_infinity check is preserved and only the comment block differs. test/js/sql/wire-frames.ts gains a pgParameterStatus builder next to its siblings (my prior nit, now addressed). test/js/sql/postgres-datestyle.test.ts is new and covers both a protocol-level mock (asserts the parameter is present in the StartupMessage bytes) and an end-to-end container test (ALTER DATABASE ... SET datestyle = 'SQL, DMY', reconnect, verify dates round-trip on both simple and extended paths).

Security risks

None. The change adds a fixed literal parameter to an outbound handshake packet; no user input flows into it, no parsing of untrusted data changes, and the length prefix is computed with the same z_field_count helper used for the neighbouring client_encoding field so there is no hand-rolled arithmetic to get wrong.

Level of scrutiny

Low-to-moderate. The production surface is tiny, follows the exact pattern already in the file, and matches what node-postgres and postgres.js do (cited in the PR and comment). The behaviour is a strict correctness improvement — a startup-packet GUC (PGC_S_CLIENT) outranks server/database/role defaults but is still overridable by an explicit in-session SET, so users who deliberately change DateStyle mid-session are unaffected. I checked z_field_count in zHelpers.rs to confirm the length term matches what writer.string() emits (key + NUL + value + NUL), so the declared packet length stays correct.

Other factors

The evidence block shows the new test fails on main (parameter absent, dates corrupted) and passes with the fix on both debug-ASAN and release. CI on the touched test file is green across all lanes; remaining red is unrelated pre-existing flakes. The container test resets datestyle in finally, and even a transient leak cannot affect other Bun.SQL tests because this very change makes the startup packet override any database-level default. The merge conflict with #35121 was in the same comment block only and was resolved by keeping both the parse_infinity guard and the updated comment — verified in the preloaded DataCell.rs. No outstanding reviewer comments.

@Jarred-Sumner
Jarred-Sumner merged commit 68fcf0b into main Jul 22, 2026
50 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/09b2dd8f/postgres-datestyle branch July 22, 2026 22:27
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.

2 participants