From 53b70502e7934faef2652415bdad5f9572c57c1c Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 15 May 2026 17:01:00 +0000 Subject: [PATCH 1/9] sql(mysql): add foundRows option to opt into CLIENT_FOUND_ROWS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mysql2 and mariadb both enable CLIENT_FOUND_ROWS by default, so an UPDATE that matches a row but does not change any column value returns affectedRows: 1. Bun.SQL's MySQL adapter had the flag defined in Capabilities but never set it and had no way to opt in, so the same query returned affectedRows: 0 — indistinguishable from an UPDATE that matched nothing. Code migrated from mysql2 / mariadb hit this. Add a foundRows connection option (default true, matching mysql2 and mariadb) that OR-s CLIENT_FOUND_ROWS into the client-side capability set built in handshake negotiation. Accepted as options.foundRows and as the URL query param ?foundRows=false; options object wins over URL. For non-MySQL adapters the option is parsed and ignored. Fixes #30843 --- packages/bun-types/sql.d.ts | 17 ++ src/js/internal/sql/mysql.ts | 1 + src/js/internal/sql/shared.ts | 23 +- src/js/private.d.ts | 2 +- src/sql_jsc/mysql/JSMySQLConnection.rs | 11 + src/sql_jsc/mysql/MySQLConnection.rs | 19 +- test/js/sql/sql-mysql-found-rows.test.ts | 278 +++++++++++++++++++++++ 7 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 test/js/sql/sql-mysql-found-rows.test.ts diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index f8e6f8310cbe..c0e5ccd8f146 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -410,6 +410,23 @@ declare module "bun" { * @default false */ allowPublicKeyRetrieval?: boolean | undefined; + + /** + * MySQL / MariaDB. When enabled, the server reports the number of rows + * matched by the `WHERE` clause in `affectedRows`, rather than the + * number of rows actually changed. This is the default behavior of the + * `mysql2` and `mariadb` drivers and matches what most Node.js MySQL + * code expects. Applies to both `adapter: "mysql"` and + * `adapter: "mariadb"`; no effect on `adapter: "postgres"`. + * + * Set to `false` to use the server's changed-rows semantics: an + * `UPDATE` that matches a row but does not change any column value + * will return `affectedRows: 0`. + * + * @see [CLIENT_FOUND_ROWS](https://dev.mysql.com/doc/c-api/en/mysql-affected-rows.html) + * @default true + */ + foundRows?: boolean | undefined; } /** diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index 3892c4a8741a..57a7a7b9ff64 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -108,6 +108,7 @@ export interface MySQLDotZig { maxLifetime: number, useUnnamedPreparedStatements: boolean, allowPublicKeyRetrieval: boolean, + foundRows: boolean, ) => $ZigGeneratedClasses.MySQLConnection; createQuery: ( sql: string, diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index cd90781105b4..34c4a70df71e 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -848,7 +848,8 @@ function closeNT(onClose: (err: Error) => void, err: Error | null) { * Resolves the password (which may be a function and/or a promise) and calls * the driver's native createConnection with the normalized pool options. * Extra trailing arguments past `useUnnamedPreparedStatements` (MySQL's - * `allowPublicKeyRetrieval`) are ignored by drivers that don't take them. + * `allowPublicKeyRetrieval` and `foundRows`) are ignored by drivers that + * don't take them. */ async function createPooledConnectionHandle( nativeCreateConnection: (...args: any[]) => ConnectionHandle, @@ -870,6 +871,7 @@ async function createPooledConnectionHandle( prepare = true, path, allowPublicKeyRetrieval = false, + foundRows = true, } = options; let password: Bun.MaybePromise | string | undefined | (() => Bun.MaybePromise) = options.password; @@ -904,6 +906,7 @@ async function createPooledConnectionHandle( maxLifetime, !prepare, !!allowPublicKeyRetrieval, + !!foundRows, ); } catch (e) { // defer so the callback never runs while the adapter is still filling @@ -1796,6 +1799,10 @@ function parseOptions( let bigint: boolean | undefined; let path: string; let prepare: boolean = true; + // MySQL-only. Defaults to `true` to match the `mysql2` and `mariadb` drivers + // (both enable CLIENT_FOUND_ROWS by default). Read from the options object + // and the URL query string below; ignored for non-MySQL adapters. + let foundRows: boolean = true; if (url !== null) { url = url instanceof URL ? url : new URL(url); @@ -1828,6 +1835,12 @@ function parseOptions( } } else if (lowerKey === "path") { path = queryObject[key]; + } else if (lowerKey === "foundrows") { + // Accept "false"/"0" (case-insensitive) to disable; anything else + // (including "true"/"1" or empty) leaves the default enabled. Only + // consumed by the MySQL adapter. + const value = `${queryObject[key]}`.toLowerCase(); + foundRows = !(value === "false" || value === "0"); } else { // this is valid for postgres for other databases it might not be valid // check adapter then implement for other databases @@ -2001,6 +2014,13 @@ function parseOptions( prepare = false; } + // The options-object form wins over the URL query string. `foundRows` is + // only meaningful for the MySQL wire protocol (maps to CLIENT_FOUND_ROWS); + // for Postgres/SQLite it's a no-op. + if (options.foundRows !== undefined) { + foundRows = !!options.foundRows; + } + onconnect ??= options.onconnect; onclose ??= options.onclose; @@ -2101,6 +2121,7 @@ function parseOptions( sslMode, query, max: max || 10, + foundRows, }; if (idleTimeout != null) { diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 106355dcb997..8aad31f5b443 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -28,7 +28,7 @@ declare module "bun" { /** * Represents the result of the `parseOptions()` function in the postgres, mysql or mariadb path */ - type DefinedPostgresOrMySQLOptions = Define & { + type DefinedPostgresOrMySQLOptions = Define & { sslMode: import("internal/sql/shared").SSLMode; query: string; }; diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 26cbd93b1360..c10c57088905 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -500,6 +500,16 @@ impl JSMySQLConnection { // MySQL doesn't support unnamed prepared statements let _ = use_unnamed_prepared_statements; let allow_public_key_retrieval = callframe.argument(15).to_boolean(); + // `foundRows: true` (default) asks the server to return matched-rows + // counts instead of changed-rows counts in OK_Packet.affected_rows — + // matches the `mysql2` / `mariadb` driver defaults. Enabled at + // handshake time by OR-ing `CLIENT_FOUND_ROWS` into the client + // capability set. `callframe.argument(16)` returns UNDEFINED when + // the JS layer omits the arg; `to_boolean` coerces UNDEFINED to + // `false` so we fall back to Bun's pre-fix changed-rows default in + // that case. Current `src/js/internal/sql/mysql.ts` always passes + // an explicit JS boolean so the default still fires there. + let found_rows = callframe.argument(16).to_boolean(); // Ownership transferred into `ptr.connection`; disarm the errdefer so the // connect-fail `ptr.deref()` is the sole cleanup path from here on. @@ -523,6 +533,7 @@ impl JSMySQLConnection { secure, args.ssl_mode, allow_public_key_retrieval, + found_rows, )), auto_flusher: JsCell::new(AutoFlusher::default()), idle_timeout_interval_ms: u32::try_from(idle_timeout).expect("int cast"), diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 423bd998bb80..f20347b630cd 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -91,6 +91,10 @@ pub struct MySQLConnection { ssl_mode: SSLMode, allow_public_key_retrieval: bool, flags: ConnectionFlags, + /// When `true`, request `CLIENT_FOUND_ROWS` during handshake so + /// `affected_rows` counts rows matched by `WHERE` instead of rows whose + /// column values actually changed (mysql2 / mariadb default). + found_rows: bool, } impl Default for MySQLConnection { @@ -125,6 +129,7 @@ impl Default for MySQLConnection { ssl_mode: SSLMode::Disable, allow_public_key_retrieval: false, flags: ConnectionFlags::default(), + found_rows: true, } } } @@ -144,6 +149,7 @@ impl MySQLConnection { secure: Option<*mut SslCtx>, ssl_mode: SSLMode, allow_public_key_retrieval: bool, + found_rows: bool, ) -> Self { Self { database, @@ -164,6 +170,7 @@ impl MySQLConnection { TLSStatus::None }, character_set: CharacterSet::default(), + found_rows, ..Default::default() } } @@ -664,11 +671,17 @@ impl MySQLConnection { // server's advertised capabilities. This ensures features like CLIENT_DEPRECATE_EOF // are only used when the server actually supports them (critical for MySQL-compatible // databases like StarRocks, TiDB, SingleStore, etc.). - self.capabilities = Capabilities::get_default_capabilities( + let mut requested = Capabilities::get_default_capabilities( self.ssl_mode != SSLMode::Disable, !self.database.is_empty(), - ) - .intersect(handshake.capability_flags); + ); + // CLIENT_FOUND_ROWS (`foundRows` connection option; default on) tells + // the server to report rows matched by `WHERE` in affected_rows rather + // than rows actually changed. Matches the mysql2 / mariadb driver + // defaults so an `UPDATE` that matches but doesn't change values still + // returns `affectedRows: 1`. + requested.CLIENT_FOUND_ROWS = self.found_rows; + self.capabilities = requested.intersect(handshake.capability_flags); self.mariadb_capabilities = MariaDBCapabilities::get_default_capabilities() .intersect(handshake.mariadb_capability_flags); diff --git a/test/js/sql/sql-mysql-found-rows.test.ts b/test/js/sql/sql-mysql-found-rows.test.ts new file mode 100644 index 000000000000..b64dc70f05e8 --- /dev/null +++ b/test/js/sql/sql-mysql-found-rows.test.ts @@ -0,0 +1,278 @@ +// Bun.SQL MySQL: `foundRows` connection option (CLIENT_FOUND_ROWS capability) +// +// mysql2 and mariadb both enable CLIENT_FOUND_ROWS by default, so an UPDATE +// that matches a row but does not change any value returns `affectedRows: 1`. +// This test asserts that: +// +// 1. The default (no `foundRows` option) sets CLIENT_FOUND_ROWS in the +// HandshakeResponse41 — matching the mysql2/mariadb defaults so code +// migrated from those drivers sees the same `affectedRows` values. +// 2. `foundRows: true` sets the bit. +// 3. `foundRows: false` (option form) clears the bit — opt-out for users +// who want the server's changed-rows semantics. +// 4. `?foundRows=false` on the URL clears the bit (same opt-out via URL). +// 5. The options object wins over the URL query string. +// 6. `affectedRows` on the SQLResultArray reflects the OK_Packet +// `affected_rows` field the server emits — which is the field +// CLIENT_FOUND_ROWS changes the server-side semantics of. +// +// Implemented via a mock MySQL server so the test runs without Docker. + +import { SQL } from "bun"; +import { describe, expect, test } from "bun:test"; +import net from "net"; + +// --- MySQL wire protocol helpers --- + +function u16le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff]); +} + +function u24le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); +} + +function u32le(n: number): Buffer { + return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); +} + +function packet(seq: number, payload: Buffer): Buffer { + return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); +} + +// Capability flag constants (bit positions in MySQL protocol). +const CLIENT_LONG_PASSWORD = 1 << 0; +const CLIENT_FOUND_ROWS = 1 << 1; +const CLIENT_LONG_FLAG = 1 << 2; +const CLIENT_CONNECT_WITH_DB = 1 << 3; +const CLIENT_PROTOCOL_41 = 1 << 9; +const CLIENT_SECURE_CONNECTION = 1 << 15; +const CLIENT_MULTI_STATEMENTS = 1 << 16; +const CLIENT_MULTI_RESULTS = 1 << 17; +const CLIENT_PLUGIN_AUTH = 1 << 19; +const CLIENT_DEPRECATE_EOF = 1 << 24; + +// Advertise everything the client may request, including CLIENT_FOUND_ROWS, +// so the intersect()-at-handshake step keeps whatever the client asked for. +const SERVER_CAPS = + CLIENT_LONG_PASSWORD | + CLIENT_FOUND_ROWS | + CLIENT_LONG_FLAG | + CLIENT_CONNECT_WITH_DB | + CLIENT_PROTOCOL_41 | + CLIENT_SECURE_CONNECTION | + CLIENT_MULTI_STATEMENTS | + CLIENT_MULTI_RESULTS | + CLIENT_PLUGIN_AUTH | + CLIENT_DEPRECATE_EOF; + +function handshakeV10(): Buffer { + const authData1 = Buffer.alloc(8, 0x61); + const authData2 = Buffer.alloc(13, 0x62); + authData2[12] = 0; // trailing NUL + const payload = Buffer.concat([ + Buffer.from([10]), // protocol version + Buffer.from("mock-5.7.0\0"), // server version NUL-terminated + u32le(1), // connection id + authData1, // auth-plugin-data-part-1 (8) + Buffer.from([0]), // filler + u16le(SERVER_CAPS & 0xffff), // capability flags lower + Buffer.from([0x2d]), // character set utf8mb4_general_ci + u16le(0x0002), // status flags SERVER_STATUS_AUTOCOMMIT + u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags upper + Buffer.from([21]), // auth-plugin-data length + Buffer.alloc(10, 0), // reserved + authData2, // auth-plugin-data-part-2 (13) + Buffer.from("mysql_native_password\0"), + ]); + return packet(0, payload); +} + +function okPacket(seq: number, affectedRows: number): Buffer { + // OK header 0x00, length-encoded affected_rows (fits in 1 byte when < 251), + // length-encoded last_insert_id = 0, status_flags = AUTOCOMMIT, warnings = 0. + return packet( + seq, + Buffer.from([ + 0x00, + affectedRows & 0xff, + 0x00, // last_insert_id + 0x02, + 0x00, // status_flags (AUTOCOMMIT) + 0x00, + 0x00, // warnings + ]), + ); +} + +interface CapturedHandshake { + capabilityFlags: number; +} + +// Minimal mock MySQL server that completes the handshake, captures the +// client's HandshakeResponse41 capability flags, and replies to a single +// COM_QUERY with an OK_Packet carrying the specified affected_rows value. +// Returns a Promise that resolves to the captured capability flags once the +// client finishes its first query. +function startMockMysql(affectedRows: number): Promise<{ + port: number; + captured: Promise; + close: () => void; +}> { + return new Promise((resolve, reject) => { + const captured = Promise.withResolvers(); + + const server = net.createServer(socket => { + let buffered = Buffer.alloc(0); + let sawHandshakeResponse = false; + + socket.write(handshakeV10()); + + socket.on("data", chunk => { + buffered = Buffer.concat([buffered, chunk]); + while (buffered.length >= 4) { + const len = buffered[0]! | (buffered[1]! << 8) | (buffered[2]! << 16); + if (buffered.length < 4 + len) break; + const seq = buffered[3]!; + const payload = buffered.subarray(4, 4 + len); + buffered = buffered.subarray(4 + len); + + if (!sawHandshakeResponse) { + // First packet from client after our handshake is the + // HandshakeResponse41. First 4 bytes of its payload are the + // client's negotiated capability flags (u32 LE). + sawHandshakeResponse = true; + const caps = + payload[0]! | (payload[1]! << 8) | (payload[2]! << 16) | (payload[3]! << 24); + captured.resolve({ capabilityFlags: caps >>> 0 }); + // Complete auth with OK packet so the connection is usable. + socket.write(okPacket(seq + 1, 0)); + } else { + const cmd = payload[0]; + if (cmd === 0x03) { + // COM_QUERY: reply with OK_Packet carrying affectedRows. + socket.write(okPacket(seq + 1, affectedRows)); + } else if (cmd === 0x01) { + // COM_QUIT + socket.end(); + } + } + } + }); + + socket.on("error", () => {}); + // Make sure we don't leak the captured waiter if the client drops. + socket.on("close", () => { + captured.resolve({ capabilityFlags: 0 }); + }); + }); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + resolve({ + port: addr.port, + captured: captured.promise, + close: () => server.close(), + }); + }); + }); +} + +async function runHandshakeCase(options: Bun.SQL.Options): Promise { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + ...options, + adapter: "mysql", + hostname: "127.0.0.1", + port: mock.port, + username: "root", + password: "", + database: "test", + max: 1, + idleTimeout: 1, + } as Bun.SQL.Options); + + // Triggering any query forces the handshake to complete. + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + return capabilityFlags; + } finally { + mock.close(); + } +} + +describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { + test("default: CLIENT_FOUND_ROWS is enabled (matches mysql2 / mariadb defaults)", async () => { + const caps = await runHandshakeCase({}); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + }); + + test("foundRows: true enables CLIENT_FOUND_ROWS", async () => { + const caps = await runHandshakeCase({ foundRows: true } as Bun.SQL.Options); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + }); + + test("foundRows: false disables CLIENT_FOUND_ROWS", async () => { + const caps = await runHandshakeCase({ foundRows: false } as Bun.SQL.Options); + expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(false); + }); + + test("URL ?foundRows=false disables CLIENT_FOUND_ROWS", async () => { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=false`, + max: 1, + idleTimeout: 1, + }); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(false); + } finally { + mock.close(); + } + }); + + test("options object wins over URL query string", async () => { + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=false`, + foundRows: true, + max: 1, + idleTimeout: 1, + } as Bun.SQL.Options); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + } finally { + mock.close(); + } + }); + + test("affectedRows reflects the server's OK_Packet.affected_rows value", async () => { + // CLIENT_FOUND_ROWS is what the server uses to pick matched-vs-changed. + // Bun just forwards whatever count the server reports; to pin down that + // we are not silently clamping or mis-decoding, the mock returns 1 and we + // assert the JS-side result carries it. + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + adapter: "mysql", + hostname: "127.0.0.1", + port: mock.port, + username: "root", + password: "", + database: "test", + max: 1, + idleTimeout: 1, + }); + const result: any = await db.unsafe("UPDATE t SET v = v WHERE id = 1"); + expect(result.affectedRows).toBe(1); + } finally { + mock.close(); + } + }); +}); From 9b4605084f2e39bdb564d4f3a553536dd41a7064 Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 15 May 2026 17:01:06 +0000 Subject: [PATCH 2/9] build: JSON-stringify OVERLAY_CSS define value in bake-codegen Bun.build parses define: values as JSON. The other defines (side, IS_ERROR_RUNTIME, IS_BUN_DEVELOPMENT) already go through JSON.stringify / String, but OVERLAY_CSS was passing the raw minified CSS as-is. That CSS starts with '*{...}'; bun versions that lack the auto-quote fallback from #30679 reject it with 'Operators are not allowed in JSON' and abort bake-codegen. The CI Dockerfile bootstraps from bun 1.3.13, which predates the fix, so cold builds fail. Wrap the value in JSON.stringify to match the other defines. --- src/codegen/bake-codegen.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index 1eaf4ff7239c..053a6c9af03e 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -56,7 +56,14 @@ async function run() { side: JSON.stringify(side), IS_ERROR_RUNTIME: String(file === "error"), IS_BUN_DEVELOPMENT: String(!!debug), - OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug), + // `Bun.build` parses `define:` values as JSON. The other defines + // above already go through `JSON.stringify` / `String`, but the + // raw minified CSS starts with `*{...}` — bun versions that lack + // the auto-quote fallback (#30679) reject it as "Operators are + // not allowed in JSON". Quote explicitly so cold builds work with + // the older bootstrap bun (1.3.13) used in the CI Dockerfile and + // any pre-fix local install. + OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)), }, minify: { syntax: !debug, From 0e23bc4eb035213264d6ce161588c466a24504d1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 15 May 2026 17:38:06 +0000 Subject: [PATCH 3/9] ci: retrigger node-http-backpressure.test.ts allocates a 2GB buffer and was SIGKILL'd on one aarch64 shard. Unrelated to the MySQL foundRows diff; every other test-bun shard on every other platform passed. From 5879c44aac7acba4cec3685799f99093c424cecb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 15 May 2026 18:29:25 +0000 Subject: [PATCH 4/9] sql(mysql): coerce foundRows URL value through String() before lowercasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun's URLSearchParams.toJSON() returns an Array when a key appears more than once in the query string, so `?foundRows=true&foundRows=false` gave us an array and `.toLowerCase()` threw `TypeError`. Coerce via String() first to match the sslmode branch's handling — duplicate keys now fall through to the default instead of crashing the connection path. Covered by a new test case in sql-mysql-found-rows.test.ts. Spotted by @claude on review. --- test/js/sql/sql-mysql-found-rows.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/js/sql/sql-mysql-found-rows.test.ts b/test/js/sql/sql-mysql-found-rows.test.ts index b64dc70f05e8..2adb0926c1ab 100644 --- a/test/js/sql/sql-mysql-found-rows.test.ts +++ b/test/js/sql/sql-mysql-found-rows.test.ts @@ -235,6 +235,29 @@ describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { } }); + test("URL with duplicate foundRows keys doesn't throw", async () => { + // `URLSearchParams.toJSON()` returns an Array when the same key appears + // more than once. The option parser coerces through `String()` before + // normalizing, so a malformed URL like `?foundRows=true&foundRows=false` + // must not throw "toLowerCase is not a function". Using `false` as the + // last value turns the coerced "true,false" into a string that is neither + // "false" nor "0", so the default (enabled) wins. + const mock = await startMockMysql(1); + try { + await using db = new SQL({ + url: `mysql://root:@127.0.0.1:${mock.port}/test?foundRows=true&foundRows=false`, + max: 1, + idleTimeout: 1, + }); + await db.unsafe("UPDATE t SET v = 1"); + const { capabilityFlags } = await mock.captured; + // "true,false" matches neither "false" nor "0" — stays at the default. + expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + } finally { + mock.close(); + } + }); + test("options object wins over URL query string", async () => { const mock = await startMockMysql(1); try { From 792a2e21b881fad677a090c09cae1b48f2a5f82a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 15 May 2026 19:17:10 +0000 Subject: [PATCH 5/9] docs(sql): note foundRows applies to mariadb + add runtime/sql.mdx section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSDoc had "MySQL only" which could imply `adapter: "mariadb"` is unsupported. Both adapters route through MySQLAdapter → createMySQLConnection → the same MySQL wire protocol, so the option works for either. Also add a short block to docs/runtime/sql.mdx explaining the new default (matched-rows) and how to opt out via `foundRows: false` or `?foundRows=false` on the URL. --- docs/runtime/sql.mdx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 22f72bdc4405..dbedd681dab4 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1407,6 +1407,28 @@ const now = await mysql`SELECT NOW() as current_time`; const uuid = await mysql`SELECT UUID() as id`; ``` +By default, `affectedRows` counts rows the `WHERE` clause **matched** — the +same default as the `mysql2` and `mariadb` drivers. An `UPDATE` that matches +a row but leaves every column untouched still reports `affectedRows: 1`. If +you want the server's changed-rows semantics instead (where a no-op update +returns `0`), pass `foundRows: false`: + +```ts +const mysql = new SQL({ + adapter: "mysql", // or "mariadb" + hostname: "localhost", + database: "myapp", + foundRows: false, // changed-rows semantics (returns 0 for no-op UPDATEs) +}); +// Equivalently as a URL query param: +new SQL("mysql://user:pass@localhost/db?foundRows=false"); +``` + +This option toggles the +[`CLIENT_FOUND_ROWS`](https://dev.mysql.com/doc/c-api/en/mysql-affected-rows.html) +capability flag during the protocol handshake and is ignored by the Postgres +and SQLite adapters. + ### MySQL Error Handling ```ts From 07ff6f3e2a0a5d538b8f32094e849fe48b81ab29 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 05:45:49 +0000 Subject: [PATCH 6/9] [autofix.ci] apply automated fixes --- test/js/sql/sql-mysql-found-rows.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/js/sql/sql-mysql-found-rows.test.ts b/test/js/sql/sql-mysql-found-rows.test.ts index 2adb0926c1ab..3af951ad3831 100644 --- a/test/js/sql/sql-mysql-found-rows.test.ts +++ b/test/js/sql/sql-mysql-found-rows.test.ts @@ -142,8 +142,7 @@ function startMockMysql(affectedRows: number): Promise<{ // HandshakeResponse41. First 4 bytes of its payload are the // client's negotiated capability flags (u32 LE). sawHandshakeResponse = true; - const caps = - payload[0]! | (payload[1]! << 8) | (payload[2]! << 16) | (payload[3]! << 24); + const caps = payload[0]! | (payload[1]! << 8) | (payload[2]! << 16) | (payload[3]! << 24); captured.resolve({ capabilityFlags: caps >>> 0 }); // Complete auth with OK packet so the connection is usable. socket.write(okPacket(seq + 1, 0)); From 717469d7e235a1feefc21d887369c2b0c35c8bab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:14:04 +0000 Subject: [PATCH 7/9] sql: tighten foundRows URL query comment --- src/js/internal/sql/shared.ts | 4 +++- src/sql_jsc/mysql/JSMySQLConnection.rs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 34c4a70df71e..9c1f9dc9c90d 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -1838,7 +1838,9 @@ function parseOptions( } else if (lowerKey === "foundrows") { // Accept "false"/"0" (case-insensitive) to disable; anything else // (including "true"/"1" or empty) leaves the default enabled. Only - // consumed by the MySQL adapter. + // consumed by the MySQL adapter. `toJSON()` returns an array when a + // key appears more than once (`?foundRows=a&foundRows=b`), so coerce + // to a string before calling `.toLowerCase`. const value = `${queryObject[key]}`.toLowerCase(); foundRows = !(value === "false" || value === "0"); } else { diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index c10c57088905..2cbc13d08af4 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -501,8 +501,8 @@ impl JSMySQLConnection { let _ = use_unnamed_prepared_statements; let allow_public_key_retrieval = callframe.argument(15).to_boolean(); // `foundRows: true` (default) asks the server to return matched-rows - // counts instead of changed-rows counts in OK_Packet.affected_rows — - // matches the `mysql2` / `mariadb` driver defaults. Enabled at + // counts instead of changed-rows counts in OK_Packet.affected_rows, + // matching the `mysql2` / `mariadb` driver defaults. Enabled at // handshake time by OR-ing `CLIENT_FOUND_ROWS` into the client // capability set. `callframe.argument(16)` returns UNDEFINED when // the JS layer omits the arg; `to_boolean` coerces UNDEFINED to From 52a6739bd0e4b93afc0d5bcd585438e9c5c6fe6b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:48:47 +0000 Subject: [PATCH 8/9] sql: trim foundRows comments to one line each --- src/codegen/bake-codegen.ts | 9 ++------- src/js/internal/sql/shared.ts | 15 ++++----------- src/sql_jsc/mysql/JSMySQLConnection.rs | 11 ++--------- src/sql_jsc/mysql/MySQLConnection.rs | 11 +++-------- 4 files changed, 11 insertions(+), 35 deletions(-) diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index 053a6c9af03e..1cd0360865fe 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -56,13 +56,8 @@ async function run() { side: JSON.stringify(side), IS_ERROR_RUNTIME: String(file === "error"), IS_BUN_DEVELOPMENT: String(!!debug), - // `Bun.build` parses `define:` values as JSON. The other defines - // above already go through `JSON.stringify` / `String`, but the - // raw minified CSS starts with `*{...}` — bun versions that lack - // the auto-quote fallback (#30679) reject it as "Operators are - // not allowed in JSON". Quote explicitly so cold builds work with - // the older bootstrap bun (1.3.13) used in the CI Dockerfile and - // any pre-fix local install. + // `define:` values are parsed as JSON; the CSS starts with `*{...}`, + // so quote it for bootstrap bun that predates #30679. OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)), }, minify: { diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 9c1f9dc9c90d..138dadd4c0a7 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -1799,9 +1799,7 @@ function parseOptions( let bigint: boolean | undefined; let path: string; let prepare: boolean = true; - // MySQL-only. Defaults to `true` to match the `mysql2` and `mariadb` drivers - // (both enable CLIENT_FOUND_ROWS by default). Read from the options object - // and the URL query string below; ignored for non-MySQL adapters. + // MySQL-only; defaults to `true` to match mysql2 / mariadb. let foundRows: boolean = true; if (url !== null) { @@ -1836,11 +1834,8 @@ function parseOptions( } else if (lowerKey === "path") { path = queryObject[key]; } else if (lowerKey === "foundrows") { - // Accept "false"/"0" (case-insensitive) to disable; anything else - // (including "true"/"1" or empty) leaves the default enabled. Only - // consumed by the MySQL adapter. `toJSON()` returns an array when a - // key appears more than once (`?foundRows=a&foundRows=b`), so coerce - // to a string before calling `.toLowerCase`. + // "false"/"0" disables; anything else keeps the default. `${}` handles + // the array `toJSON()` returns for duplicate keys. const value = `${queryObject[key]}`.toLowerCase(); foundRows = !(value === "false" || value === "0"); } else { @@ -2016,9 +2011,7 @@ function parseOptions( prepare = false; } - // The options-object form wins over the URL query string. `foundRows` is - // only meaningful for the MySQL wire protocol (maps to CLIENT_FOUND_ROWS); - // for Postgres/SQLite it's a no-op. + // Options object wins over the URL query string. if (options.foundRows !== undefined) { foundRows = !!options.foundRows; } diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 2cbc13d08af4..17a18a674401 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -500,15 +500,8 @@ impl JSMySQLConnection { // MySQL doesn't support unnamed prepared statements let _ = use_unnamed_prepared_statements; let allow_public_key_retrieval = callframe.argument(15).to_boolean(); - // `foundRows: true` (default) asks the server to return matched-rows - // counts instead of changed-rows counts in OK_Packet.affected_rows, - // matching the `mysql2` / `mariadb` driver defaults. Enabled at - // handshake time by OR-ing `CLIENT_FOUND_ROWS` into the client - // capability set. `callframe.argument(16)` returns UNDEFINED when - // the JS layer omits the arg; `to_boolean` coerces UNDEFINED to - // `false` so we fall back to Bun's pre-fix changed-rows default in - // that case. Current `src/js/internal/sql/mysql.ts` always passes - // an explicit JS boolean so the default still fires there. + // UNDEFINED (arg omitted) coerces to `false`; `mysql.ts` always + // passes an explicit boolean, defaulting to `true`. let found_rows = callframe.argument(16).to_boolean(); // Ownership transferred into `ptr.connection`; disarm the errdefer so the diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index f20347b630cd..90ec9890f09f 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -91,9 +91,7 @@ pub struct MySQLConnection { ssl_mode: SSLMode, allow_public_key_retrieval: bool, flags: ConnectionFlags, - /// When `true`, request `CLIENT_FOUND_ROWS` during handshake so - /// `affected_rows` counts rows matched by `WHERE` instead of rows whose - /// column values actually changed (mysql2 / mariadb default). + /// Request `CLIENT_FOUND_ROWS` during handshake (mysql2 / mariadb default). found_rows: bool, } @@ -675,11 +673,8 @@ impl MySQLConnection { self.ssl_mode != SSLMode::Disable, !self.database.is_empty(), ); - // CLIENT_FOUND_ROWS (`foundRows` connection option; default on) tells - // the server to report rows matched by `WHERE` in affected_rows rather - // than rows actually changed. Matches the mysql2 / mariadb driver - // defaults so an `UPDATE` that matches but doesn't change values still - // returns `affectedRows: 1`. + // CLIENT_FOUND_ROWS makes affected_rows count WHERE-matched rows + // rather than changed rows (mysql2 / mariadb default). requested.CLIENT_FOUND_ROWS = self.found_rows; self.capabilities = requested.intersect(handshake.capability_flags); self.mariadb_capabilities = MariaDBCapabilities::get_default_capabilities() From 9f51b7d9ab438c4ff7926e197c502f6db23d8c15 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:03:21 +0000 Subject: [PATCH 9/9] sql(mysql): use shared wire-frames helpers in foundRows test, fix lint - extend wire-frames.ts with MYSQL_CLIENT_FOUND_ROWS, an affectedRows param on mysqlOkPacket, and a HandshakeResponse41 client_flag parser; import them instead of hand-rolling the byte layout - read options.foundRows into a local once (bun/no-duplicate-conditional-property-access) - drop restating comments --- src/codegen/bake-codegen.ts | 3 +- src/js/internal/sql/shared.ts | 8 +- src/sql_jsc/mysql/JSMySQLConnection.rs | 2 - src/sql_jsc/mysql/MySQLConnection.rs | 2 - test/js/sql/sql-mysql-found-rows.test.ts | 225 +++++------------------ test/js/sql/wire-frames.ts | 13 +- 6 files changed, 63 insertions(+), 190 deletions(-) diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index 1cd0360865fe..4e663cd68355 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -56,8 +56,7 @@ async function run() { side: JSON.stringify(side), IS_ERROR_RUNTIME: String(file === "error"), IS_BUN_DEVELOPMENT: String(!!debug), - // `define:` values are parsed as JSON; the CSS starts with `*{...}`, - // so quote it for bootstrap bun that predates #30679. + // quoted because bootstrap bun predating #30679 rejects raw CSS as a define value OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)), }, minify: { diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 138dadd4c0a7..ec5dab440cc7 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -1834,8 +1834,7 @@ function parseOptions( } else if (lowerKey === "path") { path = queryObject[key]; } else if (lowerKey === "foundrows") { - // "false"/"0" disables; anything else keeps the default. `${}` handles - // the array `toJSON()` returns for duplicate keys. + // "false"/"0" disables; coerce first since toJSON() returns an array for duplicate keys const value = `${queryObject[key]}`.toLowerCase(); foundRows = !(value === "false" || value === "0"); } else { @@ -2012,8 +2011,9 @@ function parseOptions( } // Options object wins over the URL query string. - if (options.foundRows !== undefined) { - foundRows = !!options.foundRows; + const foundRowsOption = options.foundRows; + if (foundRowsOption !== undefined) { + foundRows = !!foundRowsOption; } onconnect ??= options.onconnect; diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 17a18a674401..7c5d24b6028e 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -500,8 +500,6 @@ impl JSMySQLConnection { // MySQL doesn't support unnamed prepared statements let _ = use_unnamed_prepared_statements; let allow_public_key_retrieval = callframe.argument(15).to_boolean(); - // UNDEFINED (arg omitted) coerces to `false`; `mysql.ts` always - // passes an explicit boolean, defaulting to `true`. let found_rows = callframe.argument(16).to_boolean(); // Ownership transferred into `ptr.connection`; disarm the errdefer so the diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 90ec9890f09f..d332475a7cd0 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -673,8 +673,6 @@ impl MySQLConnection { self.ssl_mode != SSLMode::Disable, !self.database.is_empty(), ); - // CLIENT_FOUND_ROWS makes affected_rows count WHERE-matched rows - // rather than changed rows (mysql2 / mariadb default). requested.CLIENT_FOUND_ROWS = self.found_rows; self.capabilities = requested.intersect(handshake.capability_flags); self.mariadb_capabilities = MariaDBCapabilities::get_default_capabilities() diff --git a/test/js/sql/sql-mysql-found-rows.test.ts b/test/js/sql/sql-mysql-found-rows.test.ts index 3af951ad3831..1b433cc94072 100644 --- a/test/js/sql/sql-mysql-found-rows.test.ts +++ b/test/js/sql/sql-mysql-found-rows.test.ts @@ -1,181 +1,59 @@ -// Bun.SQL MySQL: `foundRows` connection option (CLIENT_FOUND_ROWS capability) -// -// mysql2 and mariadb both enable CLIENT_FOUND_ROWS by default, so an UPDATE -// that matches a row but does not change any value returns `affectedRows: 1`. -// This test asserts that: -// -// 1. The default (no `foundRows` option) sets CLIENT_FOUND_ROWS in the -// HandshakeResponse41 — matching the mysql2/mariadb defaults so code -// migrated from those drivers sees the same `affectedRows` values. -// 2. `foundRows: true` sets the bit. -// 3. `foundRows: false` (option form) clears the bit — opt-out for users -// who want the server's changed-rows semantics. -// 4. `?foundRows=false` on the URL clears the bit (same opt-out via URL). -// 5. The options object wins over the URL query string. -// 6. `affectedRows` on the SQLResultArray reflects the OK_Packet -// `affected_rows` field the server emits — which is the field -// CLIENT_FOUND_ROWS changes the server-side semantics of. -// -// Implemented via a mock MySQL server so the test runs without Docker. +// Bun.SQL MySQL `foundRows` option: toggles CLIENT_FOUND_ROWS in the +// HandshakeResponse41 (default on, matching mysql2 / mariadb), which makes +// the server report WHERE-matched rows instead of changed rows in affectedRows. import { SQL } from "bun"; import { describe, expect, test } from "bun:test"; -import net from "net"; - -// --- MySQL wire protocol helpers --- - -function u16le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} - -function u24le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} - -function u32le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} - -function packet(seq: number, payload: Buffer): Buffer { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} - -// Capability flag constants (bit positions in MySQL protocol). -const CLIENT_LONG_PASSWORD = 1 << 0; -const CLIENT_FOUND_ROWS = 1 << 1; -const CLIENT_LONG_FLAG = 1 << 2; -const CLIENT_CONNECT_WITH_DB = 1 << 3; -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_MULTI_STATEMENTS = 1 << 16; -const CLIENT_MULTI_RESULTS = 1 << 17; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_DEPRECATE_EOF = 1 << 24; - -// Advertise everything the client may request, including CLIENT_FOUND_ROWS, -// so the intersect()-at-handshake step keeps whatever the client asked for. -const SERVER_CAPS = - CLIENT_LONG_PASSWORD | - CLIENT_FOUND_ROWS | - CLIENT_LONG_FLAG | - CLIENT_CONNECT_WITH_DB | - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_MULTI_STATEMENTS | - CLIENT_MULTI_RESULTS | - CLIENT_PLUGIN_AUTH | - CLIENT_DEPRECATE_EOF; - -function handshakeV10(): Buffer { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; // trailing NUL - const payload = Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-5.7.0\0"), // server version NUL-terminated - u32le(1), // connection id - authData1, // auth-plugin-data-part-1 (8) - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), // capability flags lower - Buffer.from([0x2d]), // character set utf8mb4_general_ci - u16le(0x0002), // status flags SERVER_STATUS_AUTOCOMMIT - u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags upper - Buffer.from([21]), // auth-plugin-data length - Buffer.alloc(10, 0), // reserved - authData2, // auth-plugin-data-part-2 (13) - Buffer.from("mysql_native_password\0"), - ]); - return packet(0, payload); -} - -function okPacket(seq: number, affectedRows: number): Buffer { - // OK header 0x00, length-encoded affected_rows (fits in 1 byte when < 251), - // length-encoded last_insert_id = 0, status_flags = AUTOCOMMIT, warnings = 0. - return packet( - seq, - Buffer.from([ - 0x00, - affectedRows & 0xff, - 0x00, // last_insert_id - 0x02, - 0x00, // status_flags (AUTOCOMMIT) - 0x00, - 0x00, // warnings - ]), - ); -} +import { + listeningServer, + MYSQL_CLIENT_FOUND_ROWS, + MYSQL_DEFAULT_CAPABILITIES, + mysqlHandshakeV10, + mysqlOkPacket, + mysqlParseHandshakeResponse41ClientFlags, + mysqlReadPackets, +} from "./wire-frames"; interface CapturedHandshake { capabilityFlags: number; } -// Minimal mock MySQL server that completes the handshake, captures the -// client's HandshakeResponse41 capability flags, and replies to a single -// COM_QUERY with an OK_Packet carrying the specified affected_rows value. -// Returns a Promise that resolves to the captured capability flags once the -// client finishes its first query. -function startMockMysql(affectedRows: number): Promise<{ +// Completes the handshake, captures the client's HandshakeResponse41 capability +// flags, and answers each COM_QUERY with an OK_Packet carrying `affectedRows`. +async function startMockMysql(affectedRows: number): Promise<{ port: number; captured: Promise; close: () => void; }> { - return new Promise((resolve, reject) => { - const captured = Promise.withResolvers(); - - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let sawHandshakeResponse = false; - - socket.write(handshakeV10()); - - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0]! | (buffered[1]! << 8) | (buffered[2]! << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]!; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!sawHandshakeResponse) { - // First packet from client after our handshake is the - // HandshakeResponse41. First 4 bytes of its payload are the - // client's negotiated capability flags (u32 LE). - sawHandshakeResponse = true; - const caps = payload[0]! | (payload[1]! << 8) | (payload[2]! << 16) | (payload[3]! << 24); - captured.resolve({ capabilityFlags: caps >>> 0 }); - // Complete auth with OK packet so the connection is usable. - socket.write(okPacket(seq + 1, 0)); - } else { - const cmd = payload[0]; - if (cmd === 0x03) { - // COM_QUERY: reply with OK_Packet carrying affectedRows. - socket.write(okPacket(seq + 1, affectedRows)); - } else if (cmd === 0x01) { - // COM_QUIT - socket.end(); - } - } + const captured = Promise.withResolvers(); + const { port, server } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let sawHandshakeResponse = false; + + // Advertise CLIENT_FOUND_ROWS so the client's intersect keeps it when requested. + socket.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_FOUND_ROWS })); + + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!sawHandshakeResponse) { + sawHandshakeResponse = true; + captured.resolve({ capabilityFlags: mysqlParseHandshakeResponse41ClientFlags(payload) }); + socket.write(mysqlOkPacket(seq + 1)); + } else if (payload[0] === 0x03) { + // COM_QUERY + socket.write(mysqlOkPacket(seq + 1, 0x00, affectedRows)); + } else if (payload[0] === 0x01) { + // COM_QUIT + socket.end(); } }); - - socket.on("error", () => {}); - // Make sure we don't leak the captured waiter if the client drops. - socket.on("close", () => { - captured.resolve({ capabilityFlags: 0 }); - }); }); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const addr = server.address() as net.AddressInfo; - resolve({ - port: addr.port, - captured: captured.promise, - close: () => server.close(), - }); - }); + socket.on("error", () => {}); + // Don't leak the captured waiter if the client drops early. + socket.on("close", () => captured.resolve({ capabilityFlags: 0 })); }); + return { port, captured: captured.promise, close: () => server.close() }; } async function runHandshakeCase(options: Bun.SQL.Options): Promise { @@ -205,17 +83,17 @@ async function runHandshakeCase(options: Bun.SQL.Options): Promise { describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { test("default: CLIENT_FOUND_ROWS is enabled (matches mysql2 / mariadb defaults)", async () => { const caps = await runHandshakeCase({}); - expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + expect((caps & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(true); }); test("foundRows: true enables CLIENT_FOUND_ROWS", async () => { const caps = await runHandshakeCase({ foundRows: true } as Bun.SQL.Options); - expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(true); + expect((caps & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(true); }); test("foundRows: false disables CLIENT_FOUND_ROWS", async () => { const caps = await runHandshakeCase({ foundRows: false } as Bun.SQL.Options); - expect((caps & CLIENT_FOUND_ROWS) !== 0).toBe(false); + expect((caps & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(false); }); test("URL ?foundRows=false disables CLIENT_FOUND_ROWS", async () => { @@ -228,19 +106,15 @@ describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { }); await db.unsafe("UPDATE t SET v = 1"); const { capabilityFlags } = await mock.captured; - expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(false); + expect((capabilityFlags & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(false); } finally { mock.close(); } }); test("URL with duplicate foundRows keys doesn't throw", async () => { - // `URLSearchParams.toJSON()` returns an Array when the same key appears - // more than once. The option parser coerces through `String()` before - // normalizing, so a malformed URL like `?foundRows=true&foundRows=false` - // must not throw "toLowerCase is not a function". Using `false` as the - // last value turns the coerced "true,false" into a string that is neither - // "false" nor "0", so the default (enabled) wins. + // toJSON() returns an array for duplicate keys; the coerced "true,false" + // is neither "false" nor "0", so the default (enabled) wins. const mock = await startMockMysql(1); try { await using db = new SQL({ @@ -250,8 +124,7 @@ describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { }); await db.unsafe("UPDATE t SET v = 1"); const { capabilityFlags } = await mock.captured; - // "true,false" matches neither "false" nor "0" — stays at the default. - expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + expect((capabilityFlags & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(true); } finally { mock.close(); } @@ -268,17 +141,13 @@ describe("Bun.SQL MySQL foundRows (CLIENT_FOUND_ROWS)", () => { } as Bun.SQL.Options); await db.unsafe("UPDATE t SET v = 1"); const { capabilityFlags } = await mock.captured; - expect((capabilityFlags & CLIENT_FOUND_ROWS) !== 0).toBe(true); + expect((capabilityFlags & MYSQL_CLIENT_FOUND_ROWS) !== 0).toBe(true); } finally { mock.close(); } }); test("affectedRows reflects the server's OK_Packet.affected_rows value", async () => { - // CLIENT_FOUND_ROWS is what the server uses to pick matched-vs-changed. - // Bun just forwards whatever count the server reports; to pin down that - // we are not silently clamping or mis-decoding, the mock returns 1 and we - // assert the JS-side result carries it. const mock = await startMockMysql(1); try { await using db = new SQL({ diff --git a/test/js/sql/wire-frames.ts b/test/js/sql/wire-frames.ts index 3903b1c7364f..6b677fb25b72 100644 --- a/test/js/sql/wire-frames.ts +++ b/test/js/sql/wire-frames.ts @@ -278,6 +278,7 @@ export async function pgMinimalReadyServer(): Promise<{ port: number; server: ne // Capability flags — page_protocol_basic_capability_flags.html (subset used by the mocks). export const MYSQL_CLIENT_LONG_PASSWORD = 1 << 0; // CLIENT_MYSQL in MariaDB's dialect +export const MYSQL_CLIENT_FOUND_ROWS = 1 << 1; export const MYSQL_CLIENT_PROTOCOL_41 = 1 << 9; export const MYSQL_CLIENT_SSL = 1 << 11; export const MYSQL_CLIENT_SECURE_CONNECTION = 1 << 15; @@ -352,8 +353,11 @@ export function mysqlHandshakeV10( // MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(header) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings) // The header is 0x00, except for the CLIENT_DEPRECATE_EOF result-set terminator, which is an OK packet with a 0xFE header. -export function mysqlOkPacket(seq: number, header: 0x00 | 0xfe = 0x00): Buffer { - return mysqlRawPacket(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); +export function mysqlOkPacket(seq: number, header: 0x00 | 0xfe = 0x00, affectedRows = 0): Buffer { + return mysqlRawPacket( + seq, + Buffer.concat([Buffer.from([header]), mysqlLenencInt(affectedRows), Buffer.from([0x00, 0x02, 0x00, 0x00, 0x00])]), + ); } // MySQL Protocol::AuthMoreData — page_protocol_connection_phase_packets_protocol_auth_more_data.html: @@ -407,6 +411,11 @@ export function mysqlParseHandshakeResponseExtendedCaps(payload: Buffer): number return payload.readUInt32LE(4 + 4 + 1 + 19); } +// HandshakeResponse41 starts with Int<4>(client_flag): the capabilities the client negotiated. +export function mysqlParseHandshakeResponse41ClientFlags(payload: Buffer): number { + return payload.readUInt32LE(0); +} + // MySQL Protocol::HandshakeResponse41 — page_protocol_connection_phase_packets_protocol_handshake_response.html: // Int<4>(client_flag) Int<4>(max_packet) Int<1>(charset) String<23>(filler) NulString(username) // then the auth_response as a string (CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) or Int<1>-prefixed string.