From beea7180f358f779ea84d5960c6db8d92bf1c603 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 13 Sep 2025 14:52:19 -0700 Subject: [PATCH] refactor(MySQL) (#22619) ### What does this PR do? ### How did you verify your code works? --------- Co-authored-by: Jarred Sumner Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/bun.js/api/server.zig | 11 +- src/bun.js/api/server/RequestContext.zig | 2 +- src/bun.js/api/sql.classes.ts | 7 +- src/bun.js/bindings/JSRef.zig | 58 +- src/bun.js/bindings/ZigGlobalObject.cpp | 17 +- src/bun.js/bindings/bindings.cpp | 29 +- src/codegen/bundle-modules.ts | 12 +- src/js/bun/sql.ts | 30 +- src/js/internal/sql/mysql.ts | 23 +- src/js/internal/sql/postgres.ts | 2 +- src/js/internal/sql/sqlite.ts | 7 +- src/sql/mysql.zig | 8 +- src/sql/mysql/MySQLConnection.zig | 1632 +++++--------------- src/sql/mysql/MySQLQuery.zig | 670 +++----- src/sql/mysql/MySQLQueryResult.zig | 4 + src/sql/mysql/MySQLRequestQueue.zig | 224 +++ src/sql/mysql/MySQLStatement.zig | 2 +- src/sql/mysql/QueryStatus.zig | 18 + src/sql/mysql/js/JSMySQLConnection.zig | 809 ++++++++++ src/sql/mysql/js/JSMySQLQuery.zig | 402 +++++ src/sql/mysql/protocol/AnyMySQLError.zig | 4 + src/sql/mysql/protocol/ErrorPacket.zig | 2 +- src/sql/postgres/CommandTag.zig | 4 +- src/sql/postgres/PostgresSQLConnection.zig | 16 +- src/sql/postgres/PostgresSQLQuery.zig | 60 +- src/sql/shared/ObjectIterator.zig | 4 + test/harness.ts | 7 +- test/internal/ban-limits.json | 2 +- test/js/sql/sql-mysql.test.ts | 71 +- test/js/sql/sql.test.ts | 2 +- 30 files changed, 2238 insertions(+), 1901 deletions(-) create mode 100644 src/sql/mysql/MySQLQueryResult.zig create mode 100644 src/sql/mysql/MySQLRequestQueue.zig create mode 100644 src/sql/mysql/QueryStatus.zig create mode 100644 src/sql/mysql/js/JSMySQLConnection.zig create mode 100644 src/sql/mysql/js/JSMySQLQuery.zig diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index f26f455258..a29342e6a1 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -627,8 +627,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d } pub fn jsValueAssertAlive(server: *ThisServer) jsc.JSValue { - // With JSRef, we can safely access the JS value even after stop() via weak reference - return server.js_value.get(); + bun.assert(server.js_value.isNotEmpty()); + return server.js_value.tryGet().?; } pub fn requestIP(this: *ThisServer, request: *jsc.WebCore.Request) bun.JSError!jsc.JSValue { @@ -1124,7 +1124,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d this.onReloadFromZig(&new_config, globalThis); - return this.js_value.get(); + return this.js_value.tryGet() orelse .js_undefined; } pub fn onFetch(this: *ThisServer, ctx: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { @@ -1426,7 +1426,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d pub fn finalize(this: *ThisServer) void { httplog("finalize", .{}); - this.js_value.deinit(); + this.js_value.finalize(); this.flags.has_js_deinited = true; this.deinitIfWeCan(); } @@ -1539,8 +1539,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d } pub fn stop(this: *ThisServer, abrupt: bool) void { - const current_value = this.js_value.get(); - this.js_value.setWeak(current_value); + this.js_value.downgrade(); if (this.config.allow_hot and this.config.id.len > 0) { if (this.globalThis.bunVM().hotMap()) |hot| { diff --git a/src/bun.js/api/server/RequestContext.zig b/src/bun.js/api/server/RequestContext.zig index e1c0097107..aab5b503f2 100644 --- a/src/bun.js/api/server/RequestContext.zig +++ b/src/bun.js/api/server/RequestContext.zig @@ -1981,7 +1981,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, this.flags.has_called_error_handler = true; const result = server.config.onError.call( server.globalThis, - server.js_value.get(), + server.js_value.tryGet() orelse .js_undefined, &.{value}, ) catch |err| server.globalThis.takeException(err); defer result.ensureStillAlive(); diff --git a/src/bun.js/api/sql.classes.ts b/src/bun.js/api/sql.classes.ts index db29a3dc1f..3fdfe17a8d 100644 --- a/src/bun.js/api/sql.classes.ts +++ b/src/bun.js/api/sql.classes.ts @@ -9,7 +9,7 @@ for (const type of types) { construct: true, finalize: true, configurable: false, - hasPendingActivity: true, + hasPendingActivity: type === "PostgresSQL", klass: { // escapeString: { // fn: "escapeString", @@ -60,7 +60,6 @@ for (const type of types) { construct: true, finalize: true, configurable: false, - JSType: "0b11101110", klass: {}, proto: { @@ -77,11 +76,11 @@ for (const type of types) { length: 0, }, setMode: { - fn: "setMode", + fn: "setModeFromJS", length: 1, }, setPendingValue: { - fn: "setPendingValue", + fn: "setPendingValueFromJS", length: 1, }, }, diff --git a/src/bun.js/bindings/JSRef.zig b/src/bun.js/bindings/JSRef.zig index a4f079b98d..c76a4056eb 100644 --- a/src/bun.js/bindings/JSRef.zig +++ b/src/bun.js/bindings/JSRef.zig @@ -4,33 +4,28 @@ pub const JSRef = union(enum) { finalized: void, pub fn initWeak(value: jsc.JSValue) @This() { + bun.assert(!value.isEmptyOrUndefinedOrNull()); return .{ .weak = value }; } pub fn initStrong(value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) @This() { + bun.assert(!value.isEmptyOrUndefinedOrNull()); return .{ .strong = .create(value, globalThis) }; } pub fn empty() @This() { - return .{ .weak = .zero }; + return .{ .weak = .js_undefined }; } - pub fn get(this: *@This()) jsc.JSValue { + pub fn tryGet(this: *const @This()) ?jsc.JSValue { return switch (this.*) { - .weak => this.weak, - .strong => this.strong.get() orelse .zero, - .finalized => .zero, - }; - } - - pub fn tryGet(this: *@This()) ?jsc.JSValue { - return switch (this.*) { - .weak => if (this.weak != .zero) this.weak else null, + .weak => if (this.weak.isEmptyOrUndefinedOrNull()) null else this.weak, .strong => this.strong.get(), .finalized => null, }; } pub fn setWeak(this: *@This(), value: jsc.JSValue) void { + bun.assert(!value.isEmptyOrUndefinedOrNull()); switch (this.*) { .weak => {}, .strong => { @@ -44,6 +39,7 @@ pub const JSRef = union(enum) { } pub fn setStrong(this: *@This(), value: jsc.JSValue, globalThis: *jsc.JSGlobalObject) void { + bun.assert(!value.isEmptyOrUndefinedOrNull()); if (this.* == .strong) { this.strong.set(globalThis, value); return; @@ -54,7 +50,7 @@ pub const JSRef = union(enum) { pub fn upgrade(this: *@This(), globalThis: *jsc.JSGlobalObject) void { switch (this.*) { .weak => { - bun.assert(this.weak != .zero); + bun.assert(!this.weak.isEmptyOrUndefinedOrNull()); this.* = .{ .strong = .create(this.weak, globalThis) }; }, .strong => {}, @@ -64,10 +60,41 @@ pub const JSRef = union(enum) { } } + pub fn downgrade(this: *@This()) void { + switch (this.*) { + .weak => {}, + .strong => |*strong| { + const value = strong.trySwap() orelse .js_undefined; + value.ensureStillAlive(); + strong.deinit(); + this.* = .{ .weak = value }; + }, + .finalized => { + bun.debugAssert(false); + }, + } + } + + pub fn isEmpty(this: *const @This()) bool { + return switch (this.*) { + .weak => this.weak.isEmptyOrUndefinedOrNull(), + .strong => !this.strong.has(), + .finalized => true, + }; + } + + pub fn isNotEmpty(this: *const @This()) bool { + return switch (this.*) { + .weak => !this.weak.isEmptyOrUndefinedOrNull(), + .strong => this.strong.has(), + .finalized => false, + }; + } + pub fn deinit(this: *@This()) void { switch (this.*) { .weak => { - this.weak = .zero; + this.weak = .js_undefined; }, .strong => { this.strong.deinit(); @@ -75,6 +102,11 @@ pub const JSRef = union(enum) { .finalized => {}, } } + + pub fn finalize(this: *@This()) void { + this.deinit(); + this.* = .{ .finalized = {} }; + } }; const bun = @import("bun"); diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 98b998abad..df656b263b 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -1552,9 +1552,18 @@ JSC_DEFINE_HOST_FUNCTION(functionQueueMicrotask, auto* globalObject = defaultGlobalObject(lexicalGlobalObject); JSC::JSValue asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); + auto function = globalObject->performMicrotaskFunction(); +#if ASSERT_ENABLED + ASSERT_WITH_MESSAGE(function, "Invalid microtask function"); + ASSERT_WITH_MESSAGE(!callback.isEmpty(), "Invalid microtask callback"); +#endif + + if (asyncContext.isEmpty()) { + asyncContext = JSC::jsUndefined(); + } // This is a JSC builtin function - lexicalGlobalObject->queueMicrotask(globalObject->performMicrotaskFunction(), callback, asyncContext, + lexicalGlobalObject->queueMicrotask(function, callback, asyncContext, JSC::JSValue {}, JSC::JSValue {}); return JSC::JSValue::encode(JSC::jsUndefined()); @@ -4151,6 +4160,12 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskCallback(Zig::GlobalObject* g { JSFunction* function = globalObject->nativeMicrotaskTrampoline(); +#if ASSERT_ENABLED + ASSERT_WITH_MESSAGE(function, "Invalid microtask function"); + ASSERT_WITH_MESSAGE(ptr, "Invalid microtask context"); + ASSERT_WITH_MESSAGE(callback, "Invalid microtask callback"); +#endif + // Do not use JSCell* here because the GC will try to visit it. globalObject->queueMicrotask(function, JSValue(std::bit_cast(reinterpret_cast(ptr))), JSValue(std::bit_cast(reinterpret_cast(callback))), jsUndefined(), jsUndefined()); } diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 85e0fa466b..872ad00329 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -3453,6 +3453,7 @@ void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::J JSC::EncodedJSValue encoedValue, bool handled) { JSC::JSValue value = JSC::JSValue::decode(encoedValue); + auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); uint32_t flags = promise->internalField(JSC::JSPromise::Field::Flags).get().asUInt32(); @@ -3463,10 +3464,28 @@ void JSC__JSPromise__rejectOnNextTickWithHandled(JSC::JSPromise* promise, JSC::J promise->internalField(JSC::JSPromise::Field::Flags).set(vm, promise, jsNumber(flags | JSC::JSPromise::isFirstResolvingFunctionCalledFlag)); auto* globalObject = jsCast(promise->globalObject()); + auto microtaskFunction = globalObject->performMicrotaskFunction(); + auto rejectPromiseFunction = globalObject->rejectPromiseFunction(); + + auto asyncContext = globalObject->m_asyncContextData.get()->getInternalField(0); + +#if ASSERT_ENABLED + ASSERT_WITH_MESSAGE(microtaskFunction, "Invalid microtask function"); + ASSERT_WITH_MESSAGE(rejectPromiseFunction, "Invalid microtask callback"); + ASSERT_WITH_MESSAGE(!value.isEmpty(), "Invalid microtask value"); +#endif + + if (asyncContext.isEmpty()) { + asyncContext = jsUndefined(); + } + + if (value.isEmpty()) { + value = jsUndefined(); + } globalObject->queueMicrotask( - globalObject->performMicrotaskFunction(), - globalObject->rejectPromiseFunction(), + microtaskFunction, + rejectPromiseFunction, globalObject->m_asyncContextData.get()->getInternalField(0), promise, value); @@ -6129,8 +6148,9 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0 if (microtaskArgs[3].isEmpty()) { microtaskArgs[3] = jsUndefined(); } - + auto microTaskFunction = globalObject->performMicrotaskFunction(); #if ASSERT_ENABLED + ASSERT_WITH_MESSAGE(microTaskFunction, "Invalid microtask function"); auto& vm = globalObject->vm(); if (microtaskArgs[0].isCell()) { JSC::Integrity::auditCellFully(vm, microtaskArgs[0].asCell()); @@ -6147,10 +6167,11 @@ extern "C" void JSC__JSGlobalObject__queueMicrotaskJob(JSC::JSGlobalObject* arg0 if (microtaskArgs[3].isCell()) { JSC::Integrity::auditCellFully(vm, microtaskArgs[3].asCell()); } + #endif globalObject->queueMicrotask( - globalObject->performMicrotaskFunction(), + microTaskFunction, WTFMove(microtaskArgs[0]), WTFMove(microtaskArgs[1]), WTFMove(microtaskArgs[2]), diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index a7645c1625..46a99d44ac 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -98,8 +98,8 @@ for (let i = 0; i < nativeStartIndex; i++) { // TODO: there is no reason this cannot be converted automatically. // import { ... } from '...' -> `const { ... } = require('...')` - const scannedImports = t.scanImports(input); - for (const imp of scannedImports) { + const scannedImports = t.scan(input); + for (const imp of scannedImports.imports) { if (imp.kind === "import-statement") { var isBuiltin = true; try { @@ -120,6 +120,14 @@ for (let i = 0; i < nativeStartIndex; i++) { } } + if (scannedImports.exports.includes("default") && scannedImports.exports.length > 1) { + const err = new Error( + `Using \`export default\` AND named exports together in builtin modules is unsupported. See src/js/README.md (from ${moduleList[i]})`, + ); + err.name = "BunError"; + err.fileName = moduleList[i]; + throw err; + } let importStatements: string[] = []; const processed = sliceSourceCode( diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index db7b0eb871..dc063c0a1a 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -291,7 +291,7 @@ const SQL: typeof Bun.SQL = function SQL( reserved_sql.connect = () => { if (state.connectionState & ReservedConnectionState.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } return Promise.$resolve(reserved_sql); }; @@ -322,7 +322,7 @@ const SQL: typeof Bun.SQL = function SQL( reserved_sql.beginDistributed = (name: string, fn: TransactionCallback) => { // begin is allowed the difference is that we need to make sure to use the same connection and never release it if (state.connectionState & ReservedConnectionState.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } let callback = fn; @@ -346,7 +346,7 @@ const SQL: typeof Bun.SQL = function SQL( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) ) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } let callback = fn; let options: string | undefined = options_or_fn as unknown as string; @@ -369,7 +369,7 @@ const SQL: typeof Bun.SQL = function SQL( reserved_sql.flush = () => { if (state.connectionState & ReservedConnectionState.closed) { - throw this.connectionClosedError(); + throw pool.connectionClosedError(); } // Use pooled connection's flush if available, otherwise use adapter's flush if (pooledConnection.flush) { @@ -429,7 +429,7 @@ const SQL: typeof Bun.SQL = function SQL( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) ) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } // just release the connection back to the pool state.connectionState |= ReservedConnectionState.closed; @@ -552,7 +552,7 @@ const SQL: typeof Bun.SQL = function SQL( function run_internal_transaction_sql(string) { if (state.connectionState & ReservedConnectionState.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } return unsafeQueryFromTransaction(string, [], pooledConnection, state.queries); } @@ -564,7 +564,7 @@ const SQL: typeof Bun.SQL = function SQL( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) ) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } if ($isArray(strings)) { // detect if is tagged template @@ -593,7 +593,7 @@ const SQL: typeof Bun.SQL = function SQL( transaction_sql.connect = () => { if (state.connectionState & ReservedConnectionState.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } return Promise.$resolve(transaction_sql); @@ -732,7 +732,7 @@ const SQL: typeof Bun.SQL = function SQL( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) ) { - throw this.connectionClosedError(); + throw pool.connectionClosedError(); } if ($isCallable(name)) { @@ -816,7 +816,7 @@ const SQL: typeof Bun.SQL = function SQL( sql.reserve = () => { if (pool.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } // Check if adapter supports reserved connections @@ -831,7 +831,7 @@ const SQL: typeof Bun.SQL = function SQL( }; sql.rollbackDistributed = async function (name: string) { if (pool.closed) { - throw this.connectionClosedError(); + throw pool.connectionClosedError(); } if (!pool.getRollbackDistributedSQL) { @@ -844,7 +844,7 @@ const SQL: typeof Bun.SQL = function SQL( sql.commitDistributed = async function (name: string) { if (pool.closed) { - throw this.connectionClosedError(); + throw pool.connectionClosedError(); } if (!pool.getCommitDistributedSQL) { @@ -857,7 +857,7 @@ const SQL: typeof Bun.SQL = function SQL( sql.beginDistributed = (name: string, fn: TransactionCallback) => { if (pool.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } let callback = fn; @@ -876,7 +876,7 @@ const SQL: typeof Bun.SQL = function SQL( sql.begin = (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { if (pool.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } let callback = fn; let options: string | undefined = options_or_fn as unknown as string; @@ -896,7 +896,7 @@ const SQL: typeof Bun.SQL = function SQL( }; sql.connect = () => { if (pool.closed) { - return Promise.$reject(this.connectionClosedError()); + return Promise.$reject(pool.connectionClosedError()); } if (pool.isConnected()) { diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index 44a1002e5c..d3431b1144 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -274,6 +274,9 @@ function onQueryFinish(this: PooledMySQLConnection, onClose: (err: Error) => voi this.adapter.release(this); } +function closeNT(onClose: (err: Error) => void, err: Error | null) { + onClose(err as Error); +} class PooledMySQLConnection { private static async createConnection( options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, @@ -328,7 +331,7 @@ class PooledMySQLConnection { !prepare, ); } catch (e) { - onClose(e as Error); + process.nextTick(closeNT, onClose, e); return null; } } @@ -344,10 +347,13 @@ class PooledMySQLConnection { /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries queryCount: number = 0; - #onConnected(err, _) { + #onConnected(err, connection) { if (err) { err = wrapError(err); + } else { + this.connection = connection; } + const connectionInfo = this.connectionInfo; if (connectionInfo?.onconnect) { connectionInfo.onconnect(err); @@ -413,12 +419,8 @@ class PooledMySQLConnection { this.#startConnection(); } - async #startConnection() { - this.connection = await PooledMySQLConnection.createConnection( - this.connectionInfo, - this.#onConnected.bind(this), - this.#onClose.bind(this), - ); + #startConnection() { + PooledMySQLConnection.createConnection(this.connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); } onClose(onClose: (err: Error) => void) { @@ -482,14 +484,14 @@ class PooledMySQLConnection { } } -export class MySQLAdapter +class MySQLAdapter implements DatabaseAdapter { public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; public readonly connections: PooledMySQLConnection[]; - public readonly readyConnections: Set; + public readonly readyConnections: Set = new Set(); public waitingQueue: Array<(err: Error | null, result: any) => void> = []; public reservedQueue: Array<(err: Error | null, result: any) => void> = []; @@ -502,7 +504,6 @@ export class MySQLAdapter constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { this.connectionInfo = connectionInfo; this.connections = new Array(connectionInfo.max); - this.readyConnections = new Set(); } escapeIdentifier(str: string) { diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index 75ad2085ef..4e6a0c32f2 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -499,7 +499,7 @@ class PooledPostgresConnection { } } -export class PostgresAdapter +class PostgresAdapter implements DatabaseAdapter< PooledPostgresConnection, diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index 11304a7e87..8e1c944438 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -293,7 +293,7 @@ function parseSQLQuery(query: string): SQLParsedInfo { return { command, firstKeyword, hasReturning }; } -export class SQLiteQueryHandle implements BaseQueryHandle { +class SQLiteQueryHandle implements BaseQueryHandle { private mode = SQLQueryResultMode.objects; private readonly sql: string; @@ -380,9 +380,7 @@ export class SQLiteQueryHandle implements BaseQueryHandle -{ +class SQLiteAdapter implements DatabaseAdapter { public readonly connectionInfo: Bun.SQL.__internal.DefinedSQLiteOptions; public db: BunSQLiteModule.Database | null = null; public storedError: Error | null = null; @@ -807,4 +805,5 @@ export default { SQLCommand, commandToString, parseSQLQuery, + SQLiteQueryHandle, }; diff --git a/src/sql/mysql.zig b/src/sql/mysql.zig index ad391c73ab..a18663dee9 100644 --- a/src/sql/mysql.zig +++ b/src/sql/mysql.zig @@ -5,21 +5,21 @@ pub fn createBinding(globalObject: *jsc.JSGlobalObject) JSValue { binding.put( globalObject, ZigString.static("createQuery"), - jsc.JSFunction.create(globalObject, "createQuery", MySQLQuery.call, 6, .{}), + jsc.JSFunction.create(globalObject, "createQuery", MySQLQuery.createInstance, 6, .{}), ); binding.put( globalObject, ZigString.static("createConnection"), - jsc.JSFunction.create(globalObject, "createQuery", MySQLConnection.call, 2, .{}), + jsc.JSFunction.create(globalObject, "createConnection", MySQLConnection.createInstance, 2, .{}), ); return binding; } -pub const MySQLConnection = @import("./mysql/MySQLConnection.zig"); +pub const MySQLConnection = @import("./mysql/js/JSMySQLConnection.zig"); pub const MySQLContext = @import("./mysql/MySQLContext.zig"); -pub const MySQLQuery = @import("./mysql/MySQLQuery.zig"); +pub const MySQLQuery = @import("./mysql/js/JSMySQLQuery.zig"); const bun = @import("bun"); diff --git a/src/sql/mysql/MySQLConnection.zig b/src/sql/mysql/MySQLConnection.zig index 368fc03b7b..50fc0c6f12 100644 --- a/src/sql/mysql/MySQLConnection.zig +++ b/src/sql/mysql/MySQLConnection.zig @@ -1,113 +1,90 @@ const MySQLConnection = @This(); -socket: Socket, +#socket: Socket = .{ .SocketTCP = .{ .socket = .{ .detached = {} } } }, status: ConnectionState = .disconnected, -ref_count: RefCount = RefCount.init(), -write_buffer: bun.OffsetByteList = .{}, -read_buffer: bun.OffsetByteList = .{}, -last_message_start: u32 = 0, -sequence_id: u8 = 0, - -requests: Queue = Queue.init(bun.default_allocator), -// number of pipelined requests (Bind/Execute/Prepared statements) -pipelined_requests: u32 = 0, -// number of non-pipelined requests (Simple/Copy) -nonpipelinable_requests: u32 = 0, +#write_buffer: bun.OffsetByteList = .{}, +#read_buffer: bun.OffsetByteList = .{}, +#last_message_start: u32 = 0, +#sequence_id: u8 = 0, +// TODO: move it to JSMySQLConnection +queue: MySQLRequestQueue = MySQLRequestQueue.init(), +// TODO: move it to JSMySQLConnection statements: PreparedStatementsMap = .{}, -poll_ref: bun.Async.KeepAlive = .{}, -globalObject: *jsc.JSGlobalObject, -vm: *jsc.VirtualMachine, +#server_version: bun.ByteList = .{}, +#connection_id: u32 = 0, +#capabilities: Capabilities = .{}, +#character_set: CharacterSet = CharacterSet.default, +#status_flags: StatusFlags = .{}, -pending_activity_count: std.atomic.Value(u32) = std.atomic.Value(u32).init(0), -js_value: JSValue = .js_undefined, +#auth_plugin: ?AuthMethod = null, +#auth_state: AuthState = .{ .pending = {} }, -server_version: bun.ByteList = .{}, -connection_id: u32 = 0, -capabilities: Capabilities = .{}, -character_set: CharacterSet = CharacterSet.default, -status_flags: StatusFlags = .{}, +#auth_data: std.ArrayList(u8) = std.ArrayList(u8).init(bun.default_allocator), +#database: []const u8 = "", +#user: []const u8 = "", +#password: []const u8 = "", +#options: []const u8 = "", +#options_buf: []const u8 = "", +#tls_ctx: ?*uws.SocketContext = null, +#tls_config: jsc.API.ServerConfig.SSLConfig = .{}, +#tls_status: TLSStatus = .none, +#ssl_mode: SSLMode = .disable, +#flags: ConnectionFlags = .{}, -auth_plugin: ?AuthMethod = null, -auth_state: AuthState = .{ .pending = {} }, - -auth_data: std.ArrayList(u8) = std.ArrayList(u8).init(bun.default_allocator), -database: []const u8 = "", -user: []const u8 = "", -password: []const u8 = "", -options: []const u8 = "", -options_buf: []const u8 = "", - -tls_ctx: ?*uws.SocketContext = null, -tls_config: jsc.API.ServerConfig.SSLConfig = .{}, -tls_status: TLSStatus = .none, -ssl_mode: SSLMode = .disable, - -idle_timeout_interval_ms: u32 = 0, -connection_timeout_ms: u32 = 0, - -flags: ConnectionFlags = .{}, - -/// Before being connected, this is a connection timeout timer. -/// After being connected, this is an idle timeout timer. -timer: bun.api.Timer.EventLoopTimer = .{ - .tag = .MySQLConnectionTimeout, - .next = .{ - .sec = 0, - .nsec = 0, - }, -}, - -/// This timer controls the maximum lifetime of a connection. -/// It starts when the connection successfully starts (i.e. after handshake is complete). -/// It stops when the connection is closed. -max_lifetime_interval_ms: u32 = 0, -max_lifetime_timer: bun.api.Timer.EventLoopTimer = .{ - .tag = .MySQLConnectionMaxLifetime, - .next = .{ - .sec = 0, - .nsec = 0, - }, -}, - -auto_flusher: AutoFlusher = .{}, - -pub const ref = RefCount.ref; -pub const deref = RefCount.deref; - -pub fn onAutoFlush(this: *@This()) bool { - if (this.flags.has_backpressure) { - debug("onAutoFlush: has backpressure", .{}); - this.auto_flusher.registered = false; - // if we have backpressure, wait for onWritable - return false; - } - this.ref(); - defer this.deref(); - debug("onAutoFlush: draining", .{}); - // drain as much as we can - this.drainInternal(); - - // if we dont have backpressure and if we still have data to send, return true otherwise return false and wait for onWritable - const keep_flusher_registered = !this.flags.has_backpressure and this.write_buffer.len() > 0; - debug("onAutoFlush: keep_flusher_registered: {}", .{keep_flusher_registered}); - this.auto_flusher.registered = keep_flusher_registered; - return keep_flusher_registered; +pub fn init( + database: []const u8, + username: []const u8, + password: []const u8, + options: []const u8, + options_buf: []const u8, + tls_config: jsc.API.ServerConfig.SSLConfig, + tls_ctx: ?*uws.SocketContext, + ssl_mode: SSLMode, +) @This() { + return .{ + .#database = database, + .#user = username, + .#password = password, + .#options = options, + .#options_buf = options_buf, + .#socket = .{ .SocketTCP = .{ .socket = .{ .detached = {} } } }, + .queue = MySQLRequestQueue.init(), + .statements = PreparedStatementsMap{}, + .#tls_config = tls_config, + .#tls_ctx = tls_ctx, + .#ssl_mode = ssl_mode, + .#tls_status = if (ssl_mode != .disable) .pending else .none, + .#character_set = CharacterSet.default, + }; } pub fn canPipeline(this: *@This()) bool { - if (bun.getRuntimeFeatureFlag(.BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING)) { - @branchHint(.unlikely); - return false; - } + return this.queue.canPipeline(this.getJSConnection()); +} +pub fn canPrepareQuery(this: *@This()) bool { + return this.queue.canPrepareQuery(this.getJSConnection()); +} +pub fn canExecuteQuery(this: *@This()) bool { + return this.queue.canExecuteQuery(this.getJSConnection()); +} + +pub inline fn isAbleToWrite(this: *@This()) bool { return this.status == .connected and - this.nonpipelinable_requests == 0 and // need to wait for non pipelinable requests to finish - !this.flags.use_unnamed_prepared_statements and // unnamed statements are not pipelinable - !this.flags.waiting_to_prepare and // cannot pipeline when waiting prepare - !this.flags.has_backpressure and // dont make sense to buffer more if we have backpressure - this.write_buffer.len() < MAX_PIPELINE_SIZE; // buffer is too big need to flush before pipeline more + !this.#flags.has_backpressure and + this.#write_buffer.len() < MAX_PIPELINE_SIZE; +} + +pub inline fn isProcessingData(this: *@This()) bool { + return this.#flags.is_processing_data; +} +pub inline fn hasBackpressure(this: *@This()) bool { + return this.#flags.has_backpressure; +} +pub inline fn resetBackpressure(this: *@This()) void { + this.#flags.has_backpressure = false; } pub const AuthState = union(enum) { pending: void, @@ -122,870 +99,112 @@ pub const AuthState = union(enum) { }; }; -pub fn hasPendingActivity(this: *MySQLConnection) bool { - return this.pending_activity_count.load(.acquire) > 0; +pub fn canFlush(this: *@This()) bool { + return !this.#flags.has_backpressure and // if has backpressure we need to wait for onWritable event + this.status == .connected and //and we need to be connected + // we need data to send + (this.#write_buffer.len() > 0 or + if (this.queue.current()) |request| request.isPending() else false); } -fn updateHasPendingActivity(this: *MySQLConnection) void { - const a: u32 = if (this.requests.readableLength() > 0) 1 else 0; - const b: u32 = if (this.status != .disconnected) 1 else 0; - this.pending_activity_count.store(a + b, .release); +pub fn isIdle(this: *@This()) bool { + return this.queue.current() == null and this.#write_buffer.len() == 0; } -fn hasDataToSend(this: *@This()) bool { - if (this.write_buffer.len() > 0) { - return true; - } - if (this.current()) |request| { - switch (request.status) { - .pending, .binding => return true, - else => return false, - } - } - return false; +pub fn enqueueRequest(this: *@This(), request: *JSMySQLQuery) void { + this.queue.add(request); } -fn registerAutoFlusher(this: *@This()) void { - const has_data_to_send = this.hasDataToSend(); - debug("registerAutoFlusher: backpressure: {} registered: {} has_data_to_send: {}", .{ this.flags.has_backpressure, this.auto_flusher.registered, has_data_to_send }); - - if (!this.auto_flusher.registered and // should not be registered - !this.flags.has_backpressure and // if has backpressure we need to wait for onWritable event - has_data_to_send and // we need data to send - this.status == .connected //and we need to be connected - ) { - AutoFlusher.registerDeferredMicrotaskWithTypeUnchecked(@This(), this, this.vm); - this.auto_flusher.registered = true; - } -} -pub fn flushDataAndResetTimeout(this: *@This()) void { - this.resetConnectionTimeout(); - // defer flushing, so if many queries are running in parallel in the same connection, we don't flush more than once - this.registerAutoFlusher(); -} - -fn unregisterAutoFlusher(this: *@This()) void { - debug("unregisterAutoFlusher registered: {}", .{this.auto_flusher.registered}); - if (this.auto_flusher.registered) { - AutoFlusher.unregisterDeferredMicrotaskWithType(@This(), this, this.vm); - this.auto_flusher.registered = false; - } -} - -fn getTimeoutInterval(this: *const @This()) u32 { - return switch (this.status) { - .connected => this.idle_timeout_interval_ms, - .failed => 0, - else => this.connection_timeout_ms, - }; -} -pub fn disableConnectionTimeout(this: *@This()) void { - if (this.timer.state == .ACTIVE) { - this.vm.timer.remove(&this.timer); - } - this.timer.state = .CANCELLED; -} -pub fn resetConnectionTimeout(this: *@This()) void { - // if we are processing data, don't reset the timeout, wait for the data to be processed - if (this.flags.is_processing_data) return; - const interval = this.getTimeoutInterval(); - if (this.timer.state == .ACTIVE) { - this.vm.timer.remove(&this.timer); - } - if (interval == 0) { - return; - } - - this.timer.next = bun.timespec.msFromNow(@intCast(interval)); - this.vm.timer.insert(&this.timer); -} - -fn setupMaxLifetimeTimerIfNecessary(this: *@This()) void { - if (this.max_lifetime_interval_ms == 0) return; - if (this.max_lifetime_timer.state == .ACTIVE) return; - - this.max_lifetime_timer.next = bun.timespec.msFromNow(@intCast(this.max_lifetime_interval_ms)); - this.vm.timer.insert(&this.max_lifetime_timer); -} - -pub fn onConnectionTimeout(this: *@This()) bun.api.Timer.EventLoopTimer.Arm { - debug("onConnectionTimeout", .{}); - - this.timer.state = .FIRED; - if (this.flags.is_processing_data) { - return .disarm; - } - - if (this.getTimeoutInterval() == 0) { - this.resetConnectionTimeout(); - return .disarm; - } - - switch (this.status) { - .connected => { - this.failFmt(error.IdleTimeout, "Idle timeout reached after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.idle_timeout_interval_ms) *| std.time.ns_per_ms)}); - }, - else => { - this.failFmt(error.ConnectionTimedOut, "Connection timeout after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); - }, - .handshaking, - .authenticating, - .authentication_awaiting_pk, - => { - this.failFmt(error.ConnectionTimedOut, "Connection timed out after {} (during authentication)", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); - }, - } - return .disarm; -} - -pub fn onMaxLifetimeTimeout(this: *@This()) bun.api.Timer.EventLoopTimer.Arm { - debug("onMaxLifetimeTimeout", .{}); - this.max_lifetime_timer.state = .FIRED; - if (this.status == .failed) return .disarm; - this.failFmt(error.LifetimeTimeout, "Max lifetime timeout reached after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.max_lifetime_interval_ms) *| std.time.ns_per_ms)}); - return .disarm; -} -fn drainInternal(this: *@This()) void { - debug("drainInternal", .{}); - if (this.vm.isShuttingDown()) return this.close(); - - const event_loop = this.vm.eventLoop(); - event_loop.enter(); - defer event_loop.exit(); - +pub fn flushQueue(this: *@This()) error{AuthenticationFailed}!void { this.flushData(); - if (!this.flags.has_backpressure) { - if (this.tls_status == .message_sent) { - this.upgradeToTLS(); + if (!this.#flags.has_backpressure) { + if (this.#tls_status == .message_sent) { + try this.upgradeToTLS(); } else { // no backpressure yet so pipeline more if possible and flush again - this.advance(); + this.queue.advance(this.getJSConnection()); this.flushData(); } } } -pub fn finalize(this: *MySQLConnection) void { - this.stopTimers(); - debug("MySQLConnection finalize", .{}); - // Ensure we disconnect before finalizing - if (this.status != .disconnected) { - this.disconnect(); - } - - this.js_value = .zero; - this.deref(); -} - -pub fn doRef(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { - this.poll_ref.ref(this.vm); - this.updateHasPendingActivity(); - return .js_undefined; -} - -pub fn doUnref(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { - this.poll_ref.unref(this.vm); - this.updateHasPendingActivity(); - return .js_undefined; -} - -pub fn doFlush(this: *MySQLConnection, _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { - this.registerAutoFlusher(); - return .js_undefined; -} - -pub fn createQuery(this: *MySQLConnection, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - _ = callframe; - _ = globalObject; - _ = this; - - return .js_undefined; -} - -pub fn getConnected(this: *MySQLConnection, _: *jsc.JSGlobalObject) JSValue { - return JSValue.jsBoolean(this.status == .connected); -} - -pub fn doClose(this: *MySQLConnection, globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { - _ = globalObject; - this.disconnect(); - this.write_buffer.clearAndFree(bun.default_allocator); - - return .js_undefined; -} - -pub fn constructor(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*MySQLConnection { - _ = callframe; - - return globalObject.throw("MySQLConnection cannot be constructed directly", .{}); -} - -pub fn flushData(this: *@This()) void { +fn flushData(this: *@This()) void { // we know we still have backpressure so just return we will flush later - if (this.flags.has_backpressure) { + if (this.#flags.has_backpressure) { debug("flushData: has backpressure", .{}); return; } - const chunk = this.write_buffer.remaining(); + const chunk = this.#write_buffer.remaining(); if (chunk.len == 0) { - debug("flushData: no data to flush", .{}); return; } - const wrote = this.socket.write(chunk); - this.flags.has_backpressure = wrote < chunk.len; + const wrote = this.#socket.write(chunk); + this.#flags.has_backpressure = wrote < chunk.len; debug("flushData: wrote {d}/{d} bytes", .{ wrote, chunk.len }); if (wrote > 0) { SocketMonitor.write(chunk[0..@intCast(wrote)]); - this.write_buffer.consume(@intCast(wrote)); + this.#write_buffer.consume(@intCast(wrote)); } } - -pub fn stopTimers(this: *@This()) void { - if (this.timer.state == .ACTIVE) { - this.vm.timer.remove(&this.timer); - } - if (this.max_lifetime_timer.state == .ACTIVE) { - this.vm.timer.remove(&this.max_lifetime_timer); - } +pub fn close(this: *@This()) void { + this.#socket.close(); + this.#write_buffer.clearAndFree(bun.default_allocator); } - -pub fn getQueriesArray(this: *const @This()) JSValue { - return js.queriesGetCached(this.js_value) orelse .js_undefined; -} -pub fn failFmt(this: *@This(), error_code: AnyMySQLError.Error, comptime fmt: [:0]const u8, args: anytype) void { - const message = bun.handleOom(std.fmt.allocPrint(bun.default_allocator, fmt, args)); - defer bun.default_allocator.free(message); - - const err = AnyMySQLError.mysqlErrorToJS(this.globalObject, message, error_code); - this.failWithJSValue(err); -} -pub fn failWithJSValue(this: *MySQLConnection, value: JSValue) void { - defer this.updateHasPendingActivity(); - this.stopTimers(); - if (this.status == .failed) return; - this.setStatus(.failed); - - this.ref(); - defer this.deref(); - // we defer the refAndClose so the on_close will be called first before we reject the pending requests - defer this.refAndClose(value); - const on_close = this.consumeOnCloseCallback(this.globalObject) orelse return; - - const loop = this.vm.eventLoop(); - loop.enter(); - defer loop.exit(); - _ = on_close.call( - this.globalObject, - this.js_value, - &[_]JSValue{ - value, - this.getQueriesArray(), - }, - ) catch |e| this.globalObject.reportActiveExceptionAsUnhandled(e); -} - -pub fn fail(this: *MySQLConnection, message: []const u8, err: AnyMySQLError.Error) void { - debug("failed: {s}: {s}", .{ message, @errorName(err) }); - const instance = AnyMySQLError.mysqlErrorToJS(this.globalObject, message, err); - this.failWithJSValue(instance); -} - -pub fn onClose(this: *MySQLConnection) void { - var vm = this.vm; - defer vm.drainMicrotasks(); - this.fail("Connection closed", error.ConnectionClosed); -} - -fn refAndClose(this: *@This(), js_reason: ?jsc.JSValue) void { - // refAndClose is always called when we wanna to disconnect or when we are closed - - if (!this.socket.isClosed()) { - // event loop need to be alive to close the socket - this.poll_ref.ref(this.vm); - // will unref on socket close - this.socket.close(); - } - +pub fn cleanQueueAndClose(this: *@This(), js_reason: ?jsc.JSValue, js_queries_array: JSValue) void { // cleanup requests - this.cleanUpRequests(js_reason); + this.queue.clean( + js_reason, + if (js_queries_array != .zero) js_queries_array else .js_undefined, + ); + + this.close(); } -pub fn disconnect(this: *@This()) void { - this.stopTimers(); - if (this.status == .connected) { - this.setStatus(.disconnected); - this.poll_ref.disable(); +pub fn cleanup(this: *MySQLConnection) void { + var queue = this.queue; + defer queue.deinit(); + this.queue = MySQLRequestQueue.init(); + var write_buffer = this.#write_buffer; + var read_buffer = this.#read_buffer; + var statements = this.statements; + var tls_config = this.#tls_config; + const options_buf = this.#options_buf; + this.#write_buffer = .{}; + this.#read_buffer = .{}; + this.statements = PreparedStatementsMap{}; + this.#tls_config = .{}; + this.#options_buf = ""; + write_buffer.deinit(bun.default_allocator); - const requests = this.requests.readableSlice(0); - this.requests.head = 0; - this.requests.count = 0; + read_buffer.deinit(bun.default_allocator); - // Fail any pending requests - for (requests) |request| { - this.finishRequest(request); - request.onError(.{ - .error_code = 2013, // CR_SERVER_LOST - .error_message = .{ .temporary = "Lost connection to MySQL server" }, - }, this.globalObject); - } - - this.socket.close(); + var iter = statements.valueIterator(); + while (iter.next()) |statement| { + var stmt = statement.*; + stmt.deref(); } -} + statements.deinit(bun.default_allocator); -fn finishRequest(this: *@This(), item: *MySQLQuery) void { - switch (item.status) { - .running, .binding, .partial_response => { - if (item.flags.simple) { - this.nonpipelinable_requests -= 1; - } else if (item.flags.pipelined) { - this.pipelined_requests -= 1; - } - }, - .success, .fail, .pending => { - if (this.flags.waiting_to_prepare) { - this.flags.waiting_to_prepare = false; - } - }, - } -} - -fn current(this: *@This()) ?*MySQLQuery { - if (this.requests.readableLength() == 0) { - return null; - } - - return this.requests.peekItem(0); -} - -pub fn canExecuteQuery(this: *@This()) bool { - if (this.status != .connected) return false; - return this.flags.is_ready_for_query and this.current() == null; -} -pub fn canPrepareQuery(this: *@This()) bool { - return this.flags.is_ready_for_query and !this.flags.waiting_to_prepare and this.pipelined_requests == 0; -} - -fn cleanUpRequests(this: *@This(), js_reason: ?jsc.JSValue) void { - while (this.current()) |request| { - switch (request.status) { - // pending we will fail the request and the stmt will be marked as error ConnectionClosed too - .pending => { - const stmt = request.statement orelse continue; - stmt.status = .failed; - if (!this.vm.isShuttingDown()) { - if (js_reason) |reason| { - request.onJSError(reason, this.globalObject); - } else { - request.onError(.{ - .error_code = 2013, - .error_message = .{ .temporary = "Connection closed" }, - }, this.globalObject); - } - } - }, - // in the middle of running - .binding, - .running, - .partial_response, - => { - this.finishRequest(request); - if (!this.vm.isShuttingDown()) { - if (js_reason) |reason| { - request.onJSError(reason, this.globalObject); - } else { - request.onError(.{ - .error_code = 2013, - .error_message = .{ .temporary = "Connection closed" }, - }, this.globalObject); - } - } - }, - // just ignore success and fail cases - .success, .fail => {}, - } - request.deref(); - this.requests.discard(1); - } -} -fn advance(this: *@This()) void { - var offset: usize = 0; - debug("advance", .{}); - defer { - while (this.requests.readableLength() > 0) { - const result = this.requests.peekItem(0); - // An item may be in the success or failed state and still be inside the queue (see deinit later comments) - // so we do the cleanup her - switch (result.status) { - .success => { - result.deref(); - this.requests.discard(1); - continue; - }, - .fail => { - result.deref(); - this.requests.discard(1); - continue; - }, - else => break, // trully current item - } - } - } - - while (this.requests.readableLength() > offset and !this.flags.has_backpressure) { - if (this.vm.isShuttingDown()) return this.close(); - var req: *MySQLQuery = this.requests.peekItem(offset); - switch (req.status) { - .pending => { - if (req.flags.simple) { - if (this.pipelined_requests > 0 or !this.flags.is_ready_for_query) { - debug("cannot execute simple query, pipelined_requests: {d}, is_ready_for_query: {}", .{ this.pipelined_requests, this.flags.is_ready_for_query }); - // need to wait for the previous request to finish before starting simple queries - return; - } - - var query_str = req.query.toUTF8(bun.default_allocator); - defer query_str.deinit(); - - debug("execute simple query: {d} {s}", .{ this.sequence_id, query_str.slice() }); - - MySQLRequest.executeQuery(query_str.slice(), MySQLConnection.Writer, this.writer()) catch |err| { - if (this.globalObject.tryTakeException()) |err_| { - req.onJSError(err_, this.globalObject); - } else { - req.onWriteFail(err, this.globalObject, this.getQueriesArray()); - } - if (offset == 0) { - req.deref(); - this.requests.discard(1); - } else { - // deinit later - req.status = .fail; - } - debug("executeQuery failed: {s}", .{@errorName(err)}); - offset += 1; - continue; - }; - this.nonpipelinable_requests += 1; - this.flags.is_ready_for_query = false; - req.status = .running; - this.flushDataAndResetTimeout(); - return; - } else { - if (req.statement) |statement| { - switch (statement.status) { - .failed => { - debug("stmt failed", .{}); - req.onError(statement.error_response, this.globalObject); - if (offset == 0) { - req.deref(); - this.requests.discard(1); - } else { - // deinit later - req.status = .fail; - offset += 1; - } - continue; - }, - .prepared => { - req.bindAndExecute(this.writer(), statement, this.globalObject) catch |err| { - if (this.globalObject.tryTakeException()) |err_| { - req.onJSError(err_, this.globalObject); - } else { - req.onWriteFail(err, this.globalObject, this.getQueriesArray()); - } - if (offset == 0) { - req.deref(); - this.requests.discard(1); - } else { - // deinit later - req.status = .fail; - offset += 1; - } - debug("executeQuery failed: {s}", .{@errorName(err)}); - continue; - }; - - req.flags.pipelined = true; - this.pipelined_requests += 1; - this.flags.is_ready_for_query = false; - this.flushDataAndResetTimeout(); - if (this.flags.use_unnamed_prepared_statements or !this.canPipeline()) { - debug("cannot pipeline more stmt", .{}); - return; - } - offset += 1; - continue; - }, - .pending => { - if (!this.canPrepareQuery()) { - debug("need to wait to finish the pipeline before starting a new query preparation", .{}); - // need to wait to finish the pipeline before starting a new query preparation - return; - } - // We're waiting for prepare response - req.statement.?.status = .parsing; - var query_str = req.query.toUTF8(bun.default_allocator); - defer query_str.deinit(); - MySQLRequest.prepareRequest(query_str.slice(), Writer, this.writer()) catch |err| { - if (this.globalObject.tryTakeException()) |err_| { - req.onJSError(err_, this.globalObject); - } else { - req.onWriteFail(err, this.globalObject, this.getQueriesArray()); - } - if (offset == 0) { - req.deref(); - this.requests.discard(1); - } else { - // deinit later - req.status = .fail; - offset += 1; - } - debug("executeQuery failed: {s}", .{@errorName(err)}); - continue; - }; - this.flags.waiting_to_prepare = true; - this.flags.is_ready_for_query = false; - this.flushDataAndResetTimeout(); - return; - }, - .parsing => { - // we are still parsing, lets wait for it to be prepared or failed - offset += 1; - continue; - }, - } - } - } - }, - .binding, .running, .partial_response => { - const total_requests_running = this.pipelined_requests + this.nonpipelinable_requests; - if (offset < total_requests_running) { - offset += total_requests_running; - } else { - offset += 1; - } - continue; - }, - .success => { - if (offset > 0) { - // deinit later - req.status = .fail; - offset += 1; - continue; - } - req.deref(); - this.requests.discard(1); - continue; - }, - .fail => { - if (offset > 0) { - // deinit later - offset += 1; - continue; - } - req.deref(); - this.requests.discard(1); - continue; - }, - } - } -} - -fn SocketHandler(comptime ssl: bool) type { - return struct { - const SocketType = uws.NewSocketHandler(ssl); - fn _socket(s: SocketType) Socket { - if (comptime ssl) { - return Socket{ .SocketTLS = s }; - } - - return Socket{ .SocketTCP = s }; - } - pub fn onOpen(this: *MySQLConnection, socket: SocketType) void { - this.onOpen(_socket(socket)); - } - - fn onHandshake_(this: *MySQLConnection, _: anytype, success: i32, ssl_error: uws.us_bun_verify_error_t) void { - this.onHandshake(success, ssl_error); - } - - pub const onHandshake = if (ssl) onHandshake_ else null; - - pub fn onClose(this: *MySQLConnection, socket: SocketType, _: i32, _: ?*anyopaque) void { - _ = socket; - this.onClose(); - } - - pub fn onEnd(this: *MySQLConnection, socket: SocketType) void { - _ = socket; - this.onClose(); - } - - pub fn onConnectError(this: *MySQLConnection, socket: SocketType, _: i32) void { - _ = socket; - this.onClose(); - } - - pub fn onTimeout(this: *MySQLConnection, socket: SocketType) void { - _ = socket; - this.onTimeout(); - } - - pub fn onData(this: *MySQLConnection, socket: SocketType, data: []const u8) void { - _ = socket; - this.onData(data); - } - - pub fn onWritable(this: *MySQLConnection, socket: SocketType) void { - _ = socket; - this.onDrain(); - } - }; -} - -pub fn onTimeout(this: *MySQLConnection) void { - this.fail("Connection timed out", error.ConnectionTimedOut); -} - -pub fn onDrain(this: *MySQLConnection) void { - debug("onDrain", .{}); - this.flags.has_backpressure = false; - this.drainInternal(); -} - -pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { - var vm = globalObject.bunVM(); - const arguments = callframe.arguments(); - const hostname_str = try arguments[0].toBunString(globalObject); - defer hostname_str.deref(); - const port = try arguments[1].coerce(i32, globalObject); - - const username_str = try arguments[2].toBunString(globalObject); - defer username_str.deref(); - const password_str = try arguments[3].toBunString(globalObject); - defer password_str.deref(); - const database_str = try arguments[4].toBunString(globalObject); - defer database_str.deref(); - // TODO: update this to match MySQL. - const ssl_mode: SSLMode = switch (arguments[5].toInt32()) { - 0 => .disable, - 1 => .prefer, - 2 => .require, - 3 => .verify_ca, - 4 => .verify_full, - else => .disable, - }; - - const tls_object = arguments[6]; - - var tls_config: jsc.API.ServerConfig.SSLConfig = .{}; - var tls_ctx: ?*uws.SocketContext = null; - if (ssl_mode != .disable) { - tls_config = if (tls_object.isBoolean() and tls_object.toBoolean()) - .{} - else if (tls_object.isObject()) - (jsc.API.ServerConfig.SSLConfig.fromJS(vm, globalObject, tls_object) catch return .zero) orelse .{} - else { - return globalObject.throwInvalidArguments("tls must be a boolean or an object", .{}); - }; - - if (globalObject.hasException()) { - tls_config.deinit(); - return .zero; - } - - // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - const original_reject_unauthorized = tls_config.reject_unauthorized; - tls_config.reject_unauthorized = 0; - tls_config.request_cert = 1; - - // We create it right here so we can throw errors early. - const context_options = tls_config.asUSockets(); - var err: uws.create_bun_socket_error_t = .none; - tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*@This()), context_options, &err) orelse { - if (err != .none) { - return globalObject.throw("failed to create TLS context", .{}); - } else { - return globalObject.throwValue(err.toJS(globalObject)); - } - }; - - // restore the original reject_unauthorized - tls_config.reject_unauthorized = original_reject_unauthorized; - if (err != .none) { - tls_config.deinit(); - if (tls_ctx) |ctx| { - ctx.deinit(true); - } - return globalObject.throwValue(err.toJS(globalObject)); - } - - debug("configured TLS context", .{}); - uws.NewSocketHandler(true).configure(tls_ctx.?, true, *@This(), SocketHandler(true)); - } - - var username: []const u8 = ""; - var password: []const u8 = ""; - var database: []const u8 = ""; - var options: []const u8 = ""; - var path: []const u8 = ""; - - const options_str = try arguments[7].toBunString(globalObject); - defer options_str.deref(); - - const path_str = try arguments[8].toBunString(globalObject); - defer path_str.deref(); - - const options_buf: []u8 = brk: { - var b = bun.StringBuilder{}; - b.cap += username_str.utf8ByteLength() + 1 + password_str.utf8ByteLength() + 1 + database_str.utf8ByteLength() + 1 + options_str.utf8ByteLength() + 1 + path_str.utf8ByteLength() + 1; - - b.allocate(bun.default_allocator) catch {}; - var u = username_str.toUTF8WithoutRef(bun.default_allocator); - defer u.deinit(); - username = b.append(u.slice()); - - var p = password_str.toUTF8WithoutRef(bun.default_allocator); - defer p.deinit(); - password = b.append(p.slice()); - - var d = database_str.toUTF8WithoutRef(bun.default_allocator); - defer d.deinit(); - database = b.append(d.slice()); - - var o = options_str.toUTF8WithoutRef(bun.default_allocator); - defer o.deinit(); - options = b.append(o.slice()); - - var _path = path_str.toUTF8WithoutRef(bun.default_allocator); - defer _path.deinit(); - path = b.append(_path.slice()); - - break :brk b.allocatedSlice(); - }; - - const on_connect = arguments[9]; - const on_close = arguments[10]; - const idle_timeout = arguments[11].toInt32(); - const connection_timeout = arguments[12].toInt32(); - const max_lifetime = arguments[13].toInt32(); - const use_unnamed_prepared_statements = arguments[14].asBoolean(); - - var ptr = try bun.default_allocator.create(MySQLConnection); - - ptr.* = MySQLConnection{ - .globalObject = globalObject, - .vm = vm, - .database = database, - .user = username, - .password = password, - .options = options, - .options_buf = options_buf, - .socket = .{ .SocketTCP = .{ .socket = .{ .detached = {} } } }, - .requests = Queue.init(bun.default_allocator), - .statements = PreparedStatementsMap{}, - .tls_config = tls_config, - .tls_ctx = tls_ctx, - .ssl_mode = ssl_mode, - .tls_status = if (ssl_mode != .disable) .pending else .none, - .idle_timeout_interval_ms = @intCast(idle_timeout), - .connection_timeout_ms = @intCast(connection_timeout), - .max_lifetime_interval_ms = @intCast(max_lifetime), - .character_set = CharacterSet.default, - .flags = .{ - .use_unnamed_prepared_statements = use_unnamed_prepared_statements, - }, - }; - - { - const hostname = hostname_str.toUTF8(bun.default_allocator); - defer hostname.deinit(); - - const ctx = vm.rareData().mysql_context.tcp orelse brk: { - const ctx_ = uws.SocketContext.createNoSSLContext(vm.uwsLoop(), @sizeOf(*@This())).?; - uws.NewSocketHandler(false).configure(ctx_, true, *@This(), SocketHandler(false)); - vm.rareData().mysql_context.tcp = ctx_; - break :brk ctx_; - }; - - if (path.len > 0) { - debug("connecting to mysql with path", .{}); - ptr.socket = .{ - .SocketTCP = uws.SocketTCP.connectUnixAnon(path, ctx, ptr, false) catch |err| { - tls_config.deinit(); - if (tls_ctx) |tls| { - tls.deinit(true); - } - ptr.deinit(); - return globalObject.throwError(err, "failed to connect to postgresql"); - }, - }; - } else { - debug("connecting to mysql with hostname", .{}); - ptr.socket = .{ - .SocketTCP = uws.SocketTCP.connectAnon(hostname.slice(), port, ctx, ptr, false) catch |err| { - tls_config.deinit(); - if (tls_ctx) |tls| { - tls.deinit(true); - } - ptr.deinit(); - return globalObject.throwError(err, "failed to connect to mysql"); - }, - }; - } - } - ptr.setStatus(.connecting); - ptr.updateHasPendingActivity(); - ptr.resetConnectionTimeout(); - ptr.poll_ref.ref(vm); - const js_value = ptr.toJS(globalObject); - js_value.ensureStillAlive(); - ptr.js_value = js_value; - js.onconnectSetCached(js_value, globalObject, on_connect); - js.oncloseSetCached(js_value, globalObject, on_close); - - return js_value; -} - -pub fn deinit(this: *MySQLConnection) void { - this.disconnect(); - this.stopTimers(); - debug("MySQLConnection deinit", .{}); - - var requests = this.requests; - defer requests.deinit(); - this.requests = Queue.init(bun.default_allocator); - - // Clear any pending requests first - for (requests.readableSlice(0)) |request| { - this.finishRequest(request); - request.onError(.{ - .error_code = 2013, - .error_message = .{ .temporary = "Connection closed" }, - }, this.globalObject); - } - this.write_buffer.deinit(bun.default_allocator); - this.read_buffer.deinit(bun.default_allocator); - this.statements.deinit(bun.default_allocator); - this.auth_data.deinit(); - this.tls_config.deinit(); - if (this.tls_ctx) |ctx| { + tls_config.deinit(); + this.#auth_data.deinit(); + if (this.#tls_ctx) |ctx| { + this.#tls_ctx = null; ctx.deinit(true); } - bun.default_allocator.free(this.options_buf); - bun.default_allocator.destroy(this); + + if (options_buf.len > 0) { + bun.default_allocator.free(options_buf); + } } -pub fn upgradeToTLS(this: *MySQLConnection) void { - if (this.socket == .SocketTCP) { - const new_socket = this.socket.SocketTCP.socket.connected.upgrade(this.tls_ctx.?, this.tls_config.server_name) orelse { - this.fail("Failed to upgrade to TLS", error.AuthenticationFailed); - return; +pub fn upgradeToTLS(this: *MySQLConnection) !void { + if (this.#socket == .SocketTCP) { + const new_socket = this.#socket.SocketTCP.socket.connected.upgrade(this.#tls_ctx.?, this.#tls_config.server_name) orelse { + return error.AuthenticationFailed; }; - this.socket = .{ + this.#socket = .{ .SocketTLS = .{ .socket = .{ .connected = new_socket, @@ -995,43 +214,43 @@ pub fn upgradeToTLS(this: *MySQLConnection) void { } } -pub fn onOpen(this: *MySQLConnection, socket: Socket) void { - debug("onOpen", .{}); - this.setupMaxLifetimeTimerIfNecessary(); - this.resetConnectionTimeout(); - this.socket = socket; - if (socket == .SocketTCP) { - // when upgrading to TLS the onOpen callback will be called again and at this moment we dont wanna to change the status to handshaking - this.setStatus(.handshaking); - } - this.poll_ref.ref(this.vm); - this.updateHasPendingActivity(); +pub fn setSocket(this: *MySQLConnection, socket: uws.AnySocket) void { + this.#socket = socket; } +pub fn isActive(this: *MySQLConnection) bool { + if (this.status == .disconnected or this.status == .failed) { + return false; + } -pub fn onHandshake(this: *MySQLConnection, success: i32, ssl_error: uws.us_bun_verify_error_t) void { - debug("onHandshake: {d} {d} {s}", .{ success, ssl_error.error_no, @tagName(this.ssl_mode) }); + // if is connected or connecting we keep alive until idle timeout is reached + return true; +} +pub inline fn isConnected(this: *MySQLConnection) bool { + return this.status == .connected; +} +pub fn doHandshake(this: *MySQLConnection, success: i32, ssl_error: uws.us_bun_verify_error_t) !bool { + debug("onHandshake: {d} {d} {s}", .{ success, ssl_error.error_no, @tagName(this.#ssl_mode) }); const handshake_success = if (success == 1) true else false; - this.sequence_id = this.sequence_id +% 1; + this.#sequence_id = this.#sequence_id +% 1; if (handshake_success) { - this.tls_status = .ssl_ok; - if (this.tls_config.reject_unauthorized != 0) { + this.#tls_status = .ssl_ok; + if (this.#tls_config.reject_unauthorized != 0) { // follow the same rules as postgres // https://github.com/porsager/postgres/blob/6ec85a432b17661ccacbdf7f765c651e88969d36/src/connection.js#L272-L279 // only reject the connection if reject_unauthorized == true - switch (this.ssl_mode) { + switch (this.#ssl_mode) { .verify_ca, .verify_full => { if (ssl_error.error_no != 0) { - this.tls_status = .ssl_failed; - this.failWithJSValue(ssl_error.toJS(this.globalObject)); - return; + this.#tls_status = .ssl_failed; + return false; } - const ssl_ptr: *BoringSSL.c.SSL = @ptrCast(this.socket.getNativeHandle()); + const ssl_ptr: *BoringSSL.c.SSL = @ptrCast(this.#socket.getNativeHandle()); if (BoringSSL.c.SSL_get_servername(ssl_ptr, 0)) |servername| { const hostname = servername[0..bun.len(servername)]; if (!BoringSSL.checkServerIdentity(ssl_ptr, hostname)) { - this.tls_status = .ssl_failed; - return this.failWithJSValue(ssl_error.toJS(this.globalObject)); + this.#tls_status = .ssl_failed; + return false; } } }, @@ -1039,43 +258,24 @@ pub fn onHandshake(this: *MySQLConnection, success: i32, ssl_error: uws.us_bun_v .require, .prefer, .disable => {}, } } - this.sendHandshakeResponse() catch |err| this.failFmt(err, "Failed to send handshake response", .{}); - } else { - this.tls_status = .ssl_failed; - // if we are here is because server rejected us, and the error_no is the cause of this - // no matter if reject_unauthorized is false because we are disconnected by the server - this.failWithJSValue(ssl_error.toJS(this.globalObject)); + try this.sendHandshakeResponse(); + return true; } + this.#tls_status = .ssl_failed; + // if we are here is because server rejected us, and the error_no is the cause of this + // no matter if reject_unauthorized is false because we are disconnected by the server + return false; } -pub fn onData(this: *MySQLConnection, data: []const u8) void { - this.ref(); - this.flags.is_processing_data = true; - const vm = this.vm; +pub fn readAndProcessData(this: *MySQLConnection, data: []const u8) !void { + this.#flags.is_processing_data = true; + defer this.#flags.is_processing_data = false; // Clear the timeout. - this.socket.setTimeout(0); - - defer { - if (this.status == .connected and this.requests.readableLength() == 0 and this.write_buffer.remaining().len == 0) { - // Don't keep the process alive when there's nothixng to do. - this.poll_ref.unref(vm); - } else if (this.status == .connected) { - // Keep the process alive if there's something to do. - this.poll_ref.ref(vm); - } - // reset the connection timeout after we're done processing the data - this.flags.is_processing_data = false; - this.resetConnectionTimeout(); - this.deref(); - } - - const event_loop = vm.eventLoop(); - event_loop.enter(); - defer event_loop.exit(); + this.#socket.setTimeout(0); SocketMonitor.read(data); - if (this.read_buffer.remaining().len == 0) { + if (this.#read_buffer.remaining().len == 0) { var consumed: usize = 0; var offset: usize = 0; const reader = StackReader.init(data, &consumed, &offset); @@ -1090,24 +290,24 @@ pub fn onData(this: *MySQLConnection, data: []const u8) void { }); } - this.read_buffer.head = 0; - this.last_message_start = 0; - this.read_buffer.byte_list.len = 0; - this.read_buffer.write(bun.default_allocator, data[offset..]) catch @panic("failed to write to read buffer"); + this.#read_buffer.head = 0; + this.#last_message_start = 0; + this.#read_buffer.byte_list.len = 0; + this.#read_buffer.write(bun.default_allocator, data[offset..]) catch @panic("failed to write to read buffer"); } else { if (comptime bun.Environment.allow_assert) { bun.handleErrorReturnTrace(err, @errorReturnTrace()); } - this.fail("Failed to read data", err); + return err; } }; return; } { - this.read_buffer.head = this.last_message_start; + this.#read_buffer.head = this.#last_message_start; - this.read_buffer.write(bun.default_allocator, data) catch @panic("failed to write to read buffer"); + this.#read_buffer.write(bun.default_allocator, data) catch @panic("failed to write to read buffer"); this.processPackets(Reader, this.bufferedReader()) catch |err| { debug("processPackets with buffer: {s}", .{@errorName(err)}); if (err != error.ShortRead) { @@ -1116,23 +316,22 @@ pub fn onData(this: *MySQLConnection, data: []const u8) void { debug("Error: {s}\n{}", .{ @errorName(err), trace }); } } - this.fail("Failed to read data", err); - return; + return err; } if (comptime bun.Environment.allow_assert) { debug("Received short read: last_message_start: {d}, head: {d}, len: {d}", .{ - this.last_message_start, - this.read_buffer.head, - this.read_buffer.byte_list.len, + this.#last_message_start, + this.#read_buffer.head, + this.#read_buffer.byte_list.len, }); } return; }; - this.last_message_start = 0; - this.read_buffer.head = 0; + this.#last_message_start = 0; + this.#read_buffer.head = 0; } } @@ -1143,15 +342,16 @@ pub fn processPackets(this: *MySQLConnection, comptime Context: type, reader: Ne // Read packet header const header = PacketHeader.decode(reader.peek()) orelse return AnyMySQLError.Error.ShortRead; const header_length = header.length; - debug("sequence_id: {d} header: {d}", .{ this.sequence_id, header_length }); + const packet_length: usize = header_length + PacketHeader.size; + debug("sequence_id: {d} header: {d}", .{ this.#sequence_id, header_length }); // Ensure we have the full packet - reader.ensureCapacity(header_length + PacketHeader.size) catch return AnyMySQLError.Error.ShortRead; + reader.ensureCapacity(packet_length) catch return AnyMySQLError.Error.ShortRead; // always skip the full packet, we dont care about padding or unreaded bytes - defer reader.setOffsetFromStart(header_length + PacketHeader.size); + defer reader.setOffsetFromStart(packet_length); reader.skip(PacketHeader.size); // Update sequence id - this.sequence_id = header.sequence_id +% 1; + this.#sequence_id = header.sequence_id +% 1; // Process packet based on connection state switch (this.status) { @@ -1172,14 +372,14 @@ pub fn handleHandshake(this: *MySQLConnection, comptime Context: type, reader: N defer handshake.deinit(); // Store server info - this.server_version = try handshake.server_version.toOwned(); - this.connection_id = handshake.connection_id; + this.#server_version = try handshake.server_version.toOwned(); + this.#connection_id = handshake.connection_id; // this.capabilities = handshake.capability_flags; - this.capabilities = Capabilities.getDefaultCapabilities(this.ssl_mode != .disable, this.database.len > 0); + this.#capabilities = Capabilities.getDefaultCapabilities(this.#ssl_mode != .disable, this.#database.len > 0); // Override with utf8mb4 instead of using server's default - this.character_set = CharacterSet.default; - this.status_flags = handshake.status_flags; + this.#character_set = CharacterSet.default; + this.#status_flags = handshake.status_flags; debug( \\Handshake @@ -1190,27 +390,26 @@ pub fn handleHandshake(this: *MySQLConnection, comptime Context: type, reader: N \\ Status Flags: [ {} ] \\ , .{ - this.server_version.slice(), - this.connection_id, - this.character_set, - this.character_set.label(), - this.capabilities, - this.capabilities.toInt(), - this.status_flags, + this.#server_version.slice(), + this.#connection_id, + this.#character_set, + this.#character_set.label(), + this.#capabilities, + this.#capabilities.toInt(), + this.#status_flags, }); - this.auth_data.clearAndFree(); + this.#auth_data.clearAndFree(); // Store auth data - try this.auth_data.ensureTotalCapacity(handshake.auth_plugin_data_part_1.len + handshake.auth_plugin_data_part_2.len); - try this.auth_data.appendSlice(handshake.auth_plugin_data_part_1[0..]); - try this.auth_data.appendSlice(handshake.auth_plugin_data_part_2[0..]); + try this.#auth_data.ensureTotalCapacity(handshake.auth_plugin_data_part_1.len + handshake.auth_plugin_data_part_2.len); + try this.#auth_data.appendSlice(handshake.auth_plugin_data_part_1[0..]); + try this.#auth_data.appendSlice(handshake.auth_plugin_data_part_2[0..]); // Get auth plugin if (handshake.auth_plugin_name.slice().len > 0) { - this.auth_plugin = AuthMethod.fromString(handshake.auth_plugin_name.slice()) orelse { - this.fail("Unsupported auth plugin", error.UnsupportedAuthPlugin); - return; + this.#auth_plugin = AuthMethod.fromString(handshake.auth_plugin_name.slice()) orelse { + return error.UnsupportedAuthPlugin; }; } @@ -1218,9 +417,9 @@ pub fn handleHandshake(this: *MySQLConnection, comptime Context: type, reader: N this.setStatus(.authenticating); // https://dev.mysql.com/doc/dev/mysql-server/8.4.6/page_protocol_connection_phase_packets_protocol_ssl_request.html - if (this.capabilities.CLIENT_SSL) { + if (this.#capabilities.CLIENT_SSL) { var response = SSLRequest{ - .capability_flags = this.capabilities, + .capability_flags = this.#capabilities, .max_packet_size = 0, //16777216, .character_set = CharacterSet.default, // bun always send connection attributes @@ -1228,20 +427,20 @@ pub fn handleHandshake(this: *MySQLConnection, comptime Context: type, reader: N }; defer response.deinit(); try response.write(this.writer()); - this.capabilities = response.capability_flags; - this.tls_status = .message_sent; + this.#capabilities = response.capability_flags; + this.#tls_status = .message_sent; this.flushData(); - if (!this.flags.has_backpressure) { - this.upgradeToTLS(); + if (!this.#flags.has_backpressure) { + try this.upgradeToTLS(); } return; } - if (this.tls_status != .none) { - this.tls_status = .ssl_not_available; + if (this.#tls_status != .none) { + this.#tls_status = .ssl_not_available; - switch (this.ssl_mode) { + switch (this.#ssl_mode) { .verify_ca, .verify_full => { - return this.failFmt(error.AuthenticationFailed, "SSL is not available", .{}); + return error.AuthenticationFailed; }, // require is the same as prefer .require, .prefer, .disable => {}, @@ -1259,60 +458,28 @@ fn handleHandshakeDecodePublicKey(this: *MySQLConnection, comptime Context: type this.setStatus(.authenticating); var encrypted_password = Auth.caching_sha2_password.EncryptedPassword{ - .password = this.password, + .password = this.#password, .public_key = response.data.slice(), - .nonce = this.auth_data.items, - .sequence_id = this.sequence_id, + .nonce = this.#auth_data.items, + .sequence_id = this.#sequence_id, }; try encrypted_password.write(this.writer()); this.flushData(); } -pub fn consumeOnConnectCallback(this: *const @This(), globalObject: *jsc.JSGlobalObject) ?jsc.JSValue { - debug("consumeOnConnectCallback", .{}); - const on_connect = js.onconnectGetCached(this.js_value) orelse return null; - debug("consumeOnConnectCallback exists", .{}); - - js.onconnectSetCached(this.js_value, globalObject, .zero); - return on_connect; -} - -pub fn consumeOnCloseCallback(this: *const @This(), globalObject: *jsc.JSGlobalObject) ?jsc.JSValue { - debug("consumeOnCloseCallback", .{}); - const on_close = js.oncloseGetCached(this.js_value) orelse return null; - debug("consumeOnCloseCallback exists", .{}); - js.oncloseSetCached(this.js_value, globalObject, .zero); - return on_close; -} - pub fn setStatus(this: *@This(), status: ConnectionState) void { if (this.status == status) return; - defer this.updateHasPendingActivity(); this.status = status; - this.resetConnectionTimeout(); - if (this.vm.isShuttingDown()) return; switch (status) { .connected => { - const on_connect = this.consumeOnConnectCallback(this.globalObject) orelse return; - const js_value = this.js_value; - js_value.ensureStillAlive(); - this.globalObject.queueMicrotask(on_connect, &[_]JSValue{ JSValue.jsNull(), js_value }); - this.poll_ref.unref(this.vm); + this.getJSConnection().onConnectionEstabilished(); }, else => {}, } } -pub fn updateRef(this: *@This()) void { - this.updateHasPendingActivity(); - if (this.pending_activity_count.raw > 0) { - this.poll_ref.ref(this.vm); - } else { - this.poll_ref.unref(this.vm); - } -} pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewReader(Context), header_length: u24) !void { const first_byte = try reader.int(u8); reader.skip(-1); @@ -1328,12 +495,12 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea defer ok.deinit(); this.setStatus(.connected); - defer this.updateRef(); - this.status_flags = ok.status_flags; - this.flags.is_ready_for_query = true; - this.advance(); - this.registerAutoFlusher(); + this.#status_flags = ok.status_flags; + this.#flags.is_ready_for_query = true; + const connection = this.getJSConnection(); + this.queue.markAsReadyForQuery(); + this.queue.advance(connection); }, @intFromEnum(PacketType.ERROR) => { @@ -1341,13 +508,14 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea try err.decode(reader); defer err.deinit(); - this.failWithJSValue(err.toJS(this.globalObject)); + const connection = this.getJSConnection(); + connection.onErrorPacket(null, err); return error.AuthenticationFailed; }, @intFromEnum(PacketType.MORE_DATA) => { // Handle various MORE_DATA cases - if (this.auth_plugin) |plugin| { + if (this.#auth_plugin) |plugin| { switch (plugin) { .sha256_password, .caching_sha2_password => { reader.skip(1); @@ -1364,19 +532,19 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea .success => { debug("success auth", .{}); this.setStatus(.connected); - defer this.updateRef(); - this.flags.is_ready_for_query = true; - this.advance(); - this.registerAutoFlusher(); + + this.#flags.is_ready_for_query = true; + this.queue.markAsReadyForQuery(); + this.queue.advance(this.getJSConnection()); }, .continue_auth => { debug("continue auth", .{}); - if (this.ssl_mode == .disable) { + if (this.#ssl_mode == .disable) { // we are in plain TCP so we need to request the public key this.setStatus(.authentication_awaiting_pk); debug("awaiting public key", .{}); - var packet = try this.writer().start(this.sequence_id); + var packet = try this.writer().start(this.#sequence_id); var request = Auth.caching_sha2_password.PublicKeyRequest{}; try request.write(this.writer()); @@ -1385,14 +553,14 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea } else { debug("sending password TLS enabled", .{}); // SSL mode is enabled, send password as is - var packet = try this.writer().start(this.sequence_id); - try this.writer().writeZ(this.password); + var packet = try this.writer().start(this.#sequence_id); + try this.writer().writeZ(this.#password); try packet.end(); this.flushData(); } }, else => { - this.fail("Authentication failed", error.AuthenticationFailed); + return error.AuthenticationFailed; }, } }, @@ -1410,8 +578,7 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea defer infile.deinit(); // We don't support LOCAL INFILE for security reasons - this.fail("LOCAL INFILE not supported", error.LocalInfileNotSupported); - return; + return error.LocalInfileNotSupported; } else { debug("Received auth continuation without plugin", .{}); return error.UnexpectedPacket; @@ -1427,13 +594,12 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea // Update auth plugin and data const auth_method = AuthMethod.fromString(auth_switch.plugin_name.slice()) orelse { - this.fail("Unsupported auth plugin", error.UnsupportedAuthPlugin); - return; + return error.UnsupportedAuthPlugin; }; const auth_data = auth_switch.plugin_data.slice(); - this.auth_plugin = auth_method; - this.auth_data.clearRetainingCapacity(); - try this.auth_data.appendSlice(auth_data); + this.#auth_plugin = auth_method; + this.#auth_data.clearRetainingCapacity(); + try this.#auth_data.appendSlice(auth_data); // Send new auth response try this.sendAuthSwitchResponse(auth_method, auth_data); @@ -1448,19 +614,23 @@ pub fn handleAuth(this: *MySQLConnection, comptime Context: type, reader: NewRea pub fn handleCommand(this: *MySQLConnection, comptime Context: type, reader: NewReader(Context), header_length: u24) !void { // Get the current request if any - const request = this.current() orelse { + const request = this.queue.current() orelse { debug("Received unexpected command response", .{}); return error.UnexpectedPacket; }; + request.ref(); + defer request.deref(); debug("handleCommand", .{}); - if (request.flags.simple) { + if (request.isSimple()) { // Regular query response return try this.handleResultSet(Context, reader, header_length); } // Handle based on request type - if (request.statement) |statement| { + if (request.getStatement()) |statement| { + statement.ref(); + defer statement.deref(); switch (statement.status) { .pending => { return error.UnexpectedPacket; @@ -1474,14 +644,14 @@ pub fn handleCommand(this: *MySQLConnection, comptime Context: type, reader: New try this.handleResultSet(Context, reader, header_length); }, .failed => { + const connection = this.getJSConnection(); defer { - this.advance(); - this.registerAutoFlusher(); + this.queue.advance(connection); } - this.flags.is_ready_for_query = true; - this.finishRequest(request); - // Statement failed, clean up - request.onError(statement.error_response, this.globalObject); + this.#flags.is_ready_for_query = true; + this.queue.markAsReadyForQuery(); + this.queue.markCurrentRequestAsFinished(request); + connection.onErrorPacket(request, statement.error_response); }, } } @@ -1490,27 +660,26 @@ pub fn handleCommand(this: *MySQLConnection, comptime Context: type, reader: New pub fn sendHandshakeResponse(this: *MySQLConnection) AnyMySQLError.Error!void { debug("sendHandshakeResponse", .{}); // Only require password for caching_sha2_password when connecting for the first time - if (this.auth_plugin) |plugin| { + if (this.#auth_plugin) |plugin| { const requires_password = switch (plugin) { .caching_sha2_password => false, // Allow empty password, server will handle auth flow .sha256_password => true, // Always requires password .mysql_native_password => false, // Allows empty password }; - if (requires_password and this.password.len == 0) { - this.fail("Password required for authentication", error.PasswordRequired); - return; + if (requires_password and this.#password.len == 0) { + return error.PasswordRequired; } } var response = HandshakeResponse41{ - .capability_flags = this.capabilities, + .capability_flags = this.#capabilities, .max_packet_size = 0, //16777216, .character_set = CharacterSet.default, - .username = .{ .temporary = this.user }, - .database = .{ .temporary = this.database }, + .username = .{ .temporary = this.#user }, + .database = .{ .temporary = this.#database }, .auth_plugin_name = .{ - .temporary = if (this.auth_plugin) |plugin| + .temporary = if (this.#auth_plugin) |plugin| switch (plugin) { .mysql_native_password => "mysql_native_password", .caching_sha2_password => "caching_sha2_password", @@ -1520,7 +689,7 @@ pub fn sendHandshakeResponse(this: *MySQLConnection) AnyMySQLError.Error!void { "", }, .auth_response = .{ .empty = {} }, - .sequence_id = this.sequence_id, + .sequence_id = this.#sequence_id, }; defer response.deinit(); @@ -1530,17 +699,16 @@ pub fn sendHandshakeResponse(this: *MySQLConnection) AnyMySQLError.Error!void { // Generate auth response based on plugin var scrambled_buf: [32]u8 = undefined; - if (this.auth_plugin) |plugin| { - if (this.auth_data.items.len == 0) { - this.fail("Missing auth data from server", error.MissingAuthData); - return; + if (this.#auth_plugin) |plugin| { + if (this.#auth_data.items.len == 0) { + return error.MissingAuthData; } - response.auth_response = .{ .temporary = try plugin.scramble(this.password, this.auth_data.items, &scrambled_buf) }; + response.auth_response = .{ .temporary = try plugin.scramble(this.#password, this.#auth_data.items, &scrambled_buf) }; } response.capability_flags.reject(); try response.write(this.writer()); - this.capabilities = response.capability_flags; + this.#capabilities = response.capability_flags; this.flushData(); } @@ -1551,11 +719,11 @@ pub fn sendAuthSwitchResponse(this: *MySQLConnection, auth_method: AuthMethod, p var scrambled_buf: [32]u8 = undefined; response.auth_response = .{ - .temporary = try auth_method.scramble(this.password, plugin_data, &scrambled_buf), + .temporary = try auth_method.scramble(this.#password, plugin_data, &scrambled_buf), }; var response_writer = this.writer(); - var packet = try response_writer.start(this.sequence_id); + var packet = try response_writer.start(this.#sequence_id); try response.write(response_writer); try packet.end(); this.flushData(); @@ -1565,16 +733,16 @@ pub const Writer = struct { connection: *MySQLConnection, pub fn write(this: Writer, data: []const u8) AnyMySQLError.Error!void { - var buffer = &this.connection.write_buffer; + var buffer = &this.connection.#write_buffer; try buffer.write(bun.default_allocator, data); } pub fn pwrite(this: Writer, data: []const u8, index: usize) AnyMySQLError.Error!void { - @memcpy(this.connection.write_buffer.byte_list.slice()[index..][0..data.len], data); + @memcpy(this.connection.#write_buffer.byte_list.slice()[index..][0..data.len], data); } pub fn offset(this: Writer) usize { - return this.connection.write_buffer.len(); + return this.connection.#write_buffer.len(); } }; @@ -1590,41 +758,41 @@ pub const Reader = struct { connection: *MySQLConnection, pub fn markMessageStart(this: Reader) void { - this.connection.last_message_start = this.connection.read_buffer.head; + this.connection.#last_message_start = this.connection.#read_buffer.head; } pub fn setOffsetFromStart(this: Reader, offset: usize) void { - this.connection.read_buffer.head = this.connection.last_message_start + @as(u32, @truncate(offset)); + this.connection.#read_buffer.head = this.connection.#last_message_start + @as(u32, @truncate(offset)); } pub const ensureLength = ensureCapacity; pub fn peek(this: Reader) []const u8 { - return this.connection.read_buffer.remaining(); + return this.connection.#read_buffer.remaining(); } pub fn skip(this: Reader, count: isize) void { if (count < 0) { const abs_count = @abs(count); - if (abs_count > this.connection.read_buffer.head) { - this.connection.read_buffer.head = 0; + if (abs_count > this.connection.#read_buffer.head) { + this.connection.#read_buffer.head = 0; return; } - this.connection.read_buffer.head -= @intCast(abs_count); + this.connection.#read_buffer.head -= @intCast(abs_count); return; } const ucount: usize = @intCast(count); - if (this.connection.read_buffer.head + ucount > this.connection.read_buffer.byte_list.len) { - this.connection.read_buffer.head = this.connection.read_buffer.byte_list.len; + if (this.connection.#read_buffer.head + ucount > this.connection.#read_buffer.byte_list.len) { + this.connection.#read_buffer.head = this.connection.#read_buffer.byte_list.len; return; } - this.connection.read_buffer.head += @intCast(ucount); + this.connection.#read_buffer.head += @intCast(ucount); } pub fn ensureCapacity(this: Reader, count: usize) bool { - return this.connection.read_buffer.remaining().len >= count; + return this.connection.#read_buffer.remaining().len >= count; } pub fn read(this: Reader, count: usize) AnyMySQLError.Error!Data { @@ -1664,11 +832,12 @@ fn checkIfPreparedStatementIsDone(this: *MySQLConnection, statement: *MySQLState debug("checkIfPreparedStatementIsDone: {d} {d} {d} {d}", .{ statement.columns_received, statement.params_received, statement.columns.len, statement.params.len }); if (statement.columns_received == statement.columns.len and statement.params_received == statement.params.len) { statement.status = .prepared; - this.flags.waiting_to_prepare = false; - this.flags.is_ready_for_query = true; + this.#flags.waiting_to_prepare = false; + this.#flags.is_ready_for_query = true; + this.queue.markAsReadyForQuery(); + this.queue.markAsPrepared(); statement.reset(); - this.advance(); - this.registerAutoFlusher(); + this.queue.advance(this.getJSConnection()); } } @@ -1677,14 +846,18 @@ pub fn handlePreparedStatement(this: *MySQLConnection, comptime Context: type, r const first_byte = try reader.int(u8); reader.skip(-1); - const request = this.current() orelse { + const request = this.queue.current() orelse { debug("Unexpected prepared statement packet missing request", .{}); return error.UnexpectedPacket; }; - const statement = request.statement orelse { + request.ref(); + defer request.deref(); + const statement = request.getStatement() orelse { debug("Unexpected prepared statement packet missing statement", .{}); return error.UnexpectedPacket; }; + statement.ref(); + defer statement.deref(); if (statement.statement_id > 0) { if (statement.params_received < statement.params.len) { var column = ColumnDefinition41{}; @@ -1730,18 +903,21 @@ pub fn handlePreparedStatement(this: *MySQLConnection, comptime Context: type, r }, .ERROR => { + debug("handlePreparedStatement ERROR", .{}); var err = ErrorPacket{}; try err.decode(reader); defer err.deinit(); + const connection = this.getJSConnection(); defer { - this.advance(); - this.registerAutoFlusher(); + this.queue.advance(connection); } - this.flags.is_ready_for_query = true; - this.finishRequest(request); + this.#flags.is_ready_for_query = true; statement.status = .failed; statement.error_response = err; - request.onError(err, this.globalObject); + this.queue.markAsReadyForQuery(); + this.queue.markCurrentRequestAsFinished(request); + + connection.onErrorPacket(request, err); }, else => { @@ -1751,64 +927,76 @@ pub fn handlePreparedStatement(this: *MySQLConnection, comptime Context: type, r } } -fn handleResultSetOK(this: *MySQLConnection, request: *MySQLQuery, statement: *MySQLStatement, status_flags: StatusFlags, last_insert_id: u64, affected_rows: u64) void { - this.status_flags = status_flags; - this.flags.is_ready_for_query = !status_flags.has(.SERVER_MORE_RESULTS_EXISTS); - debug("handleResultSetOK: {d} {}", .{ status_flags.toInt(), status_flags.has(.SERVER_MORE_RESULTS_EXISTS) }); +fn handleResultSetOK(this: *MySQLConnection, request: *JSMySQLQuery, statement: *MySQLStatement, status_flags: StatusFlags, last_insert_id: u64, affected_rows: u64) void { + this.#status_flags = status_flags; + const is_last_result = !status_flags.has(.SERVER_MORE_RESULTS_EXISTS); + const connection = this.getJSConnection(); + debug("handleResultSetOK: {d} {}", .{ status_flags.toInt(), is_last_result }); defer { - this.advance(); - this.registerAutoFlusher(); + this.queue.advance(connection); } - if (this.flags.is_ready_for_query) { - this.finishRequest(request); + this.#flags.is_ready_for_query = is_last_result; + if (is_last_result) { + this.queue.markAsReadyForQuery(); + this.queue.markCurrentRequestAsFinished(request); } - request.onResult( - statement.result_count, - this.globalObject, - this.js_value, - this.flags.is_ready_for_query, - last_insert_id, - affected_rows, - ); + + connection.onQueryResult(request, .{ + .result_count = statement.result_count, + .last_insert_id = last_insert_id, + .affected_rows = affected_rows, + .is_last_result = is_last_result, + }); + statement.reset(); } -pub fn handleResultSet(this: *MySQLConnection, comptime Context: type, reader: NewReader(Context), header_length: u24) !void { +fn getJSConnection(this: *MySQLConnection) *JSMySQLConnection { + return @fieldParentPtr("#connection", this); +} + +fn handleResultSet(this: *MySQLConnection, comptime Context: type, reader: NewReader(Context), header_length: u24) !void { const first_byte = try reader.int(u8); debug("handleResultSet: {x:0>2}", .{first_byte}); reader.skip(-1); - var request = this.current() orelse { + var request = this.queue.current() orelse { debug("Unexpected result set packet", .{}); return error.UnexpectedPacket; }; + request.ref(); + defer request.deref(); var ok = OKPacket{ .packet_size = header_length, }; switch (@as(PacketType, @enumFromInt(first_byte))) { .ERROR => { + const connection = this.getJSConnection(); var err = ErrorPacket{}; try err.decode(reader); defer err.deinit(); defer { - this.advance(); - this.registerAutoFlusher(); + this.queue.advance(connection); } - if (request.statement) |statement| { + if (request.getStatement()) |statement| { statement.reset(); } - this.flags.is_ready_for_query = true; - this.finishRequest(request); - request.onError(err, this.globalObject); + this.#flags.is_ready_for_query = true; + this.queue.markAsReadyForQuery(); + this.queue.markCurrentRequestAsFinished(request); + + connection.onErrorPacket(request, err); }, else => |packet_type| { - const statement = request.statement orelse { + const statement = request.getStatement() orelse { debug("Unexpected result set packet", .{}); return error.UnexpectedPacket; }; + statement.ref(); + defer statement.deref(); if (!statement.execution_flags.header_received) { if (packet_type == .OK) { // if packet type is OK it means the query is done and no results are returned @@ -1845,161 +1033,48 @@ pub fn handleResultSet(this: *MySQLConnection, comptime Context: type, reader: N statement.columns_received += 1; } else { if (packet_type == .OK or packet_type == .EOF) { - if (request.flags.simple) { + if (request.isSimple() or packet_type == .EOF) { // if we are using the text protocol for sure this is a OK packet otherwise will be OK packet with 0xFE code + // If is not simple and is EOF this is actually a OK packet but with the flag EOF try ok.decode(reader); defer ok.deinit(); - this.handleResultSetOK(request, statement, ok.status_flags, ok.last_insert_id, ok.affected_rows); - return; - } else if (packet_type == .EOF) { - // this is actually a OK packet but with the flag EOF - try ok.decode(reader); - defer ok.deinit(); this.handleResultSetOK(request, statement, ok.status_flags, ok.last_insert_id, ok.affected_rows); return; } } - var stack_fallback = std.heap.stackFallback(4096, bun.default_allocator); - const allocator = stack_fallback.get(); - var row = ResultSet.Row{ - .globalObject = this.globalObject, - .columns = statement.columns, - .binary = request.flags.binary, - .raw = request.flags.result_mode == .raw, - .bigint = request.flags.bigint, - }; - var structure: JSValue = .js_undefined; - var cached_structure: ?CachedStructure = null; - switch (request.flags.result_mode) { - .objects => { - cached_structure = statement.structure(this.js_value, this.globalObject); - structure = cached_structure.?.jsValue() orelse .js_undefined; - }, - .raw, .values => { - // no need to check for duplicate fields or structure - }, - } - defer row.deinit(allocator); - try row.decode(allocator, reader); + const connection = this.getJSConnection(); - const pending_value = MySQLQuery.js.pendingValueGetCached(request.thisValue.get()) orelse .zero; - - // Process row data - const row_value = row.toJS( - this.globalObject, - pending_value, - structure, - statement.fields_flags, - request.flags.result_mode, - cached_structure, - ); - if (this.globalObject.tryTakeException()) |err| { - this.finishRequest(request); - request.onJSError(err, this.globalObject); - return error.JSError; - } - statement.result_count += 1; - - if (pending_value == .zero) { - MySQLQuery.js.pendingValueSetCached(request.thisValue.get(), this.globalObject, row_value); - } + try connection.onResultRow(request, statement, Context, reader); } }, } } -fn close(this: *@This()) void { - this.disconnect(); - this.unregisterAutoFlusher(); - this.write_buffer.clearAndFree(bun.default_allocator); -} - -pub fn closeStatement(this: *MySQLConnection, statement: *MySQLStatement) !void { - var _close = PreparedStatement.Close{ - .statement_id = statement.statement_id, - }; - - try _close.write(this.writer()); - this.flushData(); - this.registerAutoFlusher(); -} - -pub fn resetStatement(this: *MySQLConnection, statement: *MySQLStatement) !void { - var reset = PreparedStatement.Reset{ - .statement_id = statement.statement_id, - }; - - try reset.write(this.writer()); - this.flushData(); - this.registerAutoFlusher(); -} - -pub fn getQueries(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { - if (js.queriesGetCached(thisValue)) |value| { - return value; - } - - const array = try jsc.JSValue.createEmptyArray(globalObject, 0); - js.queriesSetCached(thisValue, globalObject, array); - - return array; -} - -pub fn getOnConnect(_: *@This(), thisValue: jsc.JSValue, _: *jsc.JSGlobalObject) jsc.JSValue { - if (js.onconnectGetCached(thisValue)) |value| { - return value; - } - - return .js_undefined; -} - -pub fn setOnConnect(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject, value: jsc.JSValue) void { - js.onconnectSetCached(thisValue, globalObject, value); -} - -pub fn getOnClose(_: *@This(), thisValue: jsc.JSValue, _: *jsc.JSGlobalObject) jsc.JSValue { - if (js.oncloseGetCached(thisValue)) |value| { - return value; - } - - return .js_undefined; -} - -pub fn setOnClose(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject, value: jsc.JSValue) void { - js.oncloseSetCached(thisValue, globalObject, value); -} - -pub const js = jsc.Codegen.JSMySQLConnection; -pub const fromJS = js.fromJS; -pub const fromJSDirect = js.fromJSDirect; -pub const toJS = js.toJS; -const MAX_PIPELINE_SIZE = std.math.maxInt(u16); // about 64KB per connection - const PreparedStatementsMap = std.HashMapUnmanaged(u64, *MySQLStatement, bun.IdentityContext(u64), 80); const debug = bun.Output.scoped(.MySQLConnection, .visible); -const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); -const Queue = std.fifo.LinearFifo(*MySQLQuery, .Dynamic); + +pub const ErrorPacket = @import("./protocol/ErrorPacket.zig"); + +const MAX_PIPELINE_SIZE = std.math.maxInt(u16); // about 64KB per connection +pub const PreparedStatementsMapGetOrPutResult = PreparedStatementsMap.GetOrPutResult; const AnyMySQLError = @import("./protocol/AnyMySQLError.zig"); const Auth = @import("./protocol/Auth.zig"); const AuthSwitchRequest = @import("./protocol/AuthSwitchRequest.zig"); const AuthSwitchResponse = @import("./protocol/AuthSwitchResponse.zig"); -const CachedStructure = @import("../shared/CachedStructure.zig"); const Capabilities = @import("./Capabilities.zig"); const ColumnDefinition41 = @import("./protocol/ColumnDefinition41.zig"); -const ErrorPacket = @import("./protocol/ErrorPacket.zig"); const HandshakeResponse41 = @import("./protocol/HandshakeResponse41.zig"); const HandshakeV10 = @import("./protocol/HandshakeV10.zig"); +const JSMySQLConnection = @import("./js/JSMySQLConnection.zig"); +const JSMySQLQuery = @import("./js/JSMySQLQuery.zig"); const LocalInfileRequest = @import("./protocol/LocalInfileRequest.zig"); -const MySQLQuery = @import("./MySQLQuery.zig"); -const MySQLRequest = @import("./MySQLRequest.zig"); +const MySQLRequestQueue = @import("./MySQLRequestQueue.zig"); const MySQLStatement = @import("./MySQLStatement.zig"); const OKPacket = @import("./protocol/OKPacket.zig"); const PacketHeader = @import("./protocol/PacketHeader.zig"); -const PreparedStatement = @import("./protocol/PreparedStatement.zig"); -const ResultSet = @import("./protocol/ResultSet.zig"); const ResultSetHeader = @import("./protocol/ResultSetHeader.zig"); const SSLRequest = @import("./protocol/SSLRequest.zig"); const SocketMonitor = @import("../postgres/SocketMonitor.zig"); @@ -2023,7 +1098,6 @@ const BoringSSL = bun.BoringSSL; const jsc = bun.jsc; const JSValue = jsc.JSValue; -const AutoFlusher = jsc.WebCore.AutoFlusher; const uws = bun.uws; const Socket = uws.AnySocket; diff --git a/src/sql/mysql/MySQLQuery.zig b/src/sql/mysql/MySQLQuery.zig index 5072018601..563701df5d 100644 --- a/src/sql/mysql/MySQLQuery.zig +++ b/src/sql/mysql/MySQLQuery.zig @@ -1,133 +1,18 @@ const MySQLQuery = @This(); -const RefCount = bun.ptr.ThreadSafeRefCount(@This(), "ref_count", deinit, .{}); -statement: ?*MySQLStatement = null, -query: bun.String = bun.String.empty, -cursor_name: bun.String = bun.String.empty, -thisValue: JSRef = JSRef.empty(), +#statement: ?*MySQLStatement = null, +#query: bun.String, -status: Status = Status.pending, - -ref_count: RefCount = RefCount.init(), - -flags: packed struct(u8) { - is_done: bool = false, - binary: bool = false, +#status: Status, +#flags: packed struct(u8) { bigint: bool = false, simple: bool = false, pipelined: bool = false, result_mode: SQLQueryResultMode = .objects, - _padding: u1 = 0, -} = .{}, - -pub const ref = RefCount.ref; -pub const deref = RefCount.deref; - -pub const Status = enum(u8) { - /// The query was just enqueued, statement status can be checked for more details - pending, - /// The query is being bound to the statement - binding, - /// The query is running - running, - /// The query is waiting for a partial response - partial_response, - /// The query was successful - success, - /// The query failed - fail, - - pub fn isRunning(this: Status) bool { - return @intFromEnum(this) > @intFromEnum(Status.pending) and @intFromEnum(this) < @intFromEnum(Status.success); - } -}; - -pub fn hasPendingActivity(this: *@This()) bool { - return this.ref_count.load(.monotonic) > 1; -} - -pub fn deinit(this: *@This()) void { - this.thisValue.deinit(); - if (this.statement) |statement| { - statement.deref(); - } - this.query.deref(); - this.cursor_name.deref(); - - bun.default_allocator.destroy(this); -} - -pub fn finalize(this: *@This()) void { - debug("MySQLQuery finalize", .{}); - - // Clean up any statement reference - if (this.statement) |statement| { - statement.deref(); - this.statement = null; - } - - if (this.thisValue == .weak) { - // clean up if is a weak reference, if is a strong reference we need to wait until the query is done - // if we are a strong reference, here is probably a bug because GC'd should not happen - this.thisValue.weak = .zero; - } - this.deref(); -} - -pub fn onWriteFail( - this: *@This(), - err: AnyMySQLError.Error, - globalObject: *jsc.JSGlobalObject, - queries_array: JSValue, -) void { - this.status = .fail; - const thisValue = this.thisValue.get(); - defer this.thisValue.deinit(); - const targetValue = this.getTarget(globalObject, true); - if (thisValue == .zero or targetValue == .zero) { - return; - } - - const instance = AnyMySQLError.mysqlErrorToJS(globalObject, "Failed to bind query", err); - - const vm = jsc.VirtualMachine.get(); - const function = vm.rareData().mysql_context.onQueryRejectFn.get().?; - const event_loop = vm.eventLoop(); - event_loop.runCallback(function, globalObject, thisValue, &.{ - targetValue, - // TODO: add mysql error to JS - // postgresErrorToJS(globalObject, null, err), - instance, - queries_array, - }); -} - -pub fn bindAndExecute(this: *MySQLQuery, writer: anytype, statement: *MySQLStatement, globalObject: *jsc.JSGlobalObject) AnyMySQLError.Error!void { - debug("bindAndExecute", .{}); - bun.assertf(statement.params.len == statement.params_received and statement.statement_id > 0, "statement is not prepared", .{}); - if (statement.signature.fields.len != statement.params.len) { - return error.WrongNumberOfParametersProvided; - } - var packet = try writer.start(0); - var execute = PreparedStatement.Execute{ - .statement_id = statement.statement_id, - .param_types = statement.signature.fields, - .new_params_bind_flag = statement.execution_flags.need_to_send_params, - .iteration_count = 1, - }; - statement.execution_flags.need_to_send_params = false; - defer execute.deinit(); - try this.bind(&execute, globalObject); - try execute.write(writer); - try packet.end(); - this.status = .running; -} - -fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *jsc.JSGlobalObject) AnyMySQLError.Error!void { - const thisValue = this.thisValue.get(); - const binding_value = js.bindingGetCached(thisValue) orelse .zero; - const columns_value = js.columnsGetCached(thisValue) orelse .zero; + _padding: u3 = 0, +}, +fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *JSGlobalObject, binding_value: JSValue, columns_value: JSValue) AnyMySQLError.Error!void { var iter = try QueryBindingIterator.init(binding_value, columns_value, globalObject); var i: u32 = 0; @@ -140,7 +25,6 @@ fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *j } while (try iter.next()) |js_value| { const param = execute.param_types[i]; - debug("param: {s} unsigned? {}", .{ @tagName(param.type), param.flags.UNSIGNED }); params[i] = try Value.fromJS( js_value, globalObject, @@ -154,401 +38,245 @@ fn bind(this: *MySQLQuery, execute: *PreparedStatement.Execute, globalObject: *j return error.InvalidQueryBinding; } - this.status = .binding; + this.#status = .binding; execute.params = params; } -pub fn onError(this: *@This(), err: ErrorPacket, globalObject: *jsc.JSGlobalObject) void { - debug("onError", .{}); - this.onJSError(err.toJS(globalObject), globalObject); +fn bindAndExecute(this: *MySQLQuery, writer: anytype, statement: *MySQLStatement, globalObject: *JSGlobalObject, binding_value: JSValue, columns_value: JSValue) AnyMySQLError.Error!void { + bun.assertf(statement.params.len == statement.params_received and statement.statement_id > 0, "statement is not prepared", .{}); + if (statement.signature.fields.len != statement.params.len) { + return error.WrongNumberOfParametersProvided; + } + var packet = try writer.start(0); + var execute = PreparedStatement.Execute{ + .statement_id = statement.statement_id, + .param_types = statement.signature.fields, + .new_params_bind_flag = statement.execution_flags.need_to_send_params, + .iteration_count = 1, + }; + statement.execution_flags.need_to_send_params = false; + defer execute.deinit(); + try this.bind(&execute, globalObject, binding_value, columns_value); + try execute.write(writer); + try packet.end(); + this.#status = .running; } -pub fn onJSError(this: *@This(), err: jsc.JSValue, globalObject: *jsc.JSGlobalObject) void { - this.ref(); - defer this.deref(); - this.status = .fail; - const thisValue = this.thisValue.get(); - defer this.thisValue.deinit(); - const targetValue = this.getTarget(globalObject, true); - if (thisValue == .zero or targetValue == .zero) { +fn runSimpleQuery(this: *@This(), connection: *MySQLConnection) !void { + if (this.#status != .pending or !connection.canExecuteQuery()) { + debug("cannot execute query", .{}); + // cannot execute query return; } - - var vm = jsc.VirtualMachine.get(); - const function = vm.rareData().mysql_context.onQueryRejectFn.get().?; - const event_loop = vm.eventLoop(); - event_loop.runCallback(function, globalObject, thisValue, &.{ - targetValue, - err, - }); -} -pub fn getTarget(this: *@This(), globalObject: *jsc.JSGlobalObject, clean_target: bool) jsc.JSValue { - const thisValue = this.thisValue.tryGet() orelse return .zero; - const target = js.targetGetCached(thisValue) orelse return .zero; - if (clean_target) { - js.targetSetCached(thisValue, globalObject, .zero); + var query_str = this.#query.toUTF8(bun.default_allocator); + defer query_str.deinit(); + const writer = connection.getWriter(); + if (this.#statement == null) { + const stmt = bun.new(MySQLStatement, .{ + .signature = Signature.empty(), + .status = .parsing, + .ref_count = .initExactRefs(1), + }); + this.#statement = stmt; } - return target; + try MySQLRequest.executeQuery(query_str.slice(), MySQLConnection.Writer, writer); + + this.#status = .running; } -fn consumePendingValue(thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject) ?JSValue { - const pending_value = js.pendingValueGetCached(thisValue) orelse return null; - js.pendingValueSetCached(thisValue, globalObject, .zero); - return pending_value; -} +fn runPreparedQuery( + this: *@This(), + connection: *MySQLConnection, + globalObject: *JSGlobalObject, + columns_value: JSValue, + binding_value: JSValue, +) !void { + var query_str: ?bun.ZigString.Slice = null; + defer if (query_str) |str| str.deinit(); -pub fn allowGC(thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject) void { - if (thisValue == .zero) { - return; - } + if (this.#statement == null) { + const query = this.#query.toUTF8(bun.default_allocator); + query_str = query; + var signature = Signature.generate(globalObject, query.slice(), binding_value, columns_value) catch |err| { + if (!globalObject.hasException()) + return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to generate signature", err)); + return error.JSError; + }; + errdefer signature.deinit(); + const entry = connection.getStatementFromSignatureHash(bun.hash(signature.name)) catch |err| { + return globalObject.throwError(err, "failed to allocate statement"); + }; - defer thisValue.ensureStillAlive(); - js.bindingSetCached(thisValue, globalObject, .zero); - js.pendingValueSetCached(thisValue, globalObject, .zero); - js.targetSetCached(thisValue, globalObject, .zero); -} - -fn u64ToJSValue(value: u64) JSValue { - if (value <= jsc.MAX_SAFE_INTEGER) { - return JSValue.jsNumber(value); - } - return JSValue.jsBigInt(value); -} - -pub fn onResult(this: *@This(), result_count: u64, globalObject: *jsc.JSGlobalObject, connection: jsc.JSValue, is_last: bool, last_insert_id: u64, affected_rows: u64) void { - this.ref(); - defer this.deref(); - - const thisValue = this.thisValue.get(); - const targetValue = this.getTarget(globalObject, is_last); - if (is_last) { - this.status = .success; - } else { - this.status = .partial_response; - } - defer if (is_last) { - allowGC(thisValue, globalObject); - this.thisValue.deinit(); - }; - if (thisValue == .zero or targetValue == .zero) { - return; - } - - const vm = jsc.VirtualMachine.get(); - const function = vm.rareData().mysql_context.onQueryResolveFn.get().?; - const event_loop = vm.eventLoop(); - const tag: CommandTag = .{ .SELECT = result_count }; - - event_loop.runCallback(function, globalObject, thisValue, &.{ - targetValue, - consumePendingValue(thisValue, globalObject) orelse .js_undefined, - tag.toJSTag(globalObject), - tag.toJSNumber(), - if (connection == .zero) .js_undefined else MySQLConnection.js.queriesGetCached(connection) orelse .js_undefined, - JSValue.jsBoolean(is_last), - JSValue.jsNumber(last_insert_id), - JSValue.jsNumber(affected_rows), - }); -} - -pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*MySQLQuery { - _ = callframe; - return globalThis.throw("MySQLQuery cannot be constructed directly", .{}); -} - -pub fn estimatedSize(this: *MySQLQuery) usize { - _ = this; - return @sizeOf(MySQLQuery); -} - -pub fn call(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { - const arguments = callframe.arguments(); - var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments); - defer args.deinit(); - const query = args.nextEat() orelse { - return globalThis.throw("query must be a string", .{}); - }; - const values = args.nextEat() orelse { - return globalThis.throw("values must be an array", .{}); - }; - - if (!query.isString()) { - return globalThis.throw("query must be a string", .{}); - } - - if (values.jsType() != .Array) { - return globalThis.throw("values must be an array", .{}); - } - - const pending_value: JSValue = args.nextEat() orelse .js_undefined; - const columns: JSValue = args.nextEat() orelse .js_undefined; - const js_bigint: JSValue = args.nextEat() orelse .false; - const js_simple: JSValue = args.nextEat() orelse .false; - - const bigint = js_bigint.isBoolean() and js_bigint.asBoolean(); - const simple = js_simple.isBoolean() and js_simple.asBoolean(); - if (simple) { - if (try values.getLength(globalThis) > 0) { - return globalThis.throwInvalidArguments("simple query cannot have parameters", .{}); - } - if (try query.getLength(globalThis) >= std.math.maxInt(i32)) { - return globalThis.throwInvalidArguments("query is too long", .{}); + if (entry.found_existing) { + const stmt = entry.value_ptr.*; + if (stmt.status == .failed) { + const error_response = stmt.error_response.toJS(globalObject); + // If the statement failed, we need to throw the error + return globalObject.throwValue(error_response); + } + this.#statement = stmt; + stmt.ref(); + signature.deinit(); + signature = Signature{}; + } else { + const stmt = bun.new(MySQLStatement, .{ + .signature = signature, + .ref_count = .initExactRefs(2), + .status = .pending, + .statement_id = 0, + }); + this.#statement = stmt; + entry.value_ptr.* = stmt; } } - if (!pending_value.jsType().isArrayLike()) { - return globalThis.throwInvalidArgumentType("query", "pendingValue", "Array"); + const stmt = this.#statement.?; + switch (stmt.status) { + .failed => { + debug("failed", .{}); + const error_response = stmt.error_response.toJS(globalObject); + // If the statement failed, we need to throw the error + return globalObject.throwValue(error_response); + }, + .prepared => { + if (connection.canPipeline()) { + debug("bindAndExecute", .{}); + const writer = connection.getWriter(); + this.bindAndExecute(writer, stmt, globalObject, binding_value, columns_value) catch |err| { + if (!globalObject.hasException()) + return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to bind and execute query", err)); + return error.JSError; + }; + this.#flags.pipelined = true; + } + }, + .parsing => { + debug("parsing", .{}); + }, + .pending => { + if (connection.canPrepareQuery()) { + debug("prepareRequest", .{}); + const writer = connection.getWriter(); + const query = query_str orelse this.#query.toUTF8(bun.default_allocator); + MySQLRequest.prepareRequest(query.slice(), MySQLConnection.Writer, writer) catch |err| { + return globalObject.throwError(err, "failed to prepare query"); + }; + stmt.status = .parsing; + } + }, } +} - var ptr = bun.default_allocator.create(MySQLQuery) catch |err| { - return globalThis.throwError(err, "failed to allocate query"); - }; - - const this_value = ptr.toJS(globalThis); - this_value.ensureStillAlive(); - - ptr.* = .{ - .query = try query.toBunString(globalThis), - .thisValue = JSRef.initWeak(this_value), - .flags = .{ +pub fn init(query: bun.String, bigint: bool, simple: bool) @This() { + query.ref(); + return .{ + .#query = query, + .#status = .pending, + .#flags = .{ .bigint = bigint, .simple = simple, }, }; - ptr.query.ref(); +} - js.bindingSetCached(this_value, globalThis, values); - js.pendingValueSetCached(this_value, globalThis, pending_value); - if (!columns.isUndefined()) { - js.columnsSetCached(this_value, globalThis, columns); +pub fn runQuery(this: *@This(), connection: *MySQLConnection, globalObject: *JSGlobalObject, columns_value: JSValue, binding_value: JSValue) !void { + if (this.#flags.simple) { + debug("runSimpleQuery", .{}); + return try this.runSimpleQuery(connection); } + debug("runPreparedQuery", .{}); + return try this.runPreparedQuery( + connection, + globalObject, + if (columns_value == .zero) .js_undefined else columns_value, + if (binding_value == .zero) .js_undefined else binding_value, + ); +} - return this_value; +pub inline fn setResultMode(this: *@This(), result_mode: SQLQueryResultMode) void { + this.#flags.result_mode = result_mode; } -pub fn setPendingValue(this: *@This(), globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - const result = callframe.argument(0); - const thisValue = this.thisValue.tryGet() orelse return .js_undefined; - js.pendingValueSetCached(thisValue, globalObject, result); - return .js_undefined; + +pub inline fn result(this: *@This(), is_last_result: bool) bool { + if (this.#status == .success or this.#status == .fail) return false; + this.#status = if (is_last_result) .success else .partial_response; + + return true; } -pub fn setMode(this: *@This(), globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - const js_mode = callframe.argument(0); - if (js_mode.isEmptyOrUndefinedOrNull() or !js_mode.isNumber()) { - return globalObject.throwInvalidArgumentType("setMode", "mode", "Number"); +pub fn fail(this: *@This()) bool { + if (this.#status == .fail or this.#status == .success) return false; + this.#status = .fail; + + return true; +} + +pub fn cleanup(this: *@This()) void { + if (this.#statement) |statement| { + statement.deref(); + this.#statement = null; } - - const mode = try js_mode.coerce(i32, globalObject); - this.flags.result_mode = std.meta.intToEnum(SQLQueryResultMode, mode) catch { - return globalObject.throwInvalidArgumentTypeValue("mode", "Number", js_mode); - }; - return .js_undefined; + var query = this.#query; + defer query.deref(); + this.#query = bun.String.empty; } -pub fn doDone(this: *@This(), globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { - _ = globalObject; - this.flags.is_done = true; - return .js_undefined; +pub inline fn isCompleted(this: *const @This()) bool { + return this.#status == .success or this.#status == .fail; } - -pub fn doCancel(this: *MySQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - _ = callframe; - _ = globalObject; - _ = this; - - return .js_undefined; -} - -pub fn doRun(this: *MySQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { - debug("doRun", .{}); - var arguments = callframe.arguments(); - const connection: *MySQLConnection = arguments[0].as(MySQLConnection) orelse { - return globalObject.throw("connection must be a MySQLConnection", .{}); - }; - - connection.poll_ref.ref(globalObject.bunVM()); - var query = arguments[1]; - - if (!query.isObject()) { - return globalObject.throwInvalidArgumentType("run", "query", "Query"); +pub inline fn isRunning(this: *const @This()) bool { + switch (this.#status) { + .running, .binding, .partial_response => return true, + .success, .fail, .pending => return false, } +} +pub inline fn isPending(this: *const @This()) bool { + return this.#status == .pending; +} - const this_value = callframe.this(); - const binding_value = js.bindingGetCached(this_value) orelse .zero; - var query_str = this.query.toUTF8(bun.default_allocator); - defer query_str.deinit(); - const writer = connection.writer(); - // We need a strong reference to the query so that it doesn't get GC'd - this.ref(); - const can_execute = connection.canExecuteQuery(); - if (this.flags.simple) { - // simple queries are always text in MySQL - this.flags.binary = false; - debug("executeQuery", .{}); +pub inline fn isBeingPrepared(this: *@This()) bool { + return this.#status == .pending and this.#statement != null and this.#statement.?.status == .parsing; +} - const stmt = bun.default_allocator.create(MySQLStatement) catch { - this.deref(); - return globalObject.throwOutOfMemory(); - }; - // Query is simple and it's the only owner of the statement - stmt.* = .{ - .signature = Signature.empty(), - .status = .parsing, - }; - this.statement = stmt; - - if (can_execute) { - connection.sequence_id = 0; - MySQLRequest.executeQuery(query_str.slice(), MySQLConnection.Writer, writer) catch |err| { - debug("executeQuery failed: {s}", .{@errorName(err)}); - // fail to run do cleanup - this.statement = null; - bun.default_allocator.destroy(stmt); - this.deref(); - - if (!globalObject.hasException()) - return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to execute query", err)); - return error.JSError; - }; - connection.flags.is_ready_for_query = false; - connection.nonpipelinable_requests += 1; - this.status = .running; - } else { - this.status = .pending; - } - connection.requests.writeItem(this) catch { - // fail to run do cleanup - this.statement = null; - bun.default_allocator.destroy(stmt); - this.deref(); - - return globalObject.throwOutOfMemory(); - }; - debug("doRun: wrote query to queue", .{}); - - this.thisValue.upgrade(globalObject); - js.targetSetCached(this_value, globalObject, query); - connection.flushDataAndResetTimeout(); - return .js_undefined; - } - // prepared statements are always binary in MySQL - this.flags.binary = true; - - const columns_value = js.columnsGetCached(callframe.this()) orelse .js_undefined; - - var signature = Signature.generate(globalObject, query_str.slice(), binding_value, columns_value) catch |err| { - this.deref(); - if (!globalObject.hasException()) - return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to generate signature", err)); - return error.JSError; - }; - errdefer signature.deinit(); - - const entry = connection.statements.getOrPut(bun.default_allocator, bun.hash(signature.name)) catch |err| { - this.deref(); - return globalObject.throwError(err, "failed to allocate statement"); - }; - - var did_write = false; - - enqueue: { - if (entry.found_existing) { - const stmt = entry.value_ptr.*; - this.statement = stmt; - stmt.ref(); - signature.deinit(); - signature = Signature{}; - switch (stmt.status) { - .failed => { - this.statement = null; - const error_response = stmt.error_response.toJS(globalObject); - stmt.deref(); - this.deref(); - // If the statement failed, we need to throw the error - return globalObject.throwValue(error_response); - }, - .prepared => { - if (can_execute or connection.canPipeline()) { - debug("doRun: binding and executing query", .{}); - this.bindAndExecute(writer, this.statement.?, globalObject) catch |err| { - if (!globalObject.hasException()) - return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to bind and execute query", err)); - return error.JSError; - }; - connection.sequence_id = 0; - this.flags.pipelined = true; - connection.pipelined_requests += 1; - connection.flags.is_ready_for_query = false; - did_write = true; - } - }, - - .parsing, .pending => {}, +pub inline fn isPipelined(this: *const @This()) bool { + return this.#flags.pipelined; +} +pub inline fn isSimple(this: *const @This()) bool { + return this.#flags.simple; +} +pub inline fn isBigintSupported(this: *const @This()) bool { + return this.#flags.bigint; +} +pub inline fn getResultMode(this: *const @This()) SQLQueryResultMode { + return this.#flags.result_mode; +} +pub inline fn markAsPrepared(this: *@This()) void { + if (this.#status == .pending) { + if (this.#statement) |statement| { + if (statement.status == .parsing and + statement.params.len == statement.params_received and + statement.statement_id > 0) + { + statement.status = .prepared; } - - break :enqueue; - } - - const stmt = bun.default_allocator.create(MySQLStatement) catch |err| { - this.deref(); - return globalObject.throwError(err, "failed to allocate statement"); - }; - stmt.* = .{ - .signature = signature, - .ref_count = .initExactRefs(2), - .status = .pending, - .statement_id = 0, - }; - this.statement = stmt; - entry.value_ptr.* = stmt; - } - - this.status = if (did_write) .running else .pending; - try connection.requests.writeItem(this); - this.thisValue.upgrade(globalObject); - - js.targetSetCached(this_value, globalObject, query); - if (!did_write and can_execute) { - debug("doRun: preparing query", .{}); - if (connection.canPrepareQuery()) { - this.statement.?.status = .parsing; - MySQLRequest.prepareRequest(query_str.slice(), MySQLConnection.Writer, writer) catch |err| { - this.deref(); - return globalObject.throwError(err, "failed to prepare query"); - }; - connection.flags.waiting_to_prepare = true; - connection.flags.is_ready_for_query = false; } } - connection.flushDataAndResetTimeout(); - - return .js_undefined; } - -comptime { - @export(&jsc.toJSHostFn(call), .{ .name = "MySQLQuery__createInstance" }); +pub inline fn getStatement(this: *const @This()) ?*MySQLStatement { + return this.#statement; } -pub const js = jsc.Codegen.JSMySQLQuery; -pub const fromJS = js.fromJS; -pub const fromJSDirect = js.fromJSDirect; -pub const toJS = js.toJS; - const debug = bun.Output.scoped(.MySQLQuery, .visible); -// TODO: move to shared IF POSSIBLE const AnyMySQLError = @import("./protocol/AnyMySQLError.zig"); -const ErrorPacket = @import("./protocol/ErrorPacket.zig"); -const MySQLConnection = @import("./MySQLConnection.zig"); +const MySQLConnection = @import("./js/JSMySQLConnection.zig"); const MySQLRequest = @import("./MySQLRequest.zig"); const MySQLStatement = @import("./MySQLStatement.zig"); const PreparedStatement = @import("./protocol/PreparedStatement.zig"); const Signature = @import("./protocol/Signature.zig"); const bun = @import("bun"); -const std = @import("std"); -const CommandTag = @import("../postgres/CommandTag.zig").CommandTag; const QueryBindingIterator = @import("../shared/QueryBindingIterator.zig").QueryBindingIterator; const SQLQueryResultMode = @import("../shared/SQLQueryResultMode.zig").SQLQueryResultMode; +const Status = @import("./QueryStatus.zig").Status; const Value = @import("./MySQLTypes.zig").Value; -const jsc = bun.jsc; -const JSRef = jsc.JSRef; -const JSValue = jsc.JSValue; +const JSGlobalObject = bun.jsc.JSGlobalObject; +const JSValue = bun.jsc.JSValue; diff --git a/src/sql/mysql/MySQLQueryResult.zig b/src/sql/mysql/MySQLQueryResult.zig new file mode 100644 index 0000000000..9340ab9e5e --- /dev/null +++ b/src/sql/mysql/MySQLQueryResult.zig @@ -0,0 +1,4 @@ +result_count: u64, +last_insert_id: u64, +affected_rows: u64, +is_last_result: bool, diff --git a/src/sql/mysql/MySQLRequestQueue.zig b/src/sql/mysql/MySQLRequestQueue.zig new file mode 100644 index 0000000000..569633b7a7 --- /dev/null +++ b/src/sql/mysql/MySQLRequestQueue.zig @@ -0,0 +1,224 @@ +pub const MySQLRequestQueue = @This(); + +#requests: Queue, + +#pipelined_requests: u32 = 0, +#nonpipelinable_requests: u32 = 0, +// TODO: refactor to ENUM +#waiting_to_prepare: bool = false, +#is_ready_for_query: bool = true, + +pub inline fn canExecuteQuery(this: *@This(), connection: *MySQLConnection) bool { + return connection.isAbleToWrite() and + this.#is_ready_for_query and + this.#nonpipelinable_requests == 0 and + this.#pipelined_requests == 0; +} +pub inline fn canPrepareQuery(this: *@This(), connection: *MySQLConnection) bool { + return connection.isAbleToWrite() and + this.#is_ready_for_query and + !this.#waiting_to_prepare and + this.#pipelined_requests == 0; +} + +pub inline fn markAsReadyForQuery(this: *@This()) void { + this.#is_ready_for_query = true; +} +pub inline fn markAsPrepared(this: *@This()) void { + this.#waiting_to_prepare = false; + if (this.current()) |request| { + debug("markAsPrepared markAsPrepared", .{}); + request.markAsPrepared(); + } +} +pub inline fn canPipeline(this: *@This(), connection: *MySQLConnection) bool { + if (bun.getRuntimeFeatureFlag(.BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING)) { + @branchHint(.unlikely); + return false; + } + + return this.#is_ready_for_query and + this.#nonpipelinable_requests == 0 and // need to wait for non pipelinable requests to finish + !this.#waiting_to_prepare and + connection.isAbleToWrite(); +} + +pub fn markCurrentRequestAsFinished(this: *@This(), item: *JSMySQLQuery) void { + this.#waiting_to_prepare = false; + if (item.isBeingPrepared()) { + debug("markCurrentRequestAsFinished markAsPrepared", .{}); + item.markAsPrepared(); + } else if (item.isRunning()) { + if (item.isPipelined()) { + this.#pipelined_requests -= 1; + } else { + this.#nonpipelinable_requests -= 1; + } + } +} + +pub fn advance(this: *@This(), connection: *MySQLConnection) void { + var offset: usize = 0; + defer { + while (this.#requests.readableLength() > 0) { + const request = this.#requests.peekItem(0); + // An item may be in the success or failed state and still be inside the queue (see deinit later comments) + // so we do the cleanup her + if (request.isCompleted()) { + debug("isCompleted discard after advance", .{}); + this.#requests.discard(1); + request.deref(); + continue; + } + break; + } + } + + while (this.#requests.readableLength() > offset and connection.isAbleToWrite()) { + var request: *JSMySQLQuery = this.#requests.peekItem(offset); + + if (request.isCompleted()) { + if (offset > 0) { + // discard later + offset += 1; + continue; + } + debug("isCompleted", .{}); + this.#requests.discard(1); + request.deref(); + continue; + } + + if (request.isBeingPrepared()) { + debug("isBeingPrepared", .{}); + this.#waiting_to_prepare = true; + // cannot continue the queue until the current request is marked as prepared + return; + } + if (request.isRunning()) { + debug("isRunning", .{}); + const total_requests_running = this.#pipelined_requests + this.#nonpipelinable_requests; + if (offset < total_requests_running) { + offset += total_requests_running; + } else { + offset += 1; + } + continue; + } + + request.run(connection) catch |err| { + debug("run failed", .{}); + connection.onError(request, err); + if (offset == 0) { + this.#requests.discard(1); + request.deref(); + } + offset += 1; + continue; + }; + if (request.isBeingPrepared()) { + debug("isBeingPrepared", .{}); + connection.resetConnectionTimeout(); + this.#is_ready_for_query = false; + this.#waiting_to_prepare = true; + return; + } else if (request.isRunning()) { + connection.resetConnectionTimeout(); + debug("isRunning after run", .{}); + this.#is_ready_for_query = false; + + if (request.isPipelined()) { + this.#pipelined_requests += 1; + if (this.canPipeline(connection)) { + debug("pipelined requests", .{}); + offset += 1; + continue; + } + return; + } + debug("nonpipelinable requests", .{}); + this.#nonpipelinable_requests += 1; + } + return; + } +} + +pub fn init() @This() { + return .{ .#requests = Queue.init(bun.default_allocator) }; +} + +pub fn isEmpty(this: *@This()) bool { + return this.#requests.readableLength() == 0; +} + +pub fn add(this: *@This(), request: *JSMySQLQuery) void { + debug("add", .{}); + if (request.isBeingPrepared()) { + this.#is_ready_for_query = false; + this.#waiting_to_prepare = true; + } else if (request.isRunning()) { + this.#is_ready_for_query = false; + + if (request.isPipelined()) { + this.#pipelined_requests += 1; + } else { + this.#nonpipelinable_requests += 1; + } + } + request.ref(); + bun.handleOom(this.#requests.writeItem(request)); +} + +pub fn current(this: *@This()) ?*JSMySQLQuery { + if (this.#requests.readableLength() == 0) { + return null; + } + + return this.#requests.peekItem(0); +} + +pub fn clean(this: *@This(), reason: ?JSValue, queries_array: JSValue) void { + while (this.current()) |request| { + if (request.isCompleted()) { + request.deref(); + this.#requests.discard(1); + continue; + } + if (reason) |r| { + request.rejectWithJSValue(queries_array, r); + } else { + request.reject(queries_array, error.ConnectionClosed); + } + this.#requests.discard(1); + request.deref(); + continue; + } + this.#pipelined_requests = 0; + this.#nonpipelinable_requests = 0; + this.#waiting_to_prepare = false; +} + +pub fn deinit(this: *@This()) void { + for (this.#requests.readableSlice(0)) |request| { + this.#requests.discard(1); + // We cannot touch JS here + request.markAsFailed(); + request.deref(); + } + this.#pipelined_requests = 0; + this.#nonpipelinable_requests = 0; + this.#waiting_to_prepare = false; + this.#requests.deinit(); +} + +const Queue = std.fifo.LinearFifo(*JSMySQLQuery, .Dynamic); + +const debug = bun.Output.scoped(.MySQLRequestQueue, .visible); + +const JSMySQLQuery = @import("./js/JSMySQLQuery.zig"); +const MySQLConnection = @import("./js/JSMySQLConnection.zig"); +const bun = @import("bun"); +const std = @import("std"); + +const jsc = bun.jsc; +const JSValue = jsc.JSValue; diff --git a/src/sql/mysql/MySQLStatement.zig b/src/sql/mysql/MySQLStatement.zig index 3933a2e63d..2b86c30e35 100644 --- a/src/sql/mysql/MySQLStatement.zig +++ b/src/sql/mysql/MySQLStatement.zig @@ -55,7 +55,7 @@ pub fn deinit(this: *MySQLStatement) void { this.cached_structure.deinit(); this.error_response.deinit(); this.signature.deinit(); - bun.default_allocator.destroy(this); + bun.destroy(this); } pub fn checkForDuplicateFields(this: *@This()) void { diff --git a/src/sql/mysql/QueryStatus.zig b/src/sql/mysql/QueryStatus.zig new file mode 100644 index 0000000000..4c95024d1b --- /dev/null +++ b/src/sql/mysql/QueryStatus.zig @@ -0,0 +1,18 @@ +pub const Status = enum(u8) { + /// The query was just enqueued, statement status can be checked for more details + pending, + /// The query is being bound to the statement + binding, + /// The query is running + running, + /// The query is waiting for a partial response + partial_response, + /// The query was successful + success, + /// The query failed + fail, + + pub fn isRunning(this: Status) bool { + return @intFromEnum(this) > @intFromEnum(Status.pending) and @intFromEnum(this) < @intFromEnum(Status.success); + } +}; diff --git a/src/sql/mysql/js/JSMySQLConnection.zig b/src/sql/mysql/js/JSMySQLConnection.zig new file mode 100644 index 0000000000..cac3199262 --- /dev/null +++ b/src/sql/mysql/js/JSMySQLConnection.zig @@ -0,0 +1,809 @@ +const JSMySQLConnection = @This(); +__ref_count: RefCount = RefCount.init(), +#js_value: jsc.JSRef = jsc.JSRef.empty(), +#globalObject: *jsc.JSGlobalObject, +#vm: *jsc.VirtualMachine, +#poll_ref: bun.Async.KeepAlive = .{}, + +#connection: MySQLConnection, + +auto_flusher: AutoFlusher = .{}, + +idle_timeout_interval_ms: u32 = 0, +connection_timeout_ms: u32 = 0, +/// Before being connected, this is a connection timeout timer. +/// After being connected, this is an idle timeout timer. +timer: bun.api.Timer.EventLoopTimer = .{ + .tag = .MySQLConnectionTimeout, + .next = .{ + .sec = 0, + .nsec = 0, + }, +}, + +/// This timer controls the maximum lifetime of a connection. +/// It starts when the connection successfully starts (i.e. after handshake is complete). +/// It stops when the connection is closed. +max_lifetime_interval_ms: u32 = 0, +max_lifetime_timer: bun.api.Timer.EventLoopTimer = .{ + .tag = .MySQLConnectionMaxLifetime, + .next = .{ + .sec = 0, + .nsec = 0, + }, +}, + +pub const ref = RefCount.ref; +pub const deref = RefCount.deref; + +pub fn onAutoFlush(this: *@This()) bool { + if (this.#connection.hasBackpressure()) { + this.auto_flusher.registered = false; + // if we have backpressure, wait for onWritable + return false; + } + + // drain as much as we can + this.drainInternal(); + + // if we dont have backpressure and if we still have data to send, return true otherwise return false and wait for onWritable + const keep_flusher_registered = this.#connection.canFlush(); + this.auto_flusher.registered = keep_flusher_registered; + return keep_flusher_registered; +} + +fn registerAutoFlusher(this: *@This()) void { + if (!this.auto_flusher.registered and // should not be registered + + this.#connection.canFlush()) + { + AutoFlusher.registerDeferredMicrotaskWithTypeUnchecked(@This(), this, this.#vm); + this.auto_flusher.registered = true; + } +} + +fn unregisterAutoFlusher(this: *@This()) void { + if (this.auto_flusher.registered) { + AutoFlusher.unregisterDeferredMicrotaskWithType(@This(), this, this.#vm); + this.auto_flusher.registered = false; + } +} + +fn stopTimers(this: *@This()) void { + debug("stopTimers", .{}); + if (this.timer.state == .ACTIVE) { + this.#vm.timer.remove(&this.timer); + } + if (this.max_lifetime_timer.state == .ACTIVE) { + this.#vm.timer.remove(&this.max_lifetime_timer); + } +} +fn getTimeoutInterval(this: *@This()) u32 { + return switch (this.#connection.status) { + .connected => { + if (this.#connection.isIdle()) { + return this.idle_timeout_interval_ms; + } + return 0; + }, + .failed => 0, + else => { + return this.connection_timeout_ms; + }, + }; +} +pub fn resetConnectionTimeout(this: *@This()) void { + debug("resetConnectionTimeout", .{}); + const interval = this.getTimeoutInterval(); + if (this.timer.state == .ACTIVE) { + this.#vm.timer.remove(&this.timer); + } + if (this.#connection.status == .failed or + this.#connection.isProcessingData() or + interval == 0) return; + + this.timer.next = bun.timespec.msFromNow(@intCast(interval)); + this.#vm.timer.insert(&this.timer); +} + +pub fn onConnectionTimeout(this: *@This()) bun.api.Timer.EventLoopTimer.Arm { + this.timer.state = .FIRED; + + if (this.#connection.isProcessingData()) { + return .disarm; + } + + if (this.#connection.status == .failed) return .disarm; + + if (this.getTimeoutInterval() == 0) { + this.resetConnectionTimeout(); + return .disarm; + } + + switch (this.#connection.status) { + .connected => { + this.failFmt(error.IdleTimeout, "Idle timeout reached after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.idle_timeout_interval_ms) *| std.time.ns_per_ms)}); + }, + .connecting => { + this.failFmt(error.ConnectionTimedOut, "Connection timeout after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); + }, + .handshaking, + .authenticating, + .authentication_awaiting_pk, + => { + this.failFmt(error.ConnectionTimedOut, "Connection timeout after {} (during authentication)", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); + }, + .disconnected, .failed => {}, + } + return .disarm; +} + +pub fn onMaxLifetimeTimeout(this: *@This()) bun.api.Timer.EventLoopTimer.Arm { + this.max_lifetime_timer.state = .FIRED; + if (this.#connection.status == .failed) return .disarm; + this.failFmt(error.LifetimeTimeout, "Max lifetime timeout reached after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.max_lifetime_interval_ms) *| std.time.ns_per_ms)}); + return .disarm; +} +fn setupMaxLifetimeTimerIfNecessary(this: *@This()) void { + if (this.max_lifetime_interval_ms == 0) return; + if (this.max_lifetime_timer.state == .ACTIVE) return; + + this.max_lifetime_timer.next = bun.timespec.msFromNow(@intCast(this.max_lifetime_interval_ms)); + this.#vm.timer.insert(&this.max_lifetime_timer); +} +pub fn constructor(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*@This() { + _ = callframe; + + return globalObject.throw("MySQLConnection cannot be constructed directly", .{}); +} + +pub fn enqueueRequest(this: *@This(), item: *JSMySQLQuery) void { + debug("enqueueRequest", .{}); + this.#connection.enqueueRequest(item); + this.resetConnectionTimeout(); + this.registerAutoFlusher(); +} + +pub fn close(this: *@This()) void { + this.ref(); + this.stopTimers(); + this.unregisterAutoFlusher(); + defer { + this.updateReferenceType(); + this.deref(); + } + if (this.#vm.isShuttingDown()) { + this.#connection.close(); + } else { + this.#connection.cleanQueueAndClose(null, this.getQueriesArray()); + } +} + +fn drainInternal(this: *@This()) void { + if (this.#vm.isShuttingDown()) return this.close(); + this.ref(); + defer this.deref(); + const event_loop = this.#vm.eventLoop(); + event_loop.enter(); + defer event_loop.exit(); + this.ensureJSValueIsAlive(); + this.#connection.flushQueue() catch |err| { + bun.assert_eql(err, error.AuthenticationFailed); + this.fail("Authentication failed", err); + return; + }; +} +pub fn deinit(this: *@This()) void { + this.stopTimers(); + this.#poll_ref.unref(this.#vm); + this.unregisterAutoFlusher(); + + this.#connection.cleanup(); + bun.destroy(this); +} + +fn ensureJSValueIsAlive(this: *@This()) void { + if (this.#js_value.tryGet()) |value| { + value.ensureStillAlive(); + } +} +pub fn finalize(this: *@This()) void { + debug("finalize", .{}); + this.#js_value.finalize(); + this.deref(); +} + +fn SocketHandler(comptime ssl: bool) type { + return struct { + const SocketType = uws.NewSocketHandler(ssl); + fn _socket(s: SocketType) uws.AnySocket { + if (comptime ssl) { + return uws.AnySocket{ .SocketTLS = s }; + } + + return uws.AnySocket{ .SocketTCP = s }; + } + pub fn onOpen(this: *JSMySQLConnection, s: SocketType) void { + const socket = _socket(s); + this.#connection.setSocket(socket); + + this.setupMaxLifetimeTimerIfNecessary(); + this.resetConnectionTimeout(); + if (socket == .SocketTCP) { + // when upgrading to TLS the onOpen callback will be called again and at this moment we dont wanna to change the status to handshaking + this.#connection.status = .handshaking; + this.ref(); // keep a ref for the socket + } + this.updateReferenceType(); + } + + fn onHandshake_( + this: *JSMySQLConnection, + _: anytype, + success: i32, + ssl_error: uws.us_bun_verify_error_t, + ) void { + const handshakeWasSuccessful = this.#connection.doHandshake(success, ssl_error) catch |err| return this.failFmt(err, "Failed to send handshake response", .{}); + if (!handshakeWasSuccessful) { + this.failWithJSValue(ssl_error.toJS(this.#globalObject)); + } + } + + pub const onHandshake = if (ssl) onHandshake_ else null; + + pub fn onClose(this: *JSMySQLConnection, _: SocketType, _: i32, _: ?*anyopaque) void { + defer this.deref(); + this.fail("Connection closed", error.ConnectionClosed); + } + + pub fn onEnd(_: *JSMySQLConnection, socket: SocketType) void { + // no half closed sockets + socket.close(.normal); + } + + pub fn onConnectError(this: *JSMySQLConnection, _: SocketType, _: i32) void { + // TODO: proper propagation of the error + this.fail("Connection closed", error.ConnectionClosed); + } + + pub fn onTimeout(this: *JSMySQLConnection, _: SocketType) void { + this.fail("Connection timeout", error.ConnectionTimedOut); + } + + pub fn onData(this: *JSMySQLConnection, _: SocketType, data: []const u8) void { + this.ref(); + defer this.deref(); + const vm = this.#vm; + + defer { + // reset the connection timeout after we're done processing the data + this.resetConnectionTimeout(); + this.updateReferenceType(); + this.registerAutoFlusher(); + } + if (this.#vm.isShuttingDown()) { + // we are shutting down lets not process the data + return; + } + + const event_loop = vm.eventLoop(); + event_loop.enter(); + defer event_loop.exit(); + this.ensureJSValueIsAlive(); + + this.#connection.readAndProcessData(data) catch |err| { + this.onError(null, err); + }; + } + + pub fn onWritable(this: *JSMySQLConnection, _: SocketType) void { + this.#connection.resetBackpressure(); + this.drainInternal(); + } + }; +} + +fn updateReferenceType(this: *@This()) void { + if (this.#js_value.isNotEmpty()) { + if (this.#connection.isActive()) { + if (this.#js_value == .weak) { + this.#js_value.upgrade(this.#globalObject); + this.#poll_ref.ref(this.#vm); + } + return; + } + if (this.#js_value == .strong) { + this.#js_value.downgrade(); + this.#poll_ref.unref(this.#vm); + return; + } + } + this.#poll_ref.unref(this.#vm); +} + +pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + var vm = globalObject.bunVM(); + const arguments = callframe.arguments(); + const hostname_str = try arguments[0].toBunString(globalObject); + defer hostname_str.deref(); + const port = try arguments[1].coerce(i32, globalObject); + + const username_str = try arguments[2].toBunString(globalObject); + defer username_str.deref(); + const password_str = try arguments[3].toBunString(globalObject); + defer password_str.deref(); + const database_str = try arguments[4].toBunString(globalObject); + defer database_str.deref(); + // TODO: update this to match MySQL. + const ssl_mode: SSLMode = switch (arguments[5].toInt32()) { + 0 => .disable, + 1 => .prefer, + 2 => .require, + 3 => .verify_ca, + 4 => .verify_full, + else => .disable, + }; + + const tls_object = arguments[6]; + + var tls_config: jsc.API.ServerConfig.SSLConfig = .{}; + var tls_ctx: ?*uws.SocketContext = null; + if (ssl_mode != .disable) { + tls_config = if (tls_object.isBoolean() and tls_object.toBoolean()) + .{} + else if (tls_object.isObject()) + (jsc.API.ServerConfig.SSLConfig.fromJS(vm, globalObject, tls_object) catch return .zero) orelse .{} + else { + return globalObject.throwInvalidArguments("tls must be a boolean or an object", .{}); + }; + + if (globalObject.hasException()) { + tls_config.deinit(); + return .zero; + } + + // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match + const original_reject_unauthorized = tls_config.reject_unauthorized; + tls_config.reject_unauthorized = 0; + tls_config.request_cert = 1; + + // We create it right here so we can throw errors early. + const context_options = tls_config.asUSockets(); + var err: uws.create_bun_socket_error_t = .none; + tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*@This()), context_options, &err) orelse { + if (err != .none) { + return globalObject.throw("failed to create TLS context", .{}); + } else { + return globalObject.throwValue(err.toJS(globalObject)); + } + }; + + // restore the original reject_unauthorized + tls_config.reject_unauthorized = original_reject_unauthorized; + if (err != .none) { + tls_config.deinit(); + if (tls_ctx) |ctx| { + ctx.deinit(true); + } + return globalObject.throwValue(err.toJS(globalObject)); + } + + uws.NewSocketHandler(true).configure(tls_ctx.?, true, *@This(), SocketHandler(true)); + } + + var username: []const u8 = ""; + var password: []const u8 = ""; + var database: []const u8 = ""; + var options: []const u8 = ""; + var path: []const u8 = ""; + + const options_str = try arguments[7].toBunString(globalObject); + defer options_str.deref(); + + const path_str = try arguments[8].toBunString(globalObject); + defer path_str.deref(); + + const options_buf: []u8 = brk: { + var b = bun.StringBuilder{}; + b.cap += username_str.utf8ByteLength() + 1 + password_str.utf8ByteLength() + 1 + database_str.utf8ByteLength() + 1 + options_str.utf8ByteLength() + 1 + path_str.utf8ByteLength() + 1; + + b.allocate(bun.default_allocator) catch {}; + var u = username_str.toUTF8WithoutRef(bun.default_allocator); + defer u.deinit(); + username = b.append(u.slice()); + + var p = password_str.toUTF8WithoutRef(bun.default_allocator); + defer p.deinit(); + password = b.append(p.slice()); + + var d = database_str.toUTF8WithoutRef(bun.default_allocator); + defer d.deinit(); + database = b.append(d.slice()); + + var o = options_str.toUTF8WithoutRef(bun.default_allocator); + defer o.deinit(); + options = b.append(o.slice()); + + var _path = path_str.toUTF8WithoutRef(bun.default_allocator); + defer _path.deinit(); + path = b.append(_path.slice()); + + break :brk b.allocatedSlice(); + }; + + const on_connect = arguments[9]; + const on_close = arguments[10]; + const idle_timeout = arguments[11].toInt32(); + const connection_timeout = arguments[12].toInt32(); + const max_lifetime = arguments[13].toInt32(); + const use_unnamed_prepared_statements = arguments[14].asBoolean(); + // MySQL doesn't support unnamed prepared statements + _ = use_unnamed_prepared_statements; + + var ptr = bun.new(JSMySQLConnection, .{ + .#globalObject = globalObject, + .#vm = vm, + .idle_timeout_interval_ms = @intCast(idle_timeout), + .connection_timeout_ms = @intCast(connection_timeout), + .max_lifetime_interval_ms = @intCast(max_lifetime), + .#connection = MySQLConnection.init( + database, + username, + password, + options, + options_buf, + tls_config, + tls_ctx, + ssl_mode, + ), + }); + + { + const hostname = hostname_str.toUTF8(bun.default_allocator); + defer hostname.deinit(); + + const ctx = vm.rareData().mysql_context.tcp orelse brk: { + const ctx_ = uws.SocketContext.createNoSSLContext(vm.uwsLoop(), @sizeOf(*@This())).?; + uws.NewSocketHandler(false).configure(ctx_, true, *@This(), SocketHandler(false)); + vm.rareData().mysql_context.tcp = ctx_; + break :brk ctx_; + }; + + if (path.len > 0) { + ptr.#connection.setSocket(.{ + .SocketTCP = uws.SocketTCP.connectUnixAnon(path, ctx, ptr, false) catch |err| { + ptr.deref(); + return globalObject.throwError(err, "failed to connect to postgresql"); + }, + }); + } else { + ptr.#connection.setSocket(.{ + .SocketTCP = uws.SocketTCP.connectAnon(hostname.slice(), port, ctx, ptr, false) catch |err| { + ptr.deref(); + return globalObject.throwError(err, "failed to connect to mysql"); + }, + }); + } + } + ptr.#connection.status = .connecting; + ptr.resetConnectionTimeout(); + ptr.#poll_ref.ref(vm); + const js_value = ptr.toJS(globalObject); + js_value.ensureStillAlive(); + ptr.#js_value.setStrong(js_value, globalObject); + js.onconnectSetCached(js_value, globalObject, on_connect); + js.oncloseSetCached(js_value, globalObject, on_close); + + return js_value; +} + +pub fn getQueries(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + if (js.queriesGetCached(thisValue)) |value| { + return value; + } + + const array = try jsc.JSValue.createEmptyArray(globalObject, 0); + js.queriesSetCached(thisValue, globalObject, array); + + return array; +} + +pub fn getConnected(this: *@This(), _: *jsc.JSGlobalObject) JSValue { + return JSValue.jsBoolean(this.#connection.status == .connected); +} + +pub fn getOnConnect(_: *@This(), thisValue: jsc.JSValue, _: *jsc.JSGlobalObject) jsc.JSValue { + if (js.onconnectGetCached(thisValue)) |value| { + return value; + } + + return .js_undefined; +} + +pub fn setOnConnect(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject, value: jsc.JSValue) void { + js.onconnectSetCached(thisValue, globalObject, value); +} + +pub fn getOnClose(_: *@This(), thisValue: jsc.JSValue, _: *jsc.JSGlobalObject) jsc.JSValue { + if (js.oncloseGetCached(thisValue)) |value| { + return value; + } + + return .js_undefined; +} + +pub fn setOnClose(_: *@This(), thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject, value: jsc.JSValue) void { + js.oncloseSetCached(thisValue, globalObject, value); +} + +pub fn doRef(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + this.#poll_ref.ref(this.#vm); + return .js_undefined; +} + +pub fn doUnref(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + this.#poll_ref.unref(this.#vm); + return .js_undefined; +} + +pub fn doFlush(this: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + this.registerAutoFlusher(); + return .js_undefined; +} + +pub fn doClose(this: *@This(), globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + _ = globalObject; + this.stopTimers(); + + defer this.updateReferenceType(); + this.#connection.cleanQueueAndClose(null, this.getQueriesArray()); + return .js_undefined; +} + +fn consumeOnConnectCallback(this: *const @This(), globalObject: *jsc.JSGlobalObject) ?jsc.JSValue { + if (this.#vm.isShuttingDown()) return null; + if (this.#js_value.tryGet()) |value| { + const on_connect = js.onconnectGetCached(value) orelse return null; + js.onconnectSetCached(value, globalObject, .zero); + return on_connect; + } + return null; +} + +fn consumeOnCloseCallback(this: *const @This(), globalObject: *jsc.JSGlobalObject) ?jsc.JSValue { + if (this.#vm.isShuttingDown()) return null; + if (this.#js_value.tryGet()) |value| { + const on_close = js.oncloseGetCached(value) orelse return null; + js.oncloseSetCached(value, globalObject, .zero); + return on_close; + } + return null; +} + +pub fn getQueriesArray(this: *@This()) JSValue { + if (this.#vm.isShuttingDown()) return .js_undefined; + if (this.#js_value.tryGet()) |value| { + return js.queriesGetCached(value) orelse .js_undefined; + } + + return .js_undefined; +} + +pub inline fn isAbleToWrite(this: *@This()) bool { + return this.#connection.isAbleToWrite(); +} +pub inline fn isConnected(this: *@This()) bool { + return this.#connection.status == .connected; +} +pub inline fn canPipeline(this: *@This()) bool { + return this.#connection.canPipeline(); +} +pub inline fn canPrepareQuery(this: *@This()) bool { + return this.#connection.canPrepareQuery(); +} +pub inline fn canExecuteQuery(this: *@This()) bool { + return this.#connection.canExecuteQuery(); +} +pub inline fn getWriter(this: *@This()) NewWriter(MySQLConnection.Writer) { + return this.#connection.writer(); +} +fn failFmt(this: *@This(), error_code: AnyMySQLError.Error, comptime fmt: [:0]const u8, args: anytype) void { + const message = bun.handleOom(std.fmt.allocPrint(bun.default_allocator, fmt, args)); + defer bun.default_allocator.free(message); + + const err = AnyMySQLError.mysqlErrorToJS(this.#globalObject, message, error_code); + this.failWithJSValue(err); +} + +fn failWithJSValue(this: *@This(), value: JSValue) void { + this.ref(); + + defer { + if (this.#vm.isShuttingDown()) { + this.#connection.close(); + } else { + this.#connection.cleanQueueAndClose(value, this.getQueriesArray()); + } + this.updateReferenceType(); + this.deref(); + } + this.stopTimers(); + + if (this.#connection.status == .failed) return; + + this.#connection.status = .failed; + if (this.#vm.isShuttingDown()) return; + + const on_close = this.consumeOnCloseCallback(this.#globalObject) orelse return; + on_close.ensureStillAlive(); + const loop = this.#vm.eventLoop(); + // loop.enter(); + // defer loop.exit(); + this.ensureJSValueIsAlive(); + var js_error = value.toError() orelse value; + if (js_error == .zero) { + js_error = AnyMySQLError.mysqlErrorToJS(this.#globalObject, "Connection closed", error.ConnectionClosed); + } + js_error.ensureStillAlive(); + + const queries_array = this.getQueriesArray(); + queries_array.ensureStillAlive(); + // this.#globalObject.queueMicrotask(on_close, &[_]JSValue{ js_error, queries_array }); + loop.runCallback(on_close, this.#globalObject, .js_undefined, &[_]JSValue{ js_error, queries_array }); +} + +fn fail(this: *@This(), message: []const u8, err: AnyMySQLError.Error) void { + const instance = AnyMySQLError.mysqlErrorToJS(this.#globalObject, message, err); + this.failWithJSValue(instance); +} +pub fn onConnectionEstabilished(this: *@This()) void { + if (this.#vm.isShuttingDown()) return; + const on_connect = this.consumeOnConnectCallback(this.#globalObject) orelse return; + on_connect.ensureStillAlive(); + var js_value = this.#js_value.tryGet() orelse .js_undefined; + js_value.ensureStillAlive(); + // this.#globalObject.queueMicrotask(on_connect, &[_]JSValue{ JSValue.jsNull(), js_value }); + const loop = this.#vm.eventLoop(); + loop.runCallback(on_connect, this.#globalObject, .js_undefined, &[_]JSValue{ JSValue.jsNull(), js_value }); + this.#poll_ref.unref(this.#vm); +} +pub fn onQueryResult(this: *@This(), request: *JSMySQLQuery, result: MySQLQueryResult) void { + request.resolve(this.getQueriesArray(), result); +} +pub fn onResultRow(this: *@This(), request: *JSMySQLQuery, statement: *MySQLStatement, Context: type, reader: NewReader(Context)) error{ShortRead}!void { + const result_mode = request.getResultMode(); + var stack_fallback = std.heap.stackFallback(4096, bun.default_allocator); + const allocator = stack_fallback.get(); + var row = ResultSet.Row{ + .globalObject = this.#globalObject, + .columns = statement.columns, + .binary = !request.isSimple(), + .raw = result_mode == .raw, + .bigint = request.isBigintSupported(), + }; + var structure: JSValue = .js_undefined; + var cached_structure: ?CachedStructure = null; + switch (result_mode) { + .objects => { + cached_structure = if (this.#js_value.tryGet()) |value| statement.structure(value, this.#globalObject) else null; + structure = cached_structure.?.jsValue() orelse .js_undefined; + }, + .raw, .values => { + // no need to check for duplicate fields or structure + }, + } + defer row.deinit(allocator); + row.decode(allocator, reader) catch |err| { + if (err == error.ShortRead) { + return error.ShortRead; + } + this.#connection.queue.markCurrentRequestAsFinished(request); + request.reject(this.getQueriesArray(), err); + return; + }; + const pending_value = request.getPendingValue() orelse .js_undefined; + // Process row data + const row_value = row.toJS( + this.#globalObject, + pending_value, + structure, + statement.fields_flags, + result_mode, + cached_structure, + ); + if (this.#globalObject.tryTakeException()) |err| { + this.#connection.queue.markCurrentRequestAsFinished(request); + request.rejectWithJSValue(this.getQueriesArray(), err); + return; + } + statement.result_count += 1; + + if (pending_value.isEmptyOrUndefinedOrNull()) { + request.setPendingValue(row_value); + } +} +pub fn onError(this: *@This(), request: ?*JSMySQLQuery, err: AnyMySQLError.Error) void { + if (request) |_request| { + if (this.#vm.isShuttingDown()) { + _request.markAsFailed(); + return; + } + if (this.#globalObject.tryTakeException()) |err_| { + _request.rejectWithJSValue(this.getQueriesArray(), err_); + } else { + _request.reject(this.getQueriesArray(), err); + } + } else { + if (this.#vm.isShuttingDown()) { + this.close(); + return; + } + if (this.#globalObject.tryTakeException()) |err_| { + this.failWithJSValue(err_); + } else { + this.fail("Connection closed", err); + } + } +} +pub fn onErrorPacket( + this: *@This(), + request: ?*JSMySQLQuery, + err: ErrorPacket, +) void { + if (request) |_request| { + if (this.#vm.isShuttingDown()) { + _request.markAsFailed(); + } else { + if (this.#globalObject.tryTakeException()) |err_| { + _request.rejectWithJSValue(this.getQueriesArray(), err_); + } else { + _request.rejectWithJSValue(this.getQueriesArray(), err.toJS(this.#globalObject)); + } + } + } else { + if (this.#vm.isShuttingDown()) { + this.close(); + return; + } + if (this.#globalObject.tryTakeException()) |err_| { + this.failWithJSValue(err_); + } else { + this.failWithJSValue(err.toJS(this.#globalObject)); + } + } +} + +pub fn getStatementFromSignatureHash(this: *@This(), signature_hash: u64) !MySQLConnection.PreparedStatementsMapGetOrPutResult { + return try this.#connection.statements.getOrPut(bun.default_allocator, signature_hash); +} + +const RefCount = bun.ptr.RefCount(@This(), "__ref_count", deinit, .{}); +pub const js = jsc.Codegen.JSMySQLConnection; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; +pub const toJS = js.toJS; + +pub const Writer = MySQLConnection.Writer; + +const debug = bun.Output.scoped(.MySQLConnection, .visible); + +const AnyMySQLError = @import("../protocol/AnyMySQLError.zig"); +const CachedStructure = @import("../../shared/CachedStructure.zig"); +const ErrorPacket = @import("../protocol/ErrorPacket.zig"); +const JSMySQLQuery = @import("./JSMySQLQuery.zig"); +const MySQLConnection = @import("../MySQLConnection.zig"); +const MySQLQueryResult = @import("../MySQLQueryResult.zig"); +const MySQLStatement = @import("../MySQLStatement.zig"); +const ResultSet = @import("../protocol/ResultSet.zig"); +const std = @import("std"); +const NewReader = @import("../protocol/NewReader.zig").NewReader; +const NewWriter = @import("../protocol/NewWriter.zig").NewWriter; +const SSLMode = @import("../SSLMode.zig").SSLMode; + +const bun = @import("bun"); +const uws = bun.uws; + +const jsc = bun.jsc; +const JSGlobalObject = jsc.JSGlobalObject; +const JSValue = jsc.JSValue; +const AutoFlusher = jsc.WebCore.AutoFlusher; diff --git a/src/sql/mysql/js/JSMySQLQuery.zig b/src/sql/mysql/js/JSMySQLQuery.zig new file mode 100644 index 0000000000..75fd7ea83e --- /dev/null +++ b/src/sql/mysql/js/JSMySQLQuery.zig @@ -0,0 +1,402 @@ +const JSMySQLQuery = @This(); +const RefCount = bun.ptr.RefCount(@This(), "__ref_count", deinit, .{}); + +#thisValue: JSRef = JSRef.empty(), +// unfortunally we cannot use #ref_count here +__ref_count: RefCount = RefCount.init(), +#vm: *jsc.VirtualMachine, +#globalObject: *jsc.JSGlobalObject, +#query: MySQLQuery, + +pub const ref = RefCount.ref; +pub const deref = RefCount.deref; + +pub fn estimatedSize(this: *@This()) usize { + _ = this; + return @sizeOf(@This()); +} + +pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*@This() { + _ = callframe; + return globalThis.throwInvalidArguments("MySQLQuery cannot be constructed directly", .{}); +} + +fn deinit(this: *@This()) void { + this.#query.cleanup(); + bun.destroy(this); +} + +pub fn finalize(this: *@This()) void { + debug("MySQLQuery finalize", .{}); + + this.#thisValue.finalize(); + this.deref(); +} + +pub fn createInstance(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const arguments = callframe.arguments(); + var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments); + defer args.deinit(); + const query = args.nextEat() orelse { + return globalThis.throw("query must be a string", .{}); + }; + const values = args.nextEat() orelse { + return globalThis.throw("values must be an array", .{}); + }; + + if (!query.isString()) { + return globalThis.throw("query must be a string", .{}); + } + + if (values.jsType() != .Array) { + return globalThis.throw("values must be an array", .{}); + } + + const pending_value: JSValue = args.nextEat() orelse .js_undefined; + const columns: JSValue = args.nextEat() orelse .js_undefined; + const js_bigint: JSValue = args.nextEat() orelse .false; + const js_simple: JSValue = args.nextEat() orelse .false; + + const bigint = js_bigint.isBoolean() and js_bigint.asBoolean(); + const simple = js_simple.isBoolean() and js_simple.asBoolean(); + if (simple) { + if (try values.getLength(globalThis) > 0) { + return globalThis.throwInvalidArguments("simple query cannot have parameters", .{}); + } + if (try query.getLength(globalThis) >= std.math.maxInt(i32)) { + return globalThis.throwInvalidArguments("query is too long", .{}); + } + } + if (!pending_value.jsType().isArrayLike()) { + return globalThis.throwInvalidArgumentType("query", "pendingValue", "Array"); + } + + var this = bun.new(@This(), .{ + .#query = MySQLQuery.init( + try query.toBunString(globalThis), + bigint, + simple, + ), + .#globalObject = globalThis, + .#vm = globalThis.bunVM(), + }); + + const this_value = this.toJS(globalThis); + this_value.ensureStillAlive(); + this.#thisValue.setWeak(this_value); + + this.setBinding(values); + this.setPendingValue(pending_value); + if (!columns.isUndefined()) { + this.setColumns(columns); + } + + return this_value; +} + +pub fn doRun(this: *@This(), globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { + debug("doRun", .{}); + this.ref(); + defer this.deref(); + + var arguments = callframe.arguments(); + if (arguments.len < 2) { + return globalObject.throwInvalidArguments("run must be called with 2 arguments connection and target", .{}); + } + const connection: *MySQLConnection = arguments[0].as(MySQLConnection) orelse { + return globalObject.throw("connection must be a MySQLConnection", .{}); + }; + var target = arguments[1]; + if (!target.isObject()) { + return globalObject.throwInvalidArgumentType("run", "query", "Query"); + } + this.setTarget(target); + this.run(connection) catch |err| { + if (!globalObject.hasException()) { + return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to execute query", err)); + } + return error.JSError; + }; + connection.enqueueRequest(this); + return .js_undefined; +} +pub fn doCancel(_: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + // TODO: we can cancel a query that is pending aka not pipelined yet we just need fail it + // if is running is not worth/viable to cancel the whole connection + return .js_undefined; +} + +pub fn doDone(_: *@This(), _: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue { + // TODO: investigate why this function is needed + return .js_undefined; +} + +pub fn setModeFromJS(this: *@This(), globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { + const js_mode = callframe.argument(0); + if (js_mode.isEmptyOrUndefinedOrNull() or !js_mode.isNumber()) { + return globalObject.throwInvalidArgumentType("setMode", "mode", "Number"); + } + + const mode_value = try js_mode.coerce(i32, globalObject); + const mode = std.meta.intToEnum(SQLQueryResultMode, mode_value) catch { + return globalObject.throwInvalidArgumentTypeValue("mode", "Number", js_mode); + }; + this.#query.setResultMode(mode); + return .js_undefined; +} + +pub fn setPendingValueFromJS(this: *@This(), _: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { + const result = callframe.argument(0); + this.setPendingValue(result); + return .js_undefined; +} + +pub fn resolve( + this: *@This(), + queries_array: JSValue, + result: MySQLQueryResult, +) void { + this.ref(); + const is_last_result = result.is_last_result; + defer { + if (this.#thisValue.isNotEmpty() and is_last_result) { + this.#thisValue.downgrade(); + } + this.deref(); + } + + if (!this.#query.result(is_last_result)) { + return; + } + if (this.#vm.isShuttingDown()) { + return; + } + + const targetValue = this.getTarget() orelse return; + const thisValue = this.#thisValue.tryGet() orelse return; + thisValue.ensureStillAlive(); + const tag: CommandTag = .{ .SELECT = result.result_count }; + const js_tag = tag.toJSTag(this.#globalObject) catch return bun.assertf(false, "in MySQLQuery Tag should always be a number", .{}); + js_tag.ensureStillAlive(); + + const function = this.#vm.rareData().mysql_context.onQueryResolveFn.get() orelse return; + bun.assertf(function.isCallable(), "onQueryResolveFn is not callable", .{}); + + const event_loop = this.#vm.eventLoop(); + + const pending_value = this.getPendingValue() orelse .js_undefined; + pending_value.ensureStillAlive(); + this.setPendingValue(.js_undefined); + + event_loop.runCallback(function, this.#globalObject, thisValue, &.{ + targetValue, + pending_value, + js_tag, + tag.toJSNumber(), + if (queries_array == .zero) .js_undefined else queries_array, + JSValue.jsBoolean(is_last_result), + JSValue.jsNumber(result.last_insert_id), + JSValue.jsNumber(result.affected_rows), + }); +} + +pub fn markAsFailed(this: *@This()) void { + // Attention: we cannot touch JS here + // If you need to touch JS, you wanna to use reject or rejectWithJSValue instead + this.ref(); + defer this.deref(); + if (this.#thisValue.isNotEmpty()) { + this.#thisValue.downgrade(); + } + _ = this.#query.fail(); +} + +pub fn reject(this: *@This(), queries_array: JSValue, err: AnyMySQLError.Error) void { + if (this.#vm.isShuttingDown()) { + this.markAsFailed(); + return; + } + if (this.#globalObject.tryTakeException()) |err_| { + this.rejectWithJSValue(queries_array, err_); + } else { + const instance = AnyMySQLError.mysqlErrorToJS(this.#globalObject, "Failed to bind query", err); + instance.ensureStillAlive(); + this.rejectWithJSValue(queries_array, instance); + } +} + +pub fn rejectWithJSValue(this: *@This(), queries_array: JSValue, err: JSValue) void { + this.ref(); + + defer { + if (this.#thisValue.isNotEmpty()) { + this.#thisValue.downgrade(); + } + this.deref(); + } + if (!this.#query.fail()) { + return; + } + + if (this.#vm.isShuttingDown()) { + return; + } + const targetValue = this.getTarget() orelse return; + + var js_error = err.toError() orelse err; + if (js_error == .zero) { + js_error = AnyMySQLError.mysqlErrorToJS(this.#globalObject, "Query failed", error.UnknownError); + } + bun.assertf(js_error != .zero, "js_error is zero", .{}); + js_error.ensureStillAlive(); + const function = this.#vm.rareData().mysql_context.onQueryRejectFn.get() orelse return; + bun.assertf(function.isCallable(), "onQueryRejectFn is not callable", .{}); + const event_loop = this.#vm.eventLoop(); + const js_array = if (queries_array == .zero) .js_undefined else queries_array; + js_array.ensureStillAlive(); + event_loop.runCallback(function, this.#globalObject, this.#thisValue.tryGet() orelse return, &.{ + targetValue, + js_error, + js_array, + }); +} + +pub fn run(this: *@This(), connection: *MySQLConnection) AnyMySQLError.Error!void { + if (this.#vm.isShuttingDown()) { + debug("run cannot run a query if the VM is shutting down", .{}); + // cannot run a query if the VM is shutting down + return; + } + if (!this.#query.isPending() or this.#query.isBeingPrepared()) { + debug("run already running or being prepared", .{}); + // already running or completed + return; + } + const globalObject = this.#globalObject; + this.#thisValue.upgrade(globalObject); + errdefer { + this.#thisValue.downgrade(); + _ = this.#query.fail(); + } + + const columns_value = this.getColumns() orelse .js_undefined; + const binding_value = this.getBinding() orelse .js_undefined; + this.#query.runQuery(connection, globalObject, columns_value, binding_value) catch |err| { + debug("run failed to execute query", .{}); + if (!globalObject.hasException()) + return globalObject.throwValue(AnyMySQLError.mysqlErrorToJS(globalObject, "failed to execute query", err)); + return error.JSError; + }; +} +pub inline fn isCompleted(this: *@This()) bool { + return this.#query.isCompleted(); +} +pub inline fn isRunning(this: *@This()) bool { + return this.#query.isRunning(); +} +pub inline fn isPending(this: *@This()) bool { + return this.#query.isPending(); +} +pub inline fn isBeingPrepared(this: *@This()) bool { + return this.#query.isBeingPrepared(); +} +pub inline fn isPipelined(this: *@This()) bool { + return this.#query.isPipelined(); +} +pub inline fn isSimple(this: *@This()) bool { + return this.#query.isSimple(); +} +pub inline fn isBigintSupported(this: *@This()) bool { + return this.#query.isBigintSupported(); +} +pub inline fn getResultMode(this: *@This()) SQLQueryResultMode { + return this.#query.getResultMode(); +} +// TODO: isolate statement modification away from the connection +pub fn getStatement(this: *@This()) ?*MySQLStatement { + return this.#query.getStatement(); +} + +pub fn markAsPrepared(this: *@This()) void { + this.#query.markAsPrepared(); +} + +pub inline fn setPendingValue(this: *@This(), result: JSValue) void { + if (this.#vm.isShuttingDown()) return; + if (this.#thisValue.tryGet()) |value| { + js.pendingValueSetCached(value, this.#globalObject, result); + } +} +pub inline fn getPendingValue(this: *@This()) ?JSValue { + if (this.#vm.isShuttingDown()) return null; + if (this.#thisValue.tryGet()) |value| { + return js.pendingValueGetCached(value); + } + return null; +} + +inline fn setTarget(this: *@This(), result: JSValue) void { + if (this.#vm.isShuttingDown()) return; + if (this.#thisValue.tryGet()) |value| { + js.targetSetCached(value, this.#globalObject, result); + } +} +inline fn getTarget(this: *@This()) ?JSValue { + if (this.#vm.isShuttingDown()) return null; + if (this.#thisValue.tryGet()) |value| { + return js.targetGetCached(value); + } + return null; +} + +inline fn setColumns(this: *@This(), result: JSValue) void { + if (this.#vm.isShuttingDown()) return; + if (this.#thisValue.tryGet()) |value| { + js.columnsSetCached(value, this.#globalObject, result); + } +} +inline fn getColumns(this: *@This()) ?JSValue { + if (this.#vm.isShuttingDown()) return null; + + if (this.#thisValue.tryGet()) |value| { + return js.columnsGetCached(value); + } + return null; +} +inline fn setBinding(this: *@This(), result: JSValue) void { + if (this.#vm.isShuttingDown()) return; + if (this.#thisValue.tryGet()) |value| { + js.bindingSetCached(value, this.#globalObject, result); + } +} +inline fn getBinding(this: *@This()) ?JSValue { + if (this.#vm.isShuttingDown()) return null; + if (this.#thisValue.tryGet()) |value| { + return js.bindingGetCached(value); + } + return null; +} +comptime { + @export(&jsc.toJSHostFn(createInstance), .{ .name = "MySQLQuery__createInstance" }); +} + +pub const js = jsc.Codegen.JSMySQLQuery; +pub const fromJS = js.fromJS; +pub const fromJSDirect = js.fromJSDirect; +pub const toJS = js.toJS; + +const debug = bun.Output.scoped(.MySQLQuery, .visible); + +const AnyMySQLError = @import("../protocol/AnyMySQLError.zig"); +const MySQLConnection = @import("./JSMySQLConnection.zig"); +const MySQLQuery = @import("../MySQLQuery.zig"); +const MySQLQueryResult = @import("../MySQLQueryResult.zig"); +const MySQLStatement = @import("../MySQLStatement.zig"); +const bun = @import("bun"); +const std = @import("std"); +const CommandTag = @import("../../postgres/CommandTag.zig").CommandTag; +const SQLQueryResultMode = @import("../../shared/SQLQueryResultMode.zig").SQLQueryResultMode; + +const jsc = bun.jsc; +const JSRef = jsc.JSRef; +const JSValue = jsc.JSValue; diff --git a/src/sql/mysql/protocol/AnyMySQLError.zig b/src/sql/mysql/protocol/AnyMySQLError.zig index 2bcea88279..f93a28f383 100644 --- a/src/sql/mysql/protocol/AnyMySQLError.zig +++ b/src/sql/mysql/protocol/AnyMySQLError.zig @@ -33,6 +33,8 @@ pub const Error = error{ InvalidErrorPacket, UnexpectedPacket, ShortRead, + UnknownError, + InvalidState, }; pub fn mysqlErrorToJS(globalObject: *jsc.JSGlobalObject, message: ?[]const u8, err: Error) JSValue { @@ -64,6 +66,8 @@ pub fn mysqlErrorToJS(globalObject: *jsc.JSGlobalObject, message: ?[]const u8, e error.MissingAuthData => "ERR_MYSQL_MISSING_AUTH_DATA", error.FailedToEncryptPassword => "ERR_MYSQL_FAILED_TO_ENCRYPT_PASSWORD", error.InvalidPublicKey => "ERR_MYSQL_INVALID_PUBLIC_KEY", + error.UnknownError => "ERR_MYSQL_UNKNOWN_ERROR", + error.InvalidState => "ERR_MYSQL_INVALID_STATE", error.JSError => { return globalObject.takeException(error.JSError); }, diff --git a/src/sql/mysql/protocol/ErrorPacket.zig b/src/sql/mysql/protocol/ErrorPacket.zig index 5e16c7c97f..6fb6ef9b37 100644 --- a/src/sql/mysql/protocol/ErrorPacket.zig +++ b/src/sql/mysql/protocol/ErrorPacket.zig @@ -19,7 +19,7 @@ pub fn createMySQLError( message: []const u8, options: MySQLErrorOptions, ) bun.JSError!JSValue { - const opts_obj = JSValue.createEmptyObject(globalObject, 18); + const opts_obj = JSValue.createEmptyObject(globalObject, 0); opts_obj.ensureStillAlive(); opts_obj.put(globalObject, JSC.ZigString.static("code"), try bun.String.createUTF8ForJS(globalObject, options.code)); if (options.errno) |errno| { diff --git a/src/sql/postgres/CommandTag.zig b/src/sql/postgres/CommandTag.zig index 47f70048c2..2fd987ab2c 100644 --- a/src/sql/postgres/CommandTag.zig +++ b/src/sql/postgres/CommandTag.zig @@ -29,7 +29,7 @@ pub const CommandTag = union(enum) { other: []const u8, - pub fn toJSTag(this: CommandTag, globalObject: *jsc.JSGlobalObject) JSValue { + pub fn toJSTag(this: CommandTag, globalObject: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { return switch (this) { .INSERT => JSValue.jsNumber(1), .DELETE => JSValue.jsNumber(2), @@ -39,7 +39,7 @@ pub const CommandTag = union(enum) { .MOVE => JSValue.jsNumber(6), .FETCH => JSValue.jsNumber(7), .COPY => JSValue.jsNumber(8), - .other => |tag| jsc.ZigString.init(tag).toJS(globalObject), + .other => |tag| bun.String.createUTF8ForJS(globalObject, tag), }; } diff --git a/src/sql/postgres/PostgresSQLConnection.zig b/src/sql/postgres/PostgresSQLConnection.zig index 01a5fba6bd..0d2445a5ff 100644 --- a/src/sql/postgres/PostgresSQLConnection.zig +++ b/src/sql/postgres/PostgresSQLConnection.zig @@ -219,7 +219,7 @@ pub fn onConnectionTimeout(this: *PostgresSQLConnection) bun.api.Timer.EventLoop this.failFmt("ERR_POSTGRES_CONNECTION_TIMEOUT", "Connection timeout after {}", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); }, .sent_startup_message => { - this.failFmt("ERR_POSTGRES_CONNECTION_TIMEOUT", "Connection timed out after {} (sent startup message, but never received response)", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); + this.failFmt("ERR_POSTGRES_CONNECTION_TIMEOUT", "Connection timeout after {} (sent startup message, but never received response)", .{bun.fmt.fmtDurationOneDecimal(@as(u64, this.connection_timeout_ms) *| std.time.ns_per_ms)}); }, } return .disarm; @@ -311,7 +311,7 @@ pub fn failWithJSValue(this: *PostgresSQLConnection, value: JSValue) void { this.stopTimers(); if (this.status == .failed) return; - this.setStatus(.failed); + this.status = .failed; this.ref(); defer this.deref(); @@ -321,12 +321,17 @@ pub fn failWithJSValue(this: *PostgresSQLConnection, value: JSValue) void { const loop = this.vm.eventLoop(); loop.enter(); + var js_error = value.toError() orelse value; + if (js_error == .zero) { + js_error = postgresErrorToJS(this.globalObject, "Connection closed", error.ConnectionClosed); + } + js_error.ensureStillAlive(); defer loop.exit(); _ = on_close.call( this.globalObject, - this.js_value, + .js_undefined, &[_]JSValue{ - value.toError() orelse value, + js_error, this.getQueriesArray(), }, ) catch |e| this.globalObject.reportActiveExceptionAsUnhandled(e); @@ -1350,6 +1355,9 @@ fn advance(this: *PostgresSQLConnection) void { } pub fn getQueriesArray(this: *const PostgresSQLConnection) JSValue { + if (this.js_value.isEmptyOrUndefinedOrNull()) { + return .js_undefined; + } return js.queriesGetCached(this.js_value) orelse .js_undefined; } diff --git a/src/sql/postgres/PostgresSQLQuery.zig b/src/sql/postgres/PostgresSQLQuery.zig index 9860b0273a..c8d829a765 100644 --- a/src/sql/postgres/PostgresSQLQuery.zig +++ b/src/sql/postgres/PostgresSQLQuery.zig @@ -1,5 +1,5 @@ const PostgresSQLQuery = @This(); -const RefCount = bun.ptr.ThreadSafeRefCount(@This(), "ref_count", deinit, .{}); +const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); statement: ?*PostgresSQLStatement = null, query: bun.String = bun.String.empty, cursor_name: bun.String = bun.String.empty, @@ -23,9 +23,9 @@ flags: packed struct(u8) { pub const ref = RefCount.ref; pub const deref = RefCount.deref; -pub fn getTarget(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, clean_target: bool) jsc.JSValue { - const thisValue = this.thisValue.tryGet() orelse return .zero; - const target = js.targetGetCached(thisValue) orelse return .zero; +pub fn getTarget(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, clean_target: bool) ?jsc.JSValue { + const thisValue = this.thisValue.tryGet() orelse return null; + const target = js.targetGetCached(thisValue) orelse return null; if (clean_target) { js.targetSetCached(thisValue, globalObject, .zero); } @@ -51,12 +51,7 @@ pub const Status = enum(u8) { } }; -pub fn hasPendingActivity(this: *@This()) bool { - return this.ref_count.get() > 1; -} - pub fn deinit(this: *@This()) void { - this.thisValue.deinit(); if (this.statement) |statement| { statement.deref(); } @@ -67,11 +62,7 @@ pub fn deinit(this: *@This()) void { pub fn finalize(this: *@This()) void { debug("PostgresSQLQuery finalize", .{}); - if (this.thisValue == .weak) { - // clean up if is a weak reference, if is a strong reference we need to wait until the query is done - // if we are a strong reference, here is probably a bug because GC'd should not happen - this.thisValue.weak = .zero; - } + this.thisValue.finalize(); this.deref(); } @@ -84,12 +75,9 @@ pub fn onWriteFail( this.ref(); defer this.deref(); this.status = .fail; - const thisValue = this.thisValue.get(); - defer this.thisValue.deinit(); - const targetValue = this.getTarget(globalObject, true); - if (thisValue == .zero or targetValue == .zero) { - return; - } + const thisValue = this.thisValue.tryGet() orelse return; + defer this.thisValue.downgrade(); + const targetValue = this.getTarget(globalObject, true) orelse return; const vm = jsc.VirtualMachine.get(); const function = vm.rareData().postgresql_context.onQueryRejectFn.get().?; @@ -105,12 +93,9 @@ pub fn onJSError(this: *@This(), err: jsc.JSValue, globalObject: *jsc.JSGlobalOb this.ref(); defer this.deref(); this.status = .fail; - const thisValue = this.thisValue.get(); - defer this.thisValue.deinit(); - const targetValue = this.getTarget(globalObject, true); - if (thisValue == .zero or targetValue == .zero) { - return; - } + const thisValue = this.thisValue.tryGet() orelse return; + defer this.thisValue.downgrade(); + const targetValue = this.getTarget(globalObject, true) orelse return; var vm = jsc.VirtualMachine.get(); const function = vm.rareData().postgresql_context.onQueryRejectFn.get().?; @@ -145,31 +130,30 @@ fn consumePendingValue(thisValue: jsc.JSValue, globalObject: *jsc.JSGlobalObject pub fn onResult(this: *@This(), command_tag_str: []const u8, globalObject: *jsc.JSGlobalObject, connection: jsc.JSValue, is_last: bool) void { this.ref(); defer this.deref(); - - const thisValue = this.thisValue.get(); - const targetValue = this.getTarget(globalObject, is_last); if (is_last) { this.status = .success; } else { this.status = .partial_response; } + const tag = CommandTag.init(command_tag_str); + const js_tag = tag.toJSTag(globalObject) catch |e| return this.onJSError(globalObject.takeException(e), globalObject); + js_tag.ensureStillAlive(); + + const thisValue = this.thisValue.tryGet() orelse return; defer if (is_last) { allowGC(thisValue, globalObject); - this.thisValue.deinit(); + this.thisValue.downgrade(); }; - if (thisValue == .zero or targetValue == .zero) { - return; - } + const targetValue = this.getTarget(globalObject, is_last) orelse return; const vm = jsc.VirtualMachine.get(); const function = vm.rareData().postgresql_context.onQueryResolveFn.get().?; const event_loop = vm.eventLoop(); - const tag = CommandTag.init(command_tag_str); event_loop.runCallback(function, globalObject, thisValue, &.{ targetValue, consumePendingValue(thisValue, globalObject) orelse .js_undefined, - tag.toJSTag(globalObject), + js_tag, tag.toJSNumber(), if (connection == .zero) .js_undefined else PostgresSQLConnection.js.queriesGetCached(connection) orelse .js_undefined, JSValue.jsBoolean(is_last), @@ -257,13 +241,13 @@ pub fn doDone(this: *@This(), globalObject: *jsc.JSGlobalObject, _: *jsc.CallFra this.flags.is_done = true; return .js_undefined; } -pub fn setPendingValue(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { +pub fn setPendingValueFromJS(_: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { const result = callframe.argument(0); - const thisValue = this.thisValue.tryGet() orelse return .js_undefined; + const thisValue = callframe.this(); js.pendingValueSetCached(thisValue, globalObject, result); return .js_undefined; } -pub fn setMode(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { +pub fn setModeFromJS(this: *PostgresSQLQuery, globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue { const js_mode = callframe.argument(0); if (js_mode.isEmptyOrUndefinedOrNull() or !js_mode.isNumber()) { return globalObject.throwInvalidArgumentType("setMode", "mode", "Number"); diff --git a/src/sql/shared/ObjectIterator.zig b/src/sql/shared/ObjectIterator.zig index 1e3fbb6de5..fb5f7eeb29 100644 --- a/src/sql/shared/ObjectIterator.zig +++ b/src/sql/shared/ObjectIterator.zig @@ -11,6 +11,10 @@ array_length: usize = 0, any_failed: bool = false, pub fn next(this: *ObjectIterator) ?jsc.JSValue { + if (this.array.isEmptyOrUndefinedOrNull() or this.columns.isEmptyOrUndefinedOrNull()) { + this.any_failed = true; + return null; + } if (this.row_i >= this.array_length) { return null; } diff --git a/test/harness.ts b/test/harness.ts index 19ddb1d7b3..6254a0e8d9 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -862,11 +862,6 @@ export function isDockerEnabled(): boolean { return false; } - // TODO: investigate why its not starting on Linux arm64 - if ((isLinux && process.arch === "arm64") || isMacOS) { - return false; - } - try { const info = execSync(`${dockerCLI} info`, { stdio: ["ignore", "pipe", "inherit"] }); return info.toString().indexOf("Server Version:") !== -1; @@ -924,7 +919,7 @@ export async function describeWithContainer( return; } const { arch, platform } = process; - if ((archs && !archs?.includes(arch)) || platform === "win32" || platform === "darwin") { + if ((archs && !archs?.includes(arch)) || platform === "win32") { test.skip(`docker image is not supported on ${platform}/${arch}, skipped: ${image}`, () => {}); return false; } diff --git a/test/internal/ban-limits.json b/test/internal/ban-limits.json index 2766870d4b..6e915e32be 100644 --- a/test/internal/ban-limits.json +++ b/test/internal/ban-limits.json @@ -22,7 +22,7 @@ "allocator.ptr !=": 1, "allocator.ptr ==": 0, "global.hasException": 28, - "globalObject.hasException": 47, + "globalObject.hasException": 48, "globalThis.hasException": 133, "std.StringArrayHashMap(": 1, "std.StringArrayHashMapUnmanaged(": 11, diff --git a/test/js/sql/sql-mysql.test.ts b/test/js/sql/sql-mysql.test.ts index 42a5e5635b..9a635fb683 100644 --- a/test/js/sql/sql-mysql.test.ts +++ b/test/js/sql/sql-mysql.test.ts @@ -112,21 +112,23 @@ if (docker) { }); test("Idle timeout works at start", async () => { - const onclose = mock(); + const onClosePromise = Promise.withResolvers(); + const onclose = mock(err => { + onClosePromise.resolve(err); + }); const onconnect = mock(); await using sql = new SQL({ ...options, idle_timeout: 1, onconnect, onclose, + max: 1, }); - let error: any; - try { - await sql`select SLEEP(2)`; - } catch (e) { - error = e; - } - expect(error.code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`); + await sql.connect(); + const err = await onClosePromise.promise; + expect(err).toBeInstanceOf(SQL.SQLError); + expect(err).toBeInstanceOf(SQL.MySQLError); + expect((err as SQL.MySQLError).code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`); expect(onconnect).toHaveBeenCalled(); expect(onclose).toHaveBeenCalledTimes(1); }); @@ -140,8 +142,10 @@ if (docker) { await using sql = new SQL({ ...options, idle_timeout: 1, + connection_timeout: 5, onconnect, onclose, + max: 1, }); expect<[{ x: number }]>(await sql`select 123 as x`).toEqual([{ x: 123 }]); expect(onconnect).toHaveBeenCalledTimes(1); @@ -158,11 +162,12 @@ if (docker) { onClosePromise.resolve(err); }); const onconnect = mock(); - const sql = new SQL({ + await using sql = new SQL({ ...options, max_lifetime: 1, onconnect, onclose, + max: 1, }); let error: unknown; expect<[{ x: number }]>(await sql`select 1 as x`).toEqual([{ x: 1 }]); @@ -616,6 +621,7 @@ if (docker) { expect(e.message).toBe("password error"); } }); + test("Support dynamic async password function that throws", async () => { await using sql = new SQL({ ...options, @@ -633,6 +639,7 @@ if (docker) { expect(e.message).toBe("password error"); } }); + test("sql file", async () => { await using sql = new SQL(options); expect((await sql.file(rel("select.sql")))[0].x).toBe(1); @@ -869,31 +876,33 @@ if (docker) { sql.flush(); }); - test.each(["connect_timeout", "connectTimeout", "connectionTimeout", "connection_timeout"] as const)( - "connection timeout key %p throws", - async key => { - const server = net.createServer().listen(); + describe("timeouts", () => { + test.each(["connect_timeout", "connectTimeout", "connectionTimeout", "connection_timeout"] as const)( + "connection timeout key %p throws", + async key => { + const server = net.createServer().listen(); - const port = (server.address() as import("node:net").AddressInfo).port; + const port = (server.address() as import("node:net").AddressInfo).port; - const sql = new SQL({ adapter: "mysql", port, host: "127.0.0.1", [key]: 0.2 }); + const sql = new SQL({ adapter: "mysql", port, host: "127.0.0.1", max: 1, [key]: 0.2 }); - try { - await sql`select 1`; - throw new Error("should not reach"); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.code).toBe("ERR_MYSQL_CONNECTION_TIMEOUT"); - expect(e.message).toMatch(/Connection timed out after 200ms/); - } finally { - sql.close(); - server.close(); - } - }, - { - timeout: 1000, - }, - ); + try { + await sql`select 1`; + throw new Error("should not reach"); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect(e.code).toBe("ERR_MYSQL_CONNECTION_TIMEOUT"); + expect(e.message).toMatch(/Connection timeout after 200ms/); + } finally { + sql.close(); + server.close(); + } + }, + { + timeout: 1000, + }, + ); + }); test("Array returns rows as arrays of columns", async () => { await using sql = new SQL(options); return [(await sql`select CAST(1 AS SIGNED) as x`.values())[0][0], 1]; diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 1f063f468a..aa6c3da401 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -2685,7 +2685,7 @@ if (isDockerEnabled()) { expect(e).toBeInstanceOf(SQL.SQLError); expect(e).toBeInstanceOf(SQL.PostgresError); expect(e.code).toBe("ERR_POSTGRES_CONNECTION_TIMEOUT"); - expect(e.message).toMatch(/Connection timed out after 200ms/); + expect(e.message).toMatch(/Connection timeout after 200ms/); } finally { sql.close(); server.close();