Skip to content

postgres: encode array parameters as text array literals - #33579

Open
robobun wants to merge 1 commit into
mainfrom
farm/bbb3f67d/fix-postgres-array-params
Open

robobun wants to merge 1 commit into
mainfrom
farm/bbb3f67d/fix-postgres-array-params

Conversation

@robobun

@robobun robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

A raw JavaScript array passed as a query parameter (not via sql.array) was encoded as wire garbage, so every array parameter failed against a real PostgreSQL server:

const sql = new Bun.SQL(url);
await sql.unsafe("select $1::int4[]", [[1, 2, 3]]);   // 08P01 insufficient data left in message
await sql.unsafe("select 3 = any($1::int4[])", [[1, 2, 3]]); // 08P01 insufficient data left in message
await sql.unsafe("select $1::float4[]", [[1.5, 2.5]]);  // number of array dimensions (...) exceeds the maximum
await sql.unsafe("select $1::text[]", [["a", "b"]]);    // malformed array literal: "a,b"
await sql.unsafe("select $1::int8[]", [[1, 2]]);        // malformed array literal: "1,2"

This is how you write IN-lists (where id = any($1)), batch lookups, and unnest($1) bulk inserts, so all of those were broken. postgres.js / node-postgres send {1,2,3} / {"a","b"} and the same queries work.

Cause

The Bind writer (write_bind in src/sql_jsc/postgres/PostgresRequest.rs) had no array value encoder:

  • Tag::int4_array fell into a scalar value.coerce::<i32>() arm and wrote a 4-byte scalar, while still declaring format 1 (binary).
  • Tag::float4_array fell into the generic ToString arm ([1.5,2.5] -> "1.5,2.5") but was declared binary, so the server read ASCII as a binary array header.
  • Every other array OID (text[], int8[], ...) also hit the ToString arm, producing "1,2" / "a,b" with no {} braces, which is not a PostgreSQL array literal.

Fix

Serialize any JS array parameter as a PostgreSQL text array literal and declare it format 0 (text), matching what postgres.js does:

  • [1, 2, 3] -> {"1","2","3"}
  • ["a", "b"] -> {"a","b"}
  • nested arrays -> nested braces {{"1","2"},{"3","4"}}
  • null / undefined elements -> unquoted NULL
  • " and \ in element text are escaped

json/jsonb array parameters are excluded and keep their existing JSON-text serialization ([1,2,3]), and a recursion-depth guard bounds pathologically nested input.

Verification

test/js/sql/postgres-bind-array-params.test.ts drives the prepared-query flow against a mock backend and asserts the exact bytes of the Bind message (format code + parameter value), so it is deterministic and needs no real PostgreSQL. The array-OID cases fail on the unfixed build (format code 1, scalar/ToString payload) and pass with the fix; the jsonb case guards against regressing JSON serialization.

Also verified end to end against PostgreSQL 17: all five queries above now return correct results.

Fixes #29551


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

fails on main (without fix)
ASAN without fix: 12 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-bind-array-params.test.ts
bun test v1.4.1 (4448a2e21)

test/js/sql/postgres-bind-array-params.test.ts:
70 | 
71 | const text = (b: Buffer | null) => (b === null ? null : b.toString("latin1"));
72 | 
73 | test("int4[] array parameter is sent as a text array literal, format 0", async () => {
74 |   const bind = await captureBind(1007 /* int4_array */, [1, 2, 3]);
75 |   expect(bind.formatCodes).toEqual([0]);
                                ^
error: expect(received).toEqual(expected)

  [
-   0,
+   1,
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bind-array-params.test.ts:75:28)
(fail) int4[] array parameter is sent as a text array literal, format 0 [624.18ms]
76 |   expect(text(bind.values[0])).toBe(`{"1","2","3"}`);
77 | });
78 | 
79 | test("float4[] array parameter is sent as a text array literal, format 0", async () => {
80 |   const bind = await captureBind(1021 /* float4_array */, [1.5, 2.5]);
81 |   expect(bind.formatCodes).toEqual([0]);
                       
... (truncated)

release without fix: 12 FAILED
bun test v1.4.0-canary.1 (4448a2e21)

test/js/sql/postgres-bind-array-params.test.ts:
70 | 
71 | const text = (b: Buffer | null) => (b === null ? null : b.toString("latin1"));
72 | 
73 | test("int4[] array parameter is sent as a text array literal, format 0", async () => {
74 |   const bind = await captureBind(1007 /* int4_array */, [1, 2, 3]);
75 |   expect(bind.formatCodes).toEqual([0]);
                                ^
error: expect(received).toEqual(expected)

  [
-   0,
+   1,
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bind-array-params.test.ts:75:28)
(fail) int4[] array parameter is sent as a text array literal, format 0 [10.14ms]
76 |   expect(text(bind.values[0])).toBe(`{"1","2","3"}`);
77 | });
78 | 
79 | test("float4[] array parameter is sent as a text array literal, format 0", async () => {
80 |   const bind = await captureBind(1021 /* float4_array */, [1.5, 2.5]);
81 |   expect(bind.formatCodes).toEqual([0]);
                                ^
error: expect(received).toEqual(expected)

  [
-   0,
+   1,
  ]

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bind
... (truncated)
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-bind-array-params.test.ts
bun test v1.4.1 (4448a2e21)

test/js/sql/postgres-bind-array-params.test.ts:
(pass) int4[] array parameter is sent as a text array literal, format 0 [979.88ms]
(pass) float4[] array parameter is sent as a text array literal, format 0 [140.51ms]
(pass) text[] array parameter is wrapped in braces [51.66ms]
(pass) int8[] array parameter is wrapped in braces [51.75ms]
(pass) null elements become NULL and quotes/backslashes are escaped [39.26ms]
(pass) nested arrays produce nested braces [36.14ms]
(pass) empty array becomes {} [35.02ms]
(pass) jsonb array parameter stays JSON, not a pg array literal [37.64ms]
(pass) box[] uses ; as the element delimiter [50.95ms]
(pass) Date elements serialize as ISO strings [52.32ms]
(pass) object elements in a jsonb[] serialize as JSON [45.49ms]
(pass) primitive elements in a jsonb[] serialize as JSON [59.40ms]
(pass) Buffer and Uint8Array elements in a bytea[] serialize as hex [53.64ms]

 13 pass
 0 fail
 26 expect() calls
Ran 13 tests across 1 file. [6.16s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e8df861eb2
  features     baseline

23 deps, 129 codegen, 1172 objects in 1467ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.0-canary.1 (4448a2e21)

Checked 26 installs across 63 packages (no changes) [21.00ms]
[2/1244] fetch zlib
[zlib] up to date
[3/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1217] gen bindgenv2
[5/1217] fetch tinycc
[tinycc] up to date
[6/1216] gen .bind.ts → GeneratedBindings.cpp
[7/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[8/1216] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (4448a2e21)

Checked 1 install across 2 packages (no changes) [2.00ms]
[9/1216] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1216] gen ErrorCode+*.h
[11/1216] gen b
... (truncated)
diff hotspot
src/sql_jsc/postgres/PostgresRequest.rs        |  96 +++++++++++++++-
 test/js/sql/postgres-bind-array-params.test.ts | 152 +++++++++++++++++++++++++
 test/js/sql/wire-frames.ts                     |  50 ++++++++
 3 files changed, 292 insertions(+), 6 deletions(-)

gate history · 2 passed · 0 rejected · iteration 6

evidence per changed file
file                                            reads  edits  tests
src/sql_jsc/postgres/PostgresRequest.rs            15     35      0
test/js/sql/postgres-bind-array-params.test.ts      2     10      0
test/js/sql/wire-frames.ts                          2      2      0

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR changes PostgreSQL Bind encoding so JavaScript arrays are sent as PostgreSQL text array literals, with array format forced to text, recursive array rendering, special handling for JSON, dates, buffers, and objects, plus wire helpers and tests for Bind-frame inspection.

Changes

Array Bind Serialization

Layer / File(s) Summary
Wire-frame decode helpers
test/js/sql/wire-frames.ts
Adds backend frame builders, PgBindMessage, pgDecodeBind, and pgSplitFrontend for PostgreSQL wire-message parsing.
Bind format and array dispatch
src/sql_jsc/postgres/PostgresRequest.rs
write_bind now forces text format for JS arrays, routes non-json/jsonb arrays through array-literal serialization, updates JSON writing to use OwnedString, and removes the dedicated int4_array scalar arm.
Array bind parameter tests
test/js/sql/postgres-bind-array-params.test.ts
Adds mock-socket tests that capture Bind frames and verify array literal encoding across numeric, text, nested, empty, escaped, jsonb, box[], Date, jsonb[], and bytea[] cases.

Related issues: #29551 (bun:sql incorrectly serializes JS arrays in sql(object) for PostgreSQL array columns)

Suggested labels: bun:sql, postgres, bug-fix

Suggested reviewers: SQL/Postgres protocol reviewer

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #29551 by correctly serializing JS arrays for PostgreSQL array columns instead of sending malformed values.
Out of Scope Changes check ✅ Passed The added test helpers and protocol utilities support the array-binding fix and do not appear unrelated to the issue.
Title check ✅ Passed The title clearly and concisely describes the primary change: encoding PostgreSQL array parameters as text array literals.
Description check ✅ Passed The description explains the problem, cause, fix, verification, regression coverage, and linked issue, although its headings differ from the template.

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

@github-actions github-actions Bot added the claude label Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun:sql incorrectly serializes JS arrays in sql(object) for PostgreSQL array columns #29551 - bun:sql incorrectly serializes JS arrays for PostgreSQL array columns, producing "malformed array literal" errors — exactly the missing {} braces and format code issues this PR addresses.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #29551

🤖 Generated with Claude Code

@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:07 PM PT - Aug 23rd, 2026

❌ @robobun, your commit e8df861 has 1 failures in Build #104587 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33579

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

bun-33579 --bun

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed: the repro in #29551 (sql(object) inserting into text[] columns) now round-trips correctly with this change. Linked it in the description.

Comment thread src/sql_jsc/postgres/PostgresRequest.rs
Comment thread test/js/sql/postgres-bind-array-params.test.ts Outdated
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed both in 5053bd3:

  • box[]: write_array_literal now takes a delimiter and emits ; for box_array (OID 1020), matching the JS serializer and DataCell decoder. Verified end to end against PostgreSQL: select ($1::box[])::text with ["(0,0),(1,1)", "(2,2),(3,3)"] now round-trips. Added a mock-backend assertion for the ; delimiter.
  • Test server leak: wrapped the tail of captureBind in try { return await promise; } finally { server.close(); } so the listening server is released even when the query rejects.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/sql_jsc/postgres/PostgresRequest.rs (1)

160-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim these comment blocks to the 3-line limit.

Both added comment blocks exceed the repository’s comment-length rule; keep only the durable invariant here and move extra detail elsewhere if needed. As per coding guidelines, “Keep code comments to 3 lines max.”

Proposed concise comments
-        // Serialize JS arrays as postgres text array literals (`{1,2,3}`),
-        // matching what postgres.js sends. The format code for these parameters
-        // was declared as 0 (text) in the loop above. json/jsonb arrays are
-        // excluded: they must stay JSON text (`[1,2,3]`), handled below.
+        // Serialize JS arrays as PostgreSQL text array literals. The format code
+        // was declared as text above; scalar json/jsonb arrays stay JSON text
+        // (`[1,2,3]`) and are handled below.
-/// Serialize a JS array into a PostgreSQL text array literal (e.g. `{1,2,3}`,
-/// `{"a","b"}`, `{1,NULL,3}`). Nested arrays become nested braces. Scalar
-/// elements are double-quoted with `"` and `\` escaped, which postgres accepts
-/// for every element type; `null`/`undefined` become an unquoted `NULL`.
-/// Element text mirrors the JS-side `serializeArray`
-/// (`src/js/internal/sql/postgres.ts`): `json[]`/`jsonb[]` elements and plain
-/// objects -> JSON, `Date` -> ISO string, `Buffer` in `bytea[]` -> `\x<hex>`,
-/// everything else -> `toString`. `depth` guards against stack overflow from
-/// pathologically nested input; postgres itself rejects more than 6 dimensions.
+/// Serialize a JS array into a PostgreSQL text array literal.
+/// Mirrors JS-side `serializeArray` for NULL, JSON, Date, bytea, and nesting.
+/// `depth` guards against stack overflow from pathologically nested input.

Also applies to: 274-282

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sql_jsc/postgres/PostgresRequest.rs` around lines 160 - 163, The added
comment blocks in PostgresRequest are too long and need to be trimmed to the
repository’s 3-line limit. Shorten the comments near the array-serialization
logic in PostgresRequest::... by keeping only the durable invariant and removing
the extra implementation details, and apply the same cleanup to the other
affected comment block referenced in the review so both stay within the
comment-length guideline.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/sql_jsc/postgres/PostgresRequest.rs`:
- Around line 160-163: The added comment blocks in PostgresRequest are too long
and need to be trimmed to the repository’s 3-line limit. Shorten the comments
near the array-serialization logic in PostgresRequest::... by keeping only the
durable invariant and removing the extra implementation details, and apply the
same cleanup to the other affected comment block referenced in the review so
both stay within the comment-length guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72258858-da5b-4d7d-afb4-fec5574ed73f

📥 Commits

Reviewing files that changed from the base of the PR and between ac3c3d1 and 427660c.

📒 Files selected for processing (1)
  • src/sql_jsc/postgres/PostgresRequest.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/sql_jsc/postgres/PostgresRequest.rs (1)

272-282: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Fix the stale depth-limit comment
MAX_ARRAY_DEPTH already returns InvalidQueryBinding; just update the “pg max is 6” note to match the actual 64-level safety cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sql_jsc/postgres/PostgresRequest.rs` around lines 272 - 282, The
depth-limit comment in write_array_literal is stale: it still says “pg max is 6”
even though MAX_ARRAY_DEPTH is 64 and overflow already maps to
InvalidQueryBinding. Update the doc comment above write_array_literal to
describe the actual 64-level safety cap, keeping the rest of the serialization
behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/sql_jsc/postgres/PostgresRequest.rs`:
- Around line 272-282: The depth-limit comment in write_array_literal is stale:
it still says “pg max is 6” even though MAX_ARRAY_DEPTH is 64 and overflow
already maps to InvalidQueryBinding. Update the doc comment above
write_array_literal to describe the actual 64-level safety cap, keeping the rest
of the serialization behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2732f518-41cb-4453-9fb4-e17c267ff33b

📥 Commits

Reviewing files that changed from the base of the PR and between 427660c and 598a9d4.

📒 Files selected for processing (1)
  • src/sql_jsc/postgres/PostgresRequest.rs

Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread test/js/sql/postgres-bind-array-params.test.ts Outdated
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is ready; CI is red on lanes unrelated to this change.

All four CI runs on this branch (builds 69528, 69570, 69590, 69648) hit the same infra failure on :darwin: 26 aarch64 - test-bun:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

so those jobs never downloaded a binary and never ran any tests. The remaining red is explicitly labeled flaky (style warning) on Windows: bake/dev-and-prod.test.ts HMR timeout, node-net-server.test.ts, and a spawn.test.ts timeout. None of those touch src/sql_jsc/ or test/js/sql/.

The new test test/js/sql/postgres-bind-array-params.test.ts passes on every lane that ran it (13/13), and the repros in the description and in #29551 are verified end to end against PostgreSQL 17. All review threads are resolved.

A maintainer can merge when ready or retry the darwin lane.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Issue #39878 reports the same bug: a JS array bound through sql.unsafe(query, values) to an array-typed placeholder is sent as a bare CSV, so the server rejects it with malformed array literal. This PR covers that case. The branch currently has conflicts with main and needs a rebase.

@robobun
robobun force-pushed the farm/bbb3f67d/fix-postgres-array-params branch from 66334a1 to 2fb65ad Compare August 21, 2026 05:50
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresRequest.rs Outdated
@robobun
robobun force-pushed the farm/bbb3f67d/fix-postgres-array-params branch from 2fb65ad to b569f68 Compare August 21, 2026 05:53
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and squashed to one commit (b569f68).

Conflicts were small:

  • src/sql_jsc/postgres/PostgresRequest.rs: main narrowed write_bind / write_query to pub(crate) (Narrow crate-internal Rust visibility across all targets and delete the code it proves dead #36184). Kept main's visibility; my array encoder is unchanged.
  • test/js/sql/wire-frames.ts: main independently added pgParseComplete, pgBindComplete, pgParameterDescription, and pgReadFrontendMessages. Dropped my duplicates and switched the test to main's helpers. This PR now adds only pgNoData, PgBindMessage, and pgDecodeBind.

Also pruned the inline comments flagged by the comment checker down to four one-liners.

test/js/sql/postgres-bind-array-params.test.ts passes (13/13) on the rebased build, and the repro queries still round-trip against PostgreSQL 17.

@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status after the rebase (b569f68, build 102364): the only red lane is test/js/bun/http/bun-server.test.ts on Windows x64, a websocket GC wrapper-count assertion (expected 1, received 2) in HTTP server code this PR does not touch. It has been reported for main-break triage separately. bun-patch.test.ts passed on retry. The earlier darwin artifact-download timeouts did not recur.

test/js/sql/postgres-bind-array-params.test.ts passes on every lane that ran it. The diff is ready for review.

Addendum: `test/js/sql/postgres-tls-ctx-leak.test.ts` also showed up as yellow (failed in the parallel batch, passed alone). It runs only `select 1` with no parameters and counts `PostgresSQLConnection` objects after GC, so it does not reach the Bind encoding this PR changes.

@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.

I reviewed the rebased/squashed commit and found no new issues — all four earlier findings (OwnedString leak, hex helper reuse, jsonb[] primitive elements, Uint8Array in bytea[]) are correctly carried forward, and the comment-cop flags are resolved. Because this adds a ~90-line native serializer that iterates arbitrary JS values on the Postgres Bind path, a human look is still worthwhile.

What was reviewed:

  • Format-code loop vs. value loop consistency: int4_array/float4_array are the only array tags with is_binary_format_supported() == true, and both now hit force_text via value.is_array(); every other array tag already returns format_code() == 0.
  • All three json_stringify_fast sites wrap the out-param in OwnedString so the +1 WTF ref is released on every exit.
  • is_array_buffer_like guards is_cell() before js_type(); the bytea branch runs before the JSON-object branch.
  • Rebase conflict resolution in wire-frames.ts — main's pgParseComplete/pgBindComplete/pgParameterDescription/pgReadFrontendMessages are reused; only pgNoData/pgDecodeBind are new.
Extended reasoning...

Overview

This PR adds text-format PostgreSQL array-literal encoding to write_bind in src/sql_jsc/postgres/PostgresRequest.rs. A JS array bound as a query parameter now emits {"1","2","3"} with format code 0, matching postgres.js/node-postgres. Previously int4[]/float4[] were declared binary but carried scalar/ASCII garbage, and every other array OID emitted a bare CSV. The change adds write_array_literal (~70 lines) and write_quoted_element, removes the dead int4_array match arm, forces format 0 for array values in the format-code loop, and fixes a pre-existing OwnedString leak in the json|jsonb arm. A new 13-case mock-backend test asserts exact Bind-frame bytes, and wire-frames.ts gains pgNoData + pgDecodeBind.

Security risks

None identified. Array-literal escaping (write_quoted_element) backslash-escapes " and \ per PostgreSQL array_in rules; the output goes into a Bind parameter value, not an SQL string, so this is not an injection surface. The MAX_ARRAY_DEPTH = 64 guard bounds recursion on pathological nested input and returns InvalidQueryBinding rather than overflowing the stack.

Level of scrutiny

Medium-high. This is native Rust that iterates arbitrary user JS values on a hot database path — every element goes through get_index, and per branch through json_stringify_fast, as_array_buffer, or BunString::from_js, all of which can throw or allocate WTF-refcounted strings. The repo's review guidance calls out exactly this class (exception propagation after every JS-entering call, +1 WTF ref ownership, GC re-entrancy during coercions). The PR has already been through four review iterations on this pass addressing an OwnedString leak, a Uint8Array-in-bytea[] silent-corruption regression, jsonb[] primitive-element parity with serializeArray, and a hex-helper duplication — all resolved and verified present in the squashed diff. The rebase also resolved non-trivial conflicts in wire-frames.ts (deduplicating helpers main added independently).

Other factors

  • The mock-backend test approach is deterministic and asserts exact wire bytes; the 13 cases cover the input-space checklist reasonably (empty, nested, null elements, escaping, delimiter override for box[], Date, Buffer+Uint8Array for bytea[], jsonb scalar carve-out, jsonb[] with primitives and objects).
  • is_binary_format_supported() lists only int4_array and float4_array among array tags; the added || value.is_array() in the force_text check covers exactly those, and every other array tag already yields format_code() == 0, so the two passes over the parameter list stay consistent.
  • All eight comment-cop flags posted after the rebase are resolved; the surviving four inline comments in the new code are one-liners.
  • Not approving because a ~90-line native serializer that walks user JS values on the Bind path is outside the "simple/mechanical" bar for auto-approval, and the rebase touched shared test infrastructure.

A raw JS array bound as a query parameter had no encoder in write_bind.
int4_array wrote a 4-byte scalar under a binary format declaration,
float4_array stringified to ASCII under a binary declaration, and every
other array OID got JS toString output with no braces. PostgreSQL
rejected all of them.

Serialize arrays as text array literals and declare format 0. Element
handling mirrors the JS-side serializeArray: json[]/jsonb[] elements and
plain objects go through JSON.stringify, Date becomes an ISO string, any
ArrayBufferView in bytea[] becomes \x hex, box[] uses the ; delimiter,
null becomes NULL, and nesting produces nested braces. The superseded
int4_array match arm is removed.

Fixes #29551
@robobun
robobun force-pushed the farm/bbb3f67d/fix-postgres-array-params branch from b569f68 to e8df861 Compare August 24, 2026 04:36
@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again (e8df861eb8). One conflict, in src/sql_jsc/postgres/PostgresRequest.rs: #40238 made bun_core::String own its WTF ref (it now has Drop) and changed json_stringify_fast to return the string, which removed OwnedString. I took main's side for the json | jsonb arm and updated the three call sites in write_array_literal to the new API, so the ref-release the earlier review asked for is now guaranteed by the type itself.

13/13 tests pass on the rebased build, and the repro queries round-trip against PostgreSQL 17.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status after the second rebase (e8df861, build 104587): the only failed job is again :darwin: any aarch64 - test-bun, with the same infra error as on 69528, 69570, 69590, and 69648:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

That lane never downloads a binary and never runs a test, so it carries no signal about this change. Every other red item in 104587 is labeled flaky and passed on retry (bake, bun-prune, migrate, h2-conformance, structured-clone, napi, and a template tsc check); none is under test/js/sql/.

test/js/sql/postgres-bind-array-params.test.ts passes on every lane that ran it. The PR is ready for a maintainer to review and merge, or to retry the darwin lane.

@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.

I re-reviewed after the e8df861 rebase and found no bugs. Given this is ~90 lines of new native serialization touching JSC value iteration and wire encoding, a human sign-off is still worthwhile.

Checked in this pass: the three json_stringify_fast sites and the BunString::from_js fallback under #40238's owning String — Drop now releases the WTF ref and the to_utf8_without_ref() borrow is copied before str drops; the || value.is_array() addition to force_text covers exactly the two binary-supporting array tags (int4_array/float4_array) so the declared format code and the text-literal payload agree; branch ordering keeps Uint8Array in bytea[] on the hex path ahead of the plain-object JSON path; the depth-64 guard bounds cyclic/nested input.

Extended reasoning...

Overview

The PR adds a native array-literal encoder to the PostgreSQL Bind writer (write_array_literal + write_quoted_element in src/sql_jsc/postgres/PostgresRequest.rs, ~90 new lines), removes the dead/wrong int4_array match arm, and forces format code 0 for JS-array parameters. It ships a 13-case wire-byte test against a mock backend and adds pgNoData/pgDecodeBind helpers to test/js/sql/wire-frames.ts.

Prior review history

I ran four inline-review rounds on earlier revisions (WTF ref leak in json_stringify_fast, hand-rolled hex table, jsonb[] string-primitive divergence, Uint8Array-in-bytea[] silently JSON-stringified). All were addressed and are resolved in the thread. The branch was rebased twice since; the most recent rebase (e8df861, three days ago) adapted the three json_stringify_fast call sites to #40238, which made bun_core::String own its WTF ref and gave it Drop. I verified the current code against src/bun_core/string/mod.rs:1146 and src/jsc/JSValue.rs:1586: the returned String is a named local, the to_utf8_without_ref() slice borrows from it, and the bytes are copied into out before the local drops — no leak, no UAF. The pre-existing json | jsonb arm was updated the same way.

Security risks

None identified. The recursion depth guard (64) bounds adversarial nesting; every fallible JS call (get_length, get_index, json_stringify_fast, from_js) propagates via ?; element bytes are escaped for " and \\ per the PostgreSQL array-literal grammar. No untrusted length is used to size an allocation.

Level of scrutiny

Moderate-to-high. This is new hand-written serialization in a database driver hot path (bulk inserts via unnest(), = any() IN-lists), iterating arbitrary JS values from native code. It has already surfaced two real bugs during review (a per-element ref leak and a silent-corruption path for Uint8Array in bytea[]), and was rebased three days ago onto a String-ownership refactor. That is enough moving parts that a maintainer should sign off rather than land on automated approval alone.

Other factors

Tests are deterministic wire-byte assertions against a mock backend; they verified fail-before/pass-after on both ASAN-debug and release. End-to-end PostgreSQL 17 verification is stated but manual. All prior review threads are resolved and the comment-cop findings were addressed in the squash.

This branch has not been deployed

No deployments
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.

bun:sql incorrectly serializes JS arrays in sql(object) for PostgreSQL array columns

1 participant