From f6bbc401e6b8a0534f2cc2a8da4a0e96d8273469 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 12:13:20 -0800 Subject: [PATCH 01/35] wip --- src/bun.js/bindings/ErrorCode.ts | 1 + src/js/bun/sql.ts | 224 ++++++++++++++++++++++++++++++- src/sql/postgres.zig | 15 ++- 3 files changed, 233 insertions(+), 7 deletions(-) diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index f9d3d062829177..8d75753d2ab73c 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -167,6 +167,7 @@ const errors: ErrorCodeMapping = [ ["ERR_POSTGRES_IDLE_TIMEOUT", Error, "PostgresError"], ["ERR_POSTGRES_CONNECTION_TIMEOUT", Error, "PostgresError"], ["ERR_POSTGRES_LIFETIME_TIMEOUT", Error, "PostgresError"], + ["ERR_POSTGRES_INVALID_TRANSACTION_STATE", Error, "PostgresError"], // S3 ["ERR_S3_MISSING_CREDENTIALS", Error], diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index abe2a973cc6793..1cb05c8d57127e 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1,3 +1,5 @@ +const { hideFromStack } = require("internal/shared"); + const enum QueryStatus { active = 1 << 1, cancelled = 1 << 2, @@ -15,6 +17,11 @@ const enum SSLMode { verify_full = 4, } +function connectionClosedError() { + return $ERR_POSTGRES_CONNECTION_CLOSED("Connection closed"); +} +hideFromStack(connectionClosedError); + class SQLResultArray extends PublicArray { static [Symbol.toStringTag] = "SQLResults"; @@ -33,6 +40,7 @@ const _run = Symbol("run"); const _queryStatus = Symbol("status"); const _handler = Symbol("handler"); const PublicPromise = Promise; +type TransactionCallback = (sql: (strings: string, ...values: any[]) => Query) => Promise; const { createConnection: _createConnection, @@ -228,6 +236,99 @@ init( }, ); +function onQueryFinish(onClose) { + this.queries.delete(onClose); +} +class ConnectionWithState { + pool: ConnectionPool; + connection: ReturnType; + state: "pending" | "connected" | "closed" = "pending"; + storedError: Error | null = null; + queries: Set<(err: Error) => void> = new Set(); + + #onConnected(err, _) { + this.storedError = err; + this.state = err ? "closed" : "connected"; + this.pool.release(this); + } + #onClose(err) { + this.state = "closed"; + this.connection = null; + this.storedError = err; + + // remove from ready connections if its there + this.pool.readyConnections.delete(this); + const queries = new Set(this.queries); + this.queries.clear(); + for (const onClose of queries) { + onClose(err); + } + + // we need to reconnect + // lets use a retry strategy + // TODO: implement retry strategy, maxLifetime, idleTimeout, connectionTimeout + } + constructor(connectionInfo, pool: ConnectionPool) { + //TODO: maxLifetime, idleTimeout, connectionTimeout + this.connection = createConnection(connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); + this.state = "pending"; + this.pool = pool; + } + + bindQuery(query: Query, onClose: (err: Error) => void) { + this.queries.add(onClose); + // @ts-ignore + query.finally(onQueryFinish.bind(this, onClose)); + } +} +class ConnectionPool { + connectionInfo: any; + + connections: ConnectionWithState[]; + readyConnections: Set; + waitingQueue: Array<(err: Error | null, result: any) => void> = []; + constructor(connectionInfo) { + this.connectionInfo = connectionInfo; + + let max = connectionInfo.max; + if (max && typeof max !== "number") { + throw $ERR_INVALID_ARG_TYPE("max", "number", max); + } else { + max = 10; // same default as postgres.js + } + if (max < 1) { + throw $ERR_INVALID_ARG_VALUE("max", max, "must be greater than 0"); + } + + this.connections = new Array(max); + for (let i = 0; i < max; i++) { + this.connections[i] = new ConnectionWithState(this.connectionInfo, this); + } + this.readyConnections = new Set(); + } + + release(connection: ConnectionWithState) { + if (this.waitingQueue.length > 0) { + const pending = this.waitingQueue.shift(); + pending?.(null, connection); + } else { + this.readyConnections.add(connection); + } + } + + connect(onConnected: (err: Error | null, result: any) => void) { + if (this.readyConnections.size === 0) { + // wait for connection to be released + this.waitingQueue.push(onConnected); + return; + } + // unshift + const first = this.readyConnections.values().next().value; + this.readyConnections.delete(first); + onConnected(null, first); + } +} + function createConnection( { hostname, @@ -535,6 +636,8 @@ function SQL(o) { storedErrorForClosedConnection, connectionInfo = loadOptions(o); + const pool = new ConnectionPool(connectionInfo); + function connectedHandler(query, handle, err) { if (err) { return query.reject(err); @@ -564,7 +667,7 @@ function SQL(o) { } function closedConnectionHandler(query, handle) { - query.reject(storedErrorForClosedConnection || new Error("Connection closed")); + query.reject(storedErrorForClosedConnection || connectionClosedError()); } function onConnected(err, result) { @@ -654,9 +757,124 @@ function SQL(o) { return pendingSQL(strings, values); } + sql.begin = async (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { + /* + BEGIN; -- works on POSTGRES, MySQL, and SQLite (need to change to BEGIN TRANSACTION on MSSQL) + + -- Create a SAVEPOINT + SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to SAVE TRANSACTION on MSSQL) + + -- QUERY + + -- Roll back to SAVEPOINT if needed + ROLLBACK TO SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + + -- Release the SAVEPOINT + RELEASE SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (MSSQL dont have RELEASE SAVEPOINT you just need to transaction again) + + -- Commit the transaction + COMMIT; -- works on POSTGRES, MySQL, and SQLite (need to change to COMMIT TRANSACTION on MSSQL) + -- or rollback everything + ROLLBACK; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + + */ + + // this is a big TODO we need to make sure that each created query actually uses the same connection or fails + let current_connection; + let savepoints = 0; + try { + if (closed) { + throw connectionClosedError(); + } + let callback = fn; + let options: string | undefined = options_or_fn as unknown as string; + if ($isCallable(options_or_fn)) { + callback = options_or_fn as unknown as TransactionCallback; + options = undefined; + } else if (typeof options_or_fn !== "string") { + throw $ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string"); + } + if (!$isCallable(callback)) { + throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); + } + + if (options) { + //@ts-ignore + await sql(`BEGIN ${options}`); + } else { + //@ts-ignore + await sql("BEGIN"); + } + // keep track of the connection that is being used + current_connection = connection; + + // we need a function able to check for the current connection + const sql_with_savepoint = function (strings, ...values) { + return sql(strings, ...values); + }; + // allow flush, close, options, then, and asyncDispose to be called on the sql_with_savepoint + sql_with_savepoint.flush = sql.flush; + sql_with_savepoint.close = sql.close; + sql_with_savepoint.options = sql.options; + sql_with_savepoint.then = sql.then; + // begin is not allowed on a transaction we need to use savepoint() instead + sql_with_savepoint.begin = function () { + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call begin on a transaction use savepoint() instead"); + }; + sql_with_savepoint[Symbol.asyncDispose] = sql[Symbol.asyncDispose]; + + // this version accepts savepoints with is basically nested transactions + sql_with_savepoint.savepoint = async (fn: TransactionCallback, name?: string) => { + let savepoint_callback = fn; + + if (closed || current_connection !== connection) { + throw connectionClosedError(); + } + if ($isCallable(name)) { + savepoint_callback = name as unknown as TransactionCallback; + name = ""; + } + if (!$isCallable(savepoint_callback)) { + throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); + } + // matchs the format of the savepoint name in postgres package + const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; + + try { + await sql_with_savepoint`SAVEPOINT ${save_point_name}`; + const result = await savepoint_callback(sql_with_savepoint); + if (!closed && current_connection === connection) { + await sql_with_savepoint(`RELEASE SAVEPOINT ${save_point_name}`); + } else { + throw connectionClosedError(); + } + return result; + } catch (err) { + if (!closed && current_connection === connection) { + await sql_with_savepoint(`ROLLBACK TO SAVEPOINT ${save_point_name}`); + } + throw err; + } + }; + + const transaction_result = await callback(sql_with_savepoint); + if (!closed && current_connection === connection) { + await sql("COMMIT"); + } else { + throw connectionClosedError(); + } + return transaction_result; + } catch (err) { + if (current_connection && !closed && current_connection === connection) { + await sql("ROLLBACK"); + } + throw err; + } + }; + sql.connect = () => { if (closed) { - return Promise.reject(new Error("Connection closed")); + return Promise.reject(connectionClosedError()); } if (connected) { @@ -697,7 +915,7 @@ function SQL(o) { sql.then = () => { if (closed) { - return Promise.reject(new Error("Connection closed")); + return Promise.reject(connectionClosedError()); } if (connected) { diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index c0f2bbef847e23..b93000fefcb912 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -484,9 +484,14 @@ pub const PostgresSQLQuery = struct { pub fn call(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { const arguments = callframe.arguments_old(4).slice(); - const query = arguments[0]; - const values = arguments[1]; - const columns = arguments[3]; + var args = JSC.Node.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", .{}); @@ -496,7 +501,9 @@ pub const PostgresSQLQuery = struct { return globalThis.throw("values must be an array", .{}); } - const pending_value = arguments[2]; + const pending_value = args.nextEat() orelse .undefined; + const columns = args.nextEat() orelse .undefined; + if (!pending_value.jsType().isArrayLike()) { return globalThis.throwInvalidArgumentType("query", "pendingValue", "Array"); } From f11f67486eeef9ad4896c8a25629144517aa3654 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 17:07:40 -0800 Subject: [PATCH 02/35] make it work again --- src/bun.js/bindings/ErrorCode.ts | 1 + src/js/bun/sql.ts | 634 ++++++++++++++++++++----------- 2 files changed, 412 insertions(+), 223 deletions(-) diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 8d75753d2ab73c..84895c942caa26 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -168,6 +168,7 @@ const errors: ErrorCodeMapping = [ ["ERR_POSTGRES_CONNECTION_TIMEOUT", Error, "PostgresError"], ["ERR_POSTGRES_LIFETIME_TIMEOUT", Error, "PostgresError"], ["ERR_POSTGRES_INVALID_TRANSACTION_STATE", Error, "PostgresError"], + ["ERR_POSTGRES_QUERY_CANCELLED", Error, "PostgresError"], // S3 ["ERR_S3_MISSING_CREDENTIALS", Error], diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 1cb05c8d57127e..5dfb3bb13bae40 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -245,13 +245,35 @@ class ConnectionWithState { state: "pending" | "connected" | "closed" = "pending"; storedError: Error | null = null; queries: Set<(err: Error) => void> = new Set(); + onFinish: ((err: Error | null) => void) | null = null; + canBeConnected: boolean = false; + connectionInfo: any; #onConnected(err, _) { + const connectionInfo = this.connectionInfo; + if (connectionInfo?.onconnect) { + connectionInfo.onconnect(err); + } this.storedError = err; + this.canBeConnected = !err; this.state = err ? "closed" : "connected"; + const onFinish = this.onFinish; + if (onFinish) { + // pool is closed, lets finish the connection + if (err) { + onFinish(err); + } else { + this.connection.close(); + } + return; + } this.pool.release(this); } #onClose(err) { + const connectionInfo = this.connectionInfo; + if (connectionInfo?.onclose) { + connectionInfo.onclose(err); + } this.state = "closed"; this.connection = null; this.storedError = err; @@ -260,26 +282,73 @@ class ConnectionWithState { this.pool.readyConnections.delete(this); const queries = new Set(this.queries); this.queries.clear(); + // notify all queries that the connection is closed for (const onClose of queries) { onClose(err); } - - // we need to reconnect - // lets use a retry strategy - // TODO: implement retry strategy, maxLifetime, idleTimeout, connectionTimeout + const onFinish = this.onFinish; + if (onFinish) { + onFinish(err); + return; + } } constructor(connectionInfo, pool: ConnectionPool) { //TODO: maxLifetime, idleTimeout, connectionTimeout this.connection = createConnection(connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); this.state = "pending"; this.pool = pool; + this.connectionInfo = connectionInfo; + } + onClose(onClose: (err: Error) => void) { + this.queries.add(onClose); } - bindQuery(query: Query, onClose: (err: Error) => void) { this.queries.add(onClose); // @ts-ignore query.finally(onQueryFinish.bind(this, onClose)); } + #doRetry() { + if (this.pool.closed) { + return; + } + // retry connection + this.connection = createConnection( + this.connectionInfo, + this.#onConnected.bind(this, this.connectionInfo), + this.#onClose.bind(this, this.connectionInfo), + ); + } + retry() { + // if pool is closed, we can't retry + if (this.pool.closed) { + return false; + } + // we need to reconnect + // lets use a retry strategy + // TODO: implement retry strategy, maxLifetime, idleTimeout, connectionTimeout + + // we can only retry if one day we are able to connect + if (this.canBeConnected) { + this.#doRetry(); + } else { + // analyse type of error to see if we can retry + switch (this.storedError?.code) { + case "ERR_POSTGRES_UNSUPPORTED_AUTHENTICATION_METHOD": + case "ERR_POSTGRES_UNKNOWN_AUTHENTICATION_METHOD": + case "ERR_POSTGRES_TLS_NOT_AVAILABLE": + case "ERR_POSTGRES_TLS_UPGRADE_FAILED": + case "ERR_POSTGRES_INVALID_SERVER_SIGNATURE": + case "ERR_POSTGRES_INVALID_SERVER_KEY": + case "ERR_POSTGRES_AUTHENTICATION_FAILED_PBKDF2": + // we can't retry this are authentication errors + return false; + default: + // we can retry + this.#doRetry(); + return true; + } + } + } } class ConnectionPool { connectionInfo: any; @@ -287,6 +356,8 @@ class ConnectionPool { connections: ConnectionWithState[]; readyConnections: Set; waitingQueue: Array<(err: Error | null, result: any) => void> = []; + poolStarted: boolean = false; + closed: boolean = false; constructor(connectionInfo) { this.connectionInfo = connectionInfo; @@ -301,28 +372,149 @@ class ConnectionPool { } this.connections = new Array(max); - for (let i = 0; i < max; i++) { - this.connections[i] = new ConnectionWithState(this.connectionInfo, this); - } this.readyConnections = new Set(); } release(connection: ConnectionWithState) { if (this.waitingQueue.length > 0) { + // we have some pending connections, lets connect them with the released connection const pending = this.waitingQueue.shift(); - pending?.(null, connection); + + pending?.(connection.storedError, connection); } else { + if (connection.state !== "connected") { + // connection is not ready, lets not add it to the ready connections + return; + } + // connection is ready, lets add it to the ready connections this.readyConnections.add(connection); } } + isConnected() { + if (this.readyConnections.size > 0) { + return true; + } + if (this.poolStarted) { + for (let i = 0; i < this.connections.length; i++) { + const connection = this.connections[i]; + if (connection.state === "connected") { + return true; + } + } + } + return false; + } + flush() { + if (this.closed) { + return; + } + if (this.poolStarted) { + this.poolStarted = false; + for (let i = 0; i < this.connections.length; i++) { + const connection = this.connections[i]; + if (connection.state === "connected") { + connection.connection.flush(); + } + } + } + } + close() { + if (this.closed) { + return Promise.reject(connectionClosedError()); + } + this.closed = true; + let pending; + while ((pending = this.waitingQueue.shift())) { + pending(connectionClosedError(), null); + } + const promises: Array> = []; + if (this.poolStarted) { + this.poolStarted = false; + for (let i = 0; i < this.connections.length; i++) { + const connection = this.connections[i]; + switch (connection.state) { + case "pending": + { + const { promise, resolve } = Promise.withResolvers(); + connection.onFinish = resolve; + promises.push(promise); + } + break; + case "connected": + { + const { promise, resolve } = Promise.withResolvers(); + connection.onFinish = resolve; + promises.push(promise); + connection.connection.close(); + } + break; + } + // clean connection reference + // @ts-ignore + this.connections[i] = null; + } + } + this.readyConnections.clear(); + this.waitingQueue.length = 0; + return Promise.all(promises); + } connect(onConnected: (err: Error | null, result: any) => void) { + if (this.closed) { + return onConnected(connectionClosedError(), null); + } if (this.readyConnections.size === 0) { - // wait for connection to be released + // no connection ready lets make some + let retry_in_progress = false; + let all_closed = true; + let storedError: Error | null = null; + + if (this.poolStarted) { + // we already started the pool + // lets check if some connection is available to retry + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + // we need a new connection and we have some connections that can retry + if (connection.state === "closed") { + if (connection.retry()) { + // lets wait for connection to be released + if (!retry_in_progress) { + // avoid adding to the queue twice, we wanna to retry every available pool connection + retry_in_progress = true; + this.waitingQueue.push(onConnected); + } + } else { + // we have some error, lets grab it and fail if unable to start a connection + storedError = connection.storedError; + } + } else { + // we have some pending or open connections + all_closed = false; + } + } + + if (!all_closed && !retry_in_progress) { + // is possible to connect because we have some working connections, or we are just without network for some reason + // wait for connection to be released or fail + this.waitingQueue.push(onConnected); + } else { + // impossible to connect or retry + onConnected(storedError, null); + } + return; + } + // we never started the pool, lets start it this.waitingQueue.push(onConnected); + this.poolStarted = true; + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + this.connections[i] = new ConnectionWithState(this.connectionInfo, this); + } return; } - // unshift + + // we have some connection ready const first = this.readyConnections.values().next().value; this.readyConnections.delete(first); onConnected(null, first); @@ -628,106 +820,206 @@ function loadOptions(o) { } function SQL(o) { - var connection, - connected = false, - connecting = false, - closed = false, - onConnect: any[] = [], - storedErrorForClosedConnection, - connectionInfo = loadOptions(o); + var connectionInfo = loadOptions(o); + var pool = new ConnectionPool(connectionInfo); + + function doCreateQuery(strings, values) { + const sqlString = normalizeStrings(strings, values); + let columns; + if (hasSQLArrayParameter) { + hasSQLArrayParameter = false; + const v = values[0]; + columns = v.columns; + values = v.value; + } - const pool = new ConnectionPool(connectionInfo); + return createQuery(sqlString, values, new SQLResultArray(), columns); + } - function connectedHandler(query, handle, err) { + function onQueryDisconnected(err) { + // connection closed mid query this will not be called if the query finishes first + const query = this; if (err) { return query.reject(err); } - - if (!connected) { - return query.reject(storedErrorForClosedConnection || new Error("Not connected")); - } - + // query is cancelled when waiting for a connection from the pool if (query.cancelled) { - return query.reject(new Error("Query cancelled")); + return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } - - handle.run(connection, query); - - // if the above throws, we don't want it to be in the array. - // This array exists mostly to keep the in-flight queries alive. - connection.queries.push(query); } - function pendingConnectionHandler(query, handle) { - onConnect.push(err => connectedHandler(query, handle, err)); - if (!connecting) { - connecting = true; - connection = createConnection(connectionInfo, onConnected, onClose); + function onQueryConnected(handle, err, pooledConnection) { + const query = this; + if (err) { + // fail to aquire a connection from the pool + return query.reject(err); + } + // query is cancelled when waiting for a connection from the pool + if (query.cancelled) { + pool.release(pooledConnection); // release the connection back to the pool + return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } - } - function closedConnectionHandler(query, handle) { - query.reject(storedErrorForClosedConnection || connectionClosedError()); + // bind close event to the query (will unbind and auto release the connection when the query is finished) + pooledConnection.bindQuery(query, onQueryDisconnected.bind(query)); + handle.run(pooledConnection.connection, query); } - - function onConnected(err, result) { - connected = !err; - for (const handler of onConnect) { - handler(err); + function queryFromPoolHandler(query, handle, err) { + if (err) { + // fail to create query + return query.reject(err); } - onConnect = []; - - if (connected && connectionInfo?.onconnect) { - connectionInfo.onconnect(err); + // query is cancelled + if (query.cancelled) { + return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } + + pool.connect(onQueryConnected.bind(query, handle)); + } + function queryFromPool(strings, values) { + return new Query(doCreateQuery(strings, values), queryFromPoolHandler); } - function onClose(err, queries) { - closed = true; - storedErrorForClosedConnection = err; - if (sql === lazyDefaultSQL) { - resetDefaultSQL(initialDefaultSQL); + function onTransactionQueryDisconnected(query) { + const transactionQueries = this; + transactionQueries.delete(query); + } + function queryFromTransactionHandler(transactionQueries, query, handle, err) { + const pooledConnection = this; + if (err) { + return query.reject(err); } - - onConnected(err, undefined); - if (queries) { - const queriesCopy = queries.slice(); - queries.length = 0; - for (const handler of queriesCopy) { - handler.reject(err); - } + // query is cancelled + if (query.cancelled) { + return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } - - if (connectionInfo?.onclose) { - connectionInfo.onclose(err); + // keep the query alive until we finish the transaction or the query + transactionQueries.add(query); + query.finally(onTransactionQueryDisconnected.bind(transactionQueries, query)); + handle.run(pooledConnection.connection, query); + } + function queryFromTransaction(strings, values, pooledConnection, transactionQueries) { + return new Query( + doCreateQuery(strings, values), + queryFromTransactionHandler.bind(pooledConnection, transactionQueries), + ); + } + function onTransactionDisconnected(err) { + const reject = this.reject; + this.closed = true; + if (err) { + return reject(err); } } - - function doCreateQuery(strings, values) { - const sqlString = normalizeStrings(strings, values); - let columns; - if (hasSQLArrayParameter) { - hasSQLArrayParameter = false; - const v = values[0]; - columns = v.columns; - values = v.value; + async function onTransactionConnected(options, resolve, reject, err, pooledConnection) { + const callback = this as unknown as TransactionCallback; + if (err) { + return reject(err); } + const state = { + closed: false, + reject, + }; + const onClose = onTransactionDisconnected.bind(state); + pooledConnection.onClose(onClose); + let savepoints = 0; + let transactionQueries = new Set(); - return createQuery(sqlString, values, new SQLResultArray(), columns); - } + function transaction_sql(strings, ...values) { + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + if ($isJSArray(strings) && strings[0] && typeof strings[0] === "object") { + return new SQLArrayParameter(strings, values); + } - function connectedSQL(strings, values) { - return new Query(doCreateQuery(strings, values), connectedHandler); - } + return queryFromTransaction(strings, values, pooledConnection, transactionQueries); + } + transaction_sql.connect = () => { + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + return Promise.resolve(transaction_sql); + }; + // begin is not allowed on a transaction we need to use savepoint() instead + transaction_sql.begin = function () { + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call begin inside a transaction use savepoint() instead"); + }; + + transaction_sql.flush = function () { + if (state.closed) { + throw connectionClosedError(); + } + return pooledConnection.flush(); + }; + transaction_sql.close = async function () { + // we dont actually close the connection here, we just set the state to closed and rollback the transaction + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + await transaction_sql("ROLLBACK"); + state.closed = true; + }; + transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); + transaction_sql.then = transaction_sql.connect; + transaction_sql.options = sql.options; - function closedSQL(strings, values) { - return new Query(undefined, closedConnectionHandler); - } + transaction_sql.savepoint = async (fn: TransactionCallback, name?: string) => { + let savepoint_callback = fn; - function pendingSQL(strings, values) { - return new Query(doCreateQuery(strings, values), pendingConnectionHandler); + if (state.closed) { + throw connectionClosedError(); + } + if ($isCallable(name)) { + savepoint_callback = name as unknown as TransactionCallback; + name = ""; + } + if (!$isCallable(savepoint_callback)) { + throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); + } + // matchs the format of the savepoint name in postgres package + const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; + await transaction_sql(`SAVEPOINT ${save_point_name}`); + + try { + const result = await savepoint_callback(transaction_sql); + await transaction_sql(`RELEASE SAVEPOINT ${save_point_name}`); + return result; + } catch (err) { + if (!state.closed) { + await transaction_sql(`ROLLBACK TO SAVEPOINT ${save_point_name}`); + } + throw err; + } + }; + let transaction_started = false; + try { + if (options) { + //@ts-ignore + await transaction_sql(`BEGIN ${options}`); + } else { + //@ts-ignore + await transaction_sql("BEGIN"); + } + transaction_started = true; + const transaction_result = await callback(transaction_sql); + await transaction_sql("COMMIT"); + return resolve(transaction_result); + } catch (err) { + try { + if (!state.closed && transaction_started) { + await transaction_sql("ROLLBACK"); + } + } catch (err) { + return reject(err); + } + return reject(err); + } finally { + state.closed = true; + pooledConnection.queries.delete(onClose); + pool.release(pooledConnection); + } } - function sql(strings, ...values) { /** * const users = [ @@ -746,15 +1038,7 @@ function SQL(o) { return new SQLArrayParameter(strings, values); } - if (closed) { - return closedSQL(strings, values); - } - - if (connected) { - return connectedSQL(strings, values); - } - - return pendingSQL(strings, values); + return queryFromPool(strings, values); } sql.begin = async (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { @@ -779,157 +1063,61 @@ function SQL(o) { */ - // this is a big TODO we need to make sure that each created query actually uses the same connection or fails - let current_connection; - let savepoints = 0; - try { - if (closed) { - throw connectionClosedError(); - } - let callback = fn; - let options: string | undefined = options_or_fn as unknown as string; - if ($isCallable(options_or_fn)) { - callback = options_or_fn as unknown as TransactionCallback; - options = undefined; - } else if (typeof options_or_fn !== "string") { - throw $ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string"); - } - if (!$isCallable(callback)) { - throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); - } - - if (options) { - //@ts-ignore - await sql(`BEGIN ${options}`); - } else { - //@ts-ignore - await sql("BEGIN"); - } - // keep track of the connection that is being used - current_connection = connection; - - // we need a function able to check for the current connection - const sql_with_savepoint = function (strings, ...values) { - return sql(strings, ...values); - }; - // allow flush, close, options, then, and asyncDispose to be called on the sql_with_savepoint - sql_with_savepoint.flush = sql.flush; - sql_with_savepoint.close = sql.close; - sql_with_savepoint.options = sql.options; - sql_with_savepoint.then = sql.then; - // begin is not allowed on a transaction we need to use savepoint() instead - sql_with_savepoint.begin = function () { - throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call begin on a transaction use savepoint() instead"); - }; - sql_with_savepoint[Symbol.asyncDispose] = sql[Symbol.asyncDispose]; - - // this version accepts savepoints with is basically nested transactions - sql_with_savepoint.savepoint = async (fn: TransactionCallback, name?: string) => { - let savepoint_callback = fn; - - if (closed || current_connection !== connection) { - throw connectionClosedError(); - } - if ($isCallable(name)) { - savepoint_callback = name as unknown as TransactionCallback; - name = ""; - } - if (!$isCallable(savepoint_callback)) { - throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); - } - // matchs the format of the savepoint name in postgres package - const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; - - try { - await sql_with_savepoint`SAVEPOINT ${save_point_name}`; - const result = await savepoint_callback(sql_with_savepoint); - if (!closed && current_connection === connection) { - await sql_with_savepoint(`RELEASE SAVEPOINT ${save_point_name}`); - } else { - throw connectionClosedError(); - } - return result; - } catch (err) { - if (!closed && current_connection === connection) { - await sql_with_savepoint(`ROLLBACK TO SAVEPOINT ${save_point_name}`); - } - throw err; - } - }; - - const transaction_result = await callback(sql_with_savepoint); - if (!closed && current_connection === connection) { - await sql("COMMIT"); - } else { - throw connectionClosedError(); - } - return transaction_result; - } catch (err) { - if (current_connection && !closed && current_connection === connection) { - await sql("ROLLBACK"); - } - throw err; + if (pool.closed) { + throw connectionClosedError(); + } + let callback = fn; + let options: string | undefined = options_or_fn as unknown as string; + if ($isCallable(options_or_fn)) { + callback = options_or_fn as unknown as TransactionCallback; + options = undefined; + } else if (typeof options_or_fn !== "string") { + throw $ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string"); } + if (!$isCallable(callback)) { + throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); + } + const { promise, resolve, reject } = Promise.withResolvers(); + pool.connect(onTransactionConnected.bind(callback, options, resolve, reject)); + return promise; }; sql.connect = () => { - if (closed) { + if (pool.closed) { return Promise.reject(connectionClosedError()); } - if (connected) { + if (pool.isConnected()) { return Promise.resolve(sql); } - var { resolve, reject, promise } = Promise.withResolvers(); - onConnect.push(err => (err ? reject(err) : resolve(sql))); - if (!connecting) { - connecting = true; - connection = createConnection(connectionInfo, onConnected, onClose); - } + let { resolve, reject, promise } = Promise.withResolvers(); + const onConnected = (err, connection) => { + if (err) { + return reject(err); + } + // we are just measuring the connection here lets release it + pool.release(connection); + resolve(sql); + }; + + pool.connect(onConnected); return promise; }; sql.close = () => { - if (closed) { - return Promise.resolve(); - } - - var { resolve, promise } = Promise.withResolvers(); - onConnect.push(resolve); - connection.close(); - return promise; + return pool.close(); }; sql[Symbol.asyncDispose] = () => sql.close(); - sql.flush = () => { - if (closed || !connected) { - return; - } - - connection.flush(); - }; + sql.flush = () => pool.flush(); sql.options = connectionInfo; sql.then = () => { - if (closed) { - return Promise.reject(connectionClosedError()); - } - - if (connected) { - return Promise.resolve(sql); - } - - const { resolve, reject, promise } = Promise.withResolvers(); - onConnect.push(err => (err ? reject(err) : resolve(sql))); - if (!connecting) { - connecting = true; - connection = createConnection(connectionInfo, onConnected, onClose); - } - - return promise; + // should this wait queries to finish or just return if is connected? + return sql.connect(); }; return sql; From 4802de3171143ef204c2ec5c3c469f646ff99a54 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 17:42:38 -0800 Subject: [PATCH 03/35] release query --- src/js/bun/sql.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 5dfb3bb13bae40..b0dd22aa265dd8 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -238,8 +238,9 @@ init( function onQueryFinish(onClose) { this.queries.delete(onClose); + this.pool.release(this); } -class ConnectionWithState { +class PooledConnection { pool: ConnectionPool; connection: ReturnType; state: "pending" | "connected" | "closed" = "pending"; @@ -248,7 +249,6 @@ class ConnectionWithState { onFinish: ((err: Error | null) => void) | null = null; canBeConnected: boolean = false; connectionInfo: any; - #onConnected(err, _) { const connectionInfo = this.connectionInfo; if (connectionInfo?.onconnect) { @@ -353,8 +353,8 @@ class ConnectionWithState { class ConnectionPool { connectionInfo: any; - connections: ConnectionWithState[]; - readyConnections: Set; + connections: PooledConnection[]; + readyConnections: Set; waitingQueue: Array<(err: Error | null, result: any) => void> = []; poolStarted: boolean = false; closed: boolean = false; @@ -375,11 +375,10 @@ class ConnectionPool { this.readyConnections = new Set(); } - release(connection: ConnectionWithState) { + release(connection: PooledConnection) { if (this.waitingQueue.length > 0) { // we have some pending connections, lets connect them with the released connection const pending = this.waitingQueue.shift(); - pending?.(connection.storedError, connection); } else { if (connection.state !== "connected") { @@ -509,7 +508,7 @@ class ConnectionPool { this.poolStarted = true; const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { - this.connections[i] = new ConnectionWithState(this.connectionInfo, this); + this.connections[i] = new PooledConnection(this.connectionInfo, this); } return; } @@ -859,7 +858,6 @@ function SQL(o) { pool.release(pooledConnection); // release the connection back to the pool return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } - // bind close event to the query (will unbind and auto release the connection when the query is finished) pooledConnection.bindQuery(query, onQueryDisconnected.bind(query)); handle.run(pooledConnection.connection, query); From 93c58c94c7a6b88a9c8535f52cbe19c40b45f3d2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 18:43:28 -0800 Subject: [PATCH 04/35] fix max --- src/js/bun/sql.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index b0dd22aa265dd8..c5f54bf4148c4d 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -289,8 +289,9 @@ class PooledConnection { const onFinish = this.onFinish; if (onFinish) { onFinish(err); - return; } + + this.pool.release(this); } constructor(connectionInfo, pool: ConnectionPool) { //TODO: maxLifetime, idleTimeout, connectionTimeout @@ -360,18 +361,7 @@ class ConnectionPool { closed: boolean = false; constructor(connectionInfo) { this.connectionInfo = connectionInfo; - - let max = connectionInfo.max; - if (max && typeof max !== "number") { - throw $ERR_INVALID_ARG_TYPE("max", "number", max); - } else { - max = 10; // same default as postgres.js - } - if (max < 1) { - throw $ERR_INVALID_ARG_VALUE("max", max, "must be greater than 0"); - } - - this.connections = new Array(max); + this.connections = new Array(connectionInfo.max); this.readyConnections = new Set(); } @@ -659,7 +649,8 @@ function loadOptions(o) { connectionTimeout, maxLifetime, onconnect, - onclose; + onclose, + max; const env = Bun.env; var sslMode: SSLMode = SSLMode.disable; @@ -721,6 +712,7 @@ function loadOptions(o) { password ||= o.password || o.pass || env.PGPASSWORD || ""; tls ||= o.tls || o.ssl; adapter ||= o.adapter || "postgres"; + max ||= o.max || 10; idleTimeout ??= o.idleTimeout; idleTimeout ??= o.idle_timeout; @@ -776,6 +768,13 @@ function loadOptions(o) { } } + if (max != null) { + max = Number(max); + if (max > 2 ** 31 || max < 0 || max !== max) { + throw $ERR_INVALID_ARG_VALUE("options.max", max, "must be a non-negative integer less than 2^31"); + } + } + if (sslMode !== SSLMode.disable && !tls?.serverName) { if (hostname) { tls = { @@ -815,6 +814,8 @@ function loadOptions(o) { if (onclose !== undefined) { ret.onclose = onclose; } + ret.max = max || 10; + return ret; } From 84088b2f45f1389d03e5dc5f5f8fca73b9e460bd Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 18:46:31 -0800 Subject: [PATCH 05/35] dont allow 0 on max --- src/js/bun/sql.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index c5f54bf4148c4d..00fd00579bd52d 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -712,7 +712,7 @@ function loadOptions(o) { password ||= o.password || o.pass || env.PGPASSWORD || ""; tls ||= o.tls || o.ssl; adapter ||= o.adapter || "postgres"; - max ||= o.max || 10; + max = o.max; idleTimeout ??= o.idleTimeout; idleTimeout ??= o.idle_timeout; @@ -770,8 +770,8 @@ function loadOptions(o) { if (max != null) { max = Number(max); - if (max > 2 ** 31 || max < 0 || max !== max) { - throw $ERR_INVALID_ARG_VALUE("options.max", max, "must be a non-negative integer less than 2^31"); + if (max > 2 ** 31 || max < 1 || max !== max) { + throw $ERR_INVALID_ARG_VALUE("options.max", max, "must be a non-negative integer between 1 and 2^31"); } } From dd12c0d5aa3b9736257c5983246334baf8a72c89 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 18:56:55 -0800 Subject: [PATCH 06/35] not TODO --- src/js/bun/sql.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 00fd00579bd52d..57afc8b0594ae7 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -294,7 +294,6 @@ class PooledConnection { this.pool.release(this); } constructor(connectionInfo, pool: ConnectionPool) { - //TODO: maxLifetime, idleTimeout, connectionTimeout this.connection = createConnection(connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); this.state = "pending"; this.pool = pool; @@ -326,7 +325,6 @@ class PooledConnection { } // we need to reconnect // lets use a retry strategy - // TODO: implement retry strategy, maxLifetime, idleTimeout, connectionTimeout // we can only retry if one day we are able to connect if (this.canBeConnected) { From 7e80e40e24f4ff1eecd9d9922b1c943a66526480 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Mon, 13 Jan 2025 19:05:48 -0800 Subject: [PATCH 07/35] more --- src/js/bun/sql.ts | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 57afc8b0594ae7..7fe95b3b0c0f16 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -365,6 +365,20 @@ class ConnectionPool { release(connection: PooledConnection) { if (this.waitingQueue.length > 0) { + if (connection.storedError) { + // this connection got a error but maybe we can wait for another + + if (this.hasConnectionsAvailable()) { + return; + } + + // we have no connections available so lets fails + let pending; + while ((pending = this.waitingQueue.shift())) { + pending(connection.storedError, connection); + } + return; + } // we have some pending connections, lets connect them with the released connection const pending = this.waitingQueue.shift(); pending?.(connection.storedError, connection); @@ -378,12 +392,28 @@ class ConnectionPool { } } + hasConnectionsAvailable() { + if (this.readyConnections.size > 0) return true; + if (this.poolStarted) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + if (connection.state !== "closed") { + // some connection is connecting or connected + return true; + } + } + } + return false; + } + isConnected() { if (this.readyConnections.size > 0) { return true; } if (this.poolStarted) { - for (let i = 0; i < this.connections.length; i++) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; if (connection.state === "connected") { return true; @@ -397,8 +427,8 @@ class ConnectionPool { return; } if (this.poolStarted) { - this.poolStarted = false; - for (let i = 0; i < this.connections.length; i++) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; if (connection.state === "connected") { connection.connection.flush(); @@ -418,7 +448,8 @@ class ConnectionPool { const promises: Array> = []; if (this.poolStarted) { this.poolStarted = false; - for (let i = 0; i < this.connections.length; i++) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; switch (connection.state) { case "pending": From dc008aedcb7d2530873f90a9b58afa3d12eefccc Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 12:12:05 -0800 Subject: [PATCH 08/35] reserve() --- src/js/bun/sql.ts | 140 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 10 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 7fe95b3b0c0f16..22ef1ecc472132 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -311,6 +311,9 @@ class PooledConnection { if (this.pool.closed) { return; } + // reset error and state + this.storedError = null; + this.state = "pending"; // retry connection this.connection = createConnection( this.connectionInfo, @@ -318,6 +321,11 @@ class PooledConnection { this.#onClose.bind(this, this.connectionInfo), ); } + close() { + if (this.state === "connected") { + this.connection?.close(); + } + } retry() { // if pool is closed, we can't retry if (this.pool.closed) { @@ -939,8 +947,104 @@ function SQL(o) { return reject(err); } } - async function onTransactionConnected(options, resolve, reject, err, pooledConnection) { - const callback = this as unknown as TransactionCallback; + + function onReserveDisconnected(err) { + const reject = this.reject; + this.closed = true; + if (err) { + return reject(err); + } + } + + function onReserveConnected(err, pooledConnection) { + const { promise, resolve, reject } = this; + if (err) { + return reject(err); + } + + const state = { + closed: false, + reject, + }; + const onClose = onReserveDisconnected.bind(state); + pooledConnection.onClose(onClose); + + let reserveQueries = new Set(); + function reserved_sql(strings, ...values) { + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + if ($isJSArray(strings) && strings[0] && typeof strings[0] === "object") { + return new SQLArrayParameter(strings, values); + } + // we use the same code path as the transaction sql + return queryFromTransaction(strings, values, pooledConnection, reserveQueries); + } + reserved_sql.connect = () => { + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + return Promise.resolve(reserved_sql); + }; + + // reserve is allowed to be called inside reserved connection but will return a new reserved connection from the pool + // this matchs the behavior of the postgres package + reserved_sql.reserve = () => sql.reserve(); + + reserved_sql.begin = (options_or_fn: string | TransactionCallback, 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.closed) { + return Promise.reject(connectionClosedError()); + } + let callback = fn; + let options: string | undefined = options_or_fn as unknown as string; + if ($isCallable(options_or_fn)) { + callback = options_or_fn as unknown as TransactionCallback; + options = undefined; + } else if (typeof options_or_fn !== "string") { + return Promise.reject($ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string")); + } + if (!$isCallable(callback)) { + return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); + } + const { promise, resolve, reject } = Promise.withResolvers(); + // lets just reuse the same code path as the transaction begin + onTransactionConnected(callback, options, resolve, reject, true, null, pooledConnection); + return promise; + }; + + reserved_sql.flush = () => { + if (state.closed) { + throw connectionClosedError(); + } + return pooledConnection.flush(); + }; + reserved_sql.close = async () => { + if (state.closed) { + return Promise.reject(connectionClosedError()); + } + // close will release the connection back to the pool but will actually close the connection if its open + state.closed = true; + pooledConnection.queries.delete(onClose); + + pooledConnection.close(); + + pool.release(pooledConnection); + return Promise.resolve(undefined); + }; + reserved_sql.release = () => { + // just release the connection back to the pool + state.closed = true; + pooledConnection.queries.delete(onClose); + pool.release(pooledConnection); + return Promise.resolve(undefined); + }; + reserved_sql[Symbol.asyncDispose] = () => reserved_sql.release(); + reserved_sql.then = reserved_sql.connect; + reserved_sql.options = sql.options; + resolve(reserved_sql); + } + async function onTransactionConnected(callback, options, resolve, reject, dontRelease, err, pooledConnection) { if (err) { return reject(err); } @@ -963,6 +1067,10 @@ function SQL(o) { return queryFromTransaction(strings, values, pooledConnection, transactionQueries); } + // reserve is allowed to be called inside transaction connection but will return a new reserved connection from the pool and will not be part of the transaction + // this matchs the behavior of the postgres package + transaction_sql.reserve = () => sql.reserve(); + transaction_sql.connect = () => { if (state.closed) { return Promise.reject(connectionClosedError()); @@ -1045,7 +1153,9 @@ function SQL(o) { } finally { state.closed = true; pooledConnection.queries.delete(onClose); - pool.release(pooledConnection); + if (!dontRelease) { + pool.release(pooledConnection); + } } } function sql(strings, ...values) { @@ -1069,7 +1179,17 @@ function SQL(o) { return queryFromPool(strings, values); } - sql.begin = async (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { + sql.reserve = () => { + if (pool.closed) { + return Promise.reject(connectionClosedError()); + } + + const promiseWithResolvers = Promise.withResolvers(); + pool.connect(onReserveConnected.bind(promiseWithResolvers)); + return promiseWithResolvers.promise; + }; + + sql.begin = (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { /* BEGIN; -- works on POSTGRES, MySQL, and SQLite (need to change to BEGIN TRANSACTION on MSSQL) @@ -1092,7 +1212,7 @@ function SQL(o) { */ if (pool.closed) { - throw connectionClosedError(); + return Promise.reject(connectionClosedError()); } let callback = fn; let options: string | undefined = options_or_fn as unknown as string; @@ -1100,13 +1220,13 @@ function SQL(o) { callback = options_or_fn as unknown as TransactionCallback; options = undefined; } else if (typeof options_or_fn !== "string") { - throw $ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string"); + return Promise.reject($ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string")); } if (!$isCallable(callback)) { - throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); + return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); } const { promise, resolve, reject } = Promise.withResolvers(); - pool.connect(onTransactionConnected.bind(callback, options, resolve, reject)); + pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false)); return promise; }; @@ -1134,8 +1254,8 @@ function SQL(o) { return promise; }; - sql.close = () => { - return pool.close(); + sql.close = async () => { + await pool.close(); }; sql[Symbol.asyncDispose] = () => sql.close(); From 8a92882f5dc0689fe88b8e364abd9cfee17bc58f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 12:45:16 -0800 Subject: [PATCH 09/35] dispose --- src/js/bun/sql.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 22ef1ecc472132..18043e72de291e 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1039,8 +1039,10 @@ function SQL(o) { pool.release(pooledConnection); return Promise.resolve(undefined); }; + // this dont need to be async dispose only disposable but we keep compatibility with other types of sql functions reserved_sql[Symbol.asyncDispose] = () => reserved_sql.release(); - reserved_sql.then = reserved_sql.connect; + reserved_sql[Symbol.dispose] = () => reserved_sql.release(); + reserved_sql.options = sql.options; resolve(reserved_sql); } @@ -1097,7 +1099,6 @@ function SQL(o) { state.closed = true; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); - transaction_sql.then = transaction_sql.connect; transaction_sql.options = sql.options; transaction_sql.savepoint = async (fn: TransactionCallback, name?: string) => { @@ -1263,11 +1264,6 @@ function SQL(o) { sql.flush = () => pool.flush(); sql.options = connectionInfo; - sql.then = () => { - // should this wait queries to finish or just return if is connected? - return sql.connect(); - }; - return sql; } From 2acbd824c4b63510348736fabf9963a94828623b Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 14:32:38 -0800 Subject: [PATCH 10/35] check unsafe transactions --- src/bun.js/bindings/ErrorCode.ts | 1 + src/js/bun/sql.ts | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 84895c942caa26..40982caa531d54 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -169,6 +169,7 @@ const errors: ErrorCodeMapping = [ ["ERR_POSTGRES_LIFETIME_TIMEOUT", Error, "PostgresError"], ["ERR_POSTGRES_INVALID_TRANSACTION_STATE", Error, "PostgresError"], ["ERR_POSTGRES_QUERY_CANCELLED", Error, "PostgresError"], + ["ERR_POSTGRES_UNSAFE_TRANSACTION", Error, "PostgresError"], // S3 ["ERR_S3_MISSING_CREDENTIALS", Error], diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 18043e72de291e..ee82e8f64df0a0 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -860,7 +860,7 @@ function SQL(o) { var connectionInfo = loadOptions(o); var pool = new ConnectionPool(connectionInfo); - function doCreateQuery(strings, values) { + function doCreateQuery(strings, values, allowUnsafeTransaction) { const sqlString = normalizeStrings(strings, values); let columns; if (hasSQLArrayParameter) { @@ -869,6 +869,11 @@ function SQL(o) { columns = v.columns; values = v.value; } + if (!allowUnsafeTransaction) { + if (sqlString.length === 5 && sqlString.toUpperCase() === "BEGIN" && connectionInfo.max !== 1) { + throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); + } + } return createQuery(sqlString, values, new SQLResultArray(), columns); } @@ -906,14 +911,18 @@ function SQL(o) { return query.reject(err); } // query is cancelled - if (query.cancelled) { + if (!handle || query.cancelled) { return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } pool.connect(onQueryConnected.bind(query, handle)); } function queryFromPool(strings, values) { - return new Query(doCreateQuery(strings, values), queryFromPoolHandler); + try { + return new Query(doCreateQuery(strings, values, false), queryFromPoolHandler); + } catch (err) { + return Promise.reject(err); + } } function onTransactionQueryDisconnected(query) { @@ -935,10 +944,14 @@ function SQL(o) { handle.run(pooledConnection.connection, query); } function queryFromTransaction(strings, values, pooledConnection, transactionQueries) { - return new Query( - doCreateQuery(strings, values), - queryFromTransactionHandler.bind(pooledConnection, transactionQueries), - ); + try { + return new Query( + doCreateQuery(strings, values, true), + queryFromTransactionHandler.bind(pooledConnection, transactionQueries), + ); + } catch (err) { + return Promise.reject(err); + } } function onTransactionDisconnected(err) { const reject = this.reject; From 0cd41ecbc2d74e9f4e3c6de728423a49ff5929a5 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 14:37:36 -0800 Subject: [PATCH 11/35] change check order --- src/js/bun/sql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index ee82e8f64df0a0..79c629c2b75a64 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -870,7 +870,7 @@ function SQL(o) { values = v.value; } if (!allowUnsafeTransaction) { - if (sqlString.length === 5 && sqlString.toUpperCase() === "BEGIN" && connectionInfo.max !== 1) { + if (sqlString.length === 5 && connectionInfo.max !== 1 && sqlString.toUpperCase() === "BEGIN") { throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); } } From 40c24ae544358ae697da762f0b9c300f8558543b Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 17:03:12 -0800 Subject: [PATCH 12/35] check .zero --- src/js/bun/sql.ts | 8 ++++---- src/sql/postgres.zig | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 79c629c2b75a64..89e3da90005142 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -874,7 +874,6 @@ function SQL(o) { throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); } } - return createQuery(sqlString, values, new SQLResultArray(), columns); } @@ -1142,7 +1141,7 @@ function SQL(o) { throw err; } }; - let transaction_started = false; + let needs_rollback = false; try { if (options) { //@ts-ignore @@ -1151,13 +1150,14 @@ function SQL(o) { //@ts-ignore await transaction_sql("BEGIN"); } - transaction_started = true; + needs_rollback = true; const transaction_result = await callback(transaction_sql); await transaction_sql("COMMIT"); + needs_rollback = false; return resolve(transaction_result); } catch (err) { try { - if (!state.closed && transaction_started) { + if (!state.closed && needs_rollback) { await transaction_sql("ROLLBACK"); } } catch (err) { diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index b93000fefcb912..f98d82e905704a 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -219,6 +219,9 @@ pub const PostgresSQLQuery = struct { pub usingnamespace JSC.Codegen.JSPostgresSQLQuery; pub fn getTarget(this: *PostgresSQLQuery, globalObject: *JSC.JSGlobalObject) JSC.JSValue { + if (this.thisValue == .zero) { + return .zero; + } const target = PostgresSQLQuery.targetGetCached(this.thisValue) orelse return .zero; PostgresSQLQuery.targetSetCached(this.thisValue, globalObject, .zero); return target; From caa82a04ebf1b7f796b038c3042c600d393d73a7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 19:29:49 -0800 Subject: [PATCH 13/35] fix promise rejection --- src/bun.js/event_loop.zig | 1 - src/js/bun/sql.ts | 14 ++- src/sql/postgres.zig | 20 +++- test/js/sql/sql.test.ts | 188 +++++++++++++++++++++++--------------- 4 files changed, 139 insertions(+), 84 deletions(-) diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index 48e23a010bc5e0..9d5263a3b836cd 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -898,7 +898,6 @@ pub const EventLoop = struct { pub fn runCallback(this: *EventLoop, callback: JSC.JSValue, globalObject: *JSC.JSGlobalObject, thisValue: JSC.JSValue, arguments: []const JSC.JSValue) void { this.enter(); defer this.exit(); - _ = callback.call(globalObject, thisValue, arguments) catch |err| globalObject.reportActiveExceptionAsUnhandled(err); } diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 89e3da90005142..ab8aa229a688d6 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -47,6 +47,7 @@ const { createQuery, PostgresSQLConnection, init, + setPromiseAsHandled, // maybe we should move this to utils.zig } = $zig("postgres.zig", "createBinding"); function normalizeSSLMode(value: string): SSLMode { @@ -105,7 +106,7 @@ class Query extends PublicPromise { this[_queryStatus] = handle ? 0 : QueryStatus.cancelled; } - async [_run]() { + [_run]() { const { [_handle]: handle, [_handler]: handler, [_queryStatus]: status } = this; if (status & (QueryStatus.executed | QueryStatus.error | QueryStatus.cancelled)) { @@ -113,7 +114,7 @@ class Query extends PublicPromise { } this[_queryStatus] |= QueryStatus.executed; - await 1; + // await 1; return handler(this, handle); } @@ -183,12 +184,16 @@ class Query extends PublicPromise { then() { this[_run](); - return super.$then.$apply(this, arguments); + const result = super.$then.$apply(this, arguments); + setPromiseAsHandled(result); + return result; } catch() { this[_run](); - return super.catch.$apply(this, arguments); + const result = super.catch.$apply(this, arguments); + setPromiseAsHandled(result); + return result; } finally() { @@ -1153,7 +1158,6 @@ function SQL(o) { needs_rollback = true; const transaction_result = await callback(transaction_sql); await transaction_sql("COMMIT"); - needs_rollback = false; return resolve(transaction_result); } catch (err) { try { diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index f98d82e905704a..fce3c4a35fcf5b 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -328,10 +328,13 @@ pub const PostgresSQLQuery = struct { return; } - // TODO: error handling var vm = JSC.VirtualMachine.get(); const function = vm.rareData().postgresql_context.onQueryRejectFn.get().?; - globalObject.queueMicrotask(function, &[_]JSValue{ targetValue, err.toJS(globalObject) }); + const event_loop = vm.eventLoop(); + event_loop.runCallback(function, globalObject, thisValue, &.{ + targetValue, + err.toJS(globalObject), + }); } const CommandTag = union(enum) { @@ -3175,6 +3178,14 @@ const Signature = struct { } }; +pub fn setPromiseAsHandled(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { + const js_promise = callframe.argument(0); + if (js_promise.asAnyPromise()) |promise| { + promise.setHandled(globalObject.vm()); + } + + return .undefined; +} pub fn createBinding(globalObject: *JSC.JSGlobalObject) JSValue { const binding = JSValue.createEmptyObjectWithNullPrototype(globalObject); binding.put(globalObject, ZigString.static("PostgresSQLConnection"), PostgresSQLConnection.getConstructor(globalObject)); @@ -3190,6 +3201,11 @@ pub fn createBinding(globalObject: *JSC.JSGlobalObject) JSValue { ZigString.static("createConnection"), JSC.JSFunction.create(globalObject, "createQuery", PostgresSQLConnection.call, 2, .{}), ); + binding.put( + globalObject, + ZigString.static("setPromiseAsHandled"), + JSC.JSFunction.create(globalObject, "setPromiseAsHandled", setPromiseAsHandled, 1, .{}), + ); return binding; } diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index ce681484fe6004..33b66ed4b3b54e 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -382,93 +382,129 @@ if (!isCI && hasPsql) { } }); - // t('Throws on illegal transactions', async() => { - // const sql = postgres({ ...options, max: 2, fetch_types: false }) - // const error = await sql`begin`.catch(e => e) - // return [ - // error.code, - // 'UNSAFE_TRANSACTION' - // ] - // }) - - // t('Transaction throws', async() => { - // await sql`create table test (a int)` - // return ['22P02', await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql`insert into test values('hej')` - // }).catch(x => x.code), await sql`drop table test`] - // }) - - // t('Transaction rolls back', async() => { - // await sql`create table test (a int)` - // await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql`insert into test values('hej')` - // }).catch(() => { /* ignore */ }) - // return [0, (await sql`select a from test`).count, await sql`drop table test`] - // }) - - // t('Transaction throws on uncaught savepoint', async() => { - // await sql`create table test (a int)` + test("Throws on illegal transactions", async () => { + const sql = postgres({ ...options, max: 2, fetch_types: false }); + const error = await sql`begin`.catch(e => e); + return expect(error.code).toBe("ERR_POSTGRES_UNSAFE_TRANSACTION"); + }); - // return ['fail', (await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql.savepoint(async sql => { - // await sql`insert into test values(2)` - // throw new Error('fail') - // }) - // }).catch((err) => err.message)), await sql`drop table test`] - // }) + test("Transaction throws", async () => { + await sql`create table if not exists test (a int)`; + try { + expect( + await sql + .begin(async sql => { + await sql`insert into test values(1)`; + await sql`insert into test values('hej')`; + }) + .catch(e => e.errno), + ).toBe(22); + } finally { + await sql`drop table test`; + } + }); - // t('Transaction throws on uncaught named savepoint', async() => { - // await sql`create table test (a int)` + test("Transaction rolls back", async () => { + await sql`create table if not exists test (a int)`; - // return ['fail', (await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql.savepoit('watpoint', async sql => { - // await sql`insert into test values(2)` - // throw new Error('fail') - // }) - // }).catch(() => 'fail')), await sql`drop table test`] - // }) + try { + await sql + .begin(async sql => { + await sql`insert into test values(1)`; + await sql`insert into test values('hej')`; + }) + .catch(() => { + /* ignore */ + }); + + expect((await sql`select a from test`).count).toBe(0); + } finally { + await sql`drop table test`; + } + }); - // t('Transaction succeeds on caught savepoint', async() => { - // await sql`create table test (a int)` - // await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql.savepoint(async sql => { - // await sql`insert into test values(2)` - // throw new Error('please rollback') - // }).catch(() => { /* ignore */ }) - // await sql`insert into test values(3)` - // }) + test("Transaction throws on uncaught savepoint", async () => { + await sql`create table test (a int)`; + try { + expect( + await sql + .begin(async sql => { + await sql`insert into test values(1)`; + await sql.savepoint(async sql => { + await sql`insert into test values(2)`; + throw new Error("fail"); + }); + }) + .catch(err => err.message), + ).toBe("fail"); + } finally { + await sql`drop table test`; + } + }); - // return ['2', (await sql`select count(1) from test`)[0].count, await sql`drop table test`] - // }) + test("Transaction throws on uncaught named savepoint", async () => { + await sql`create table test (a int)`; + try { + expect( + await sql + .begin(async sql => { + await sql`insert into test values(1)`; + await sql.savepoit("watpoint", async sql => { + await sql`insert into test values(2)`; + throw new Error("fail"); + }); + }) + .catch(() => "fail"), + ).toBe("fail"); + } finally { + await sql`drop table test`; + } + }); - // t('Savepoint returns Result', async() => { - // let result - // await sql.begin(async sql => { - // result = await sql.savepoint(sql => - // sql`select 1 as x` - // ) - // }) + test("Transaction succeeds on caught savepoint", async () => { + try { + await sql`create table test (a int)`; + await sql.begin(async sql => { + await sql`insert into test values(1)`; + await sql + .savepoint(async sql => { + await sql`insert into test values(2)`; + throw new Error("please rollback"); + }) + .catch(() => { + /* ignore */ + }); + await sql`insert into test values(3)`; + }); + expect((await sql`select count(1) from test`)[0].count).toBe("2"); + } finally { + await sql`drop table test`; + } + }); - // return [1, result[0].x] - // }) + test("Savepoint returns Result", async () => { + let result; + await sql.begin(async t => { + result = await t.savepoint(s => s`select 1 as x`); + }); + expect(result[0]?.x).toBe(1); + }); - // t('Prepared transaction', async() => { - // await sql`create table test (a int)` + // test.only("Prepared transaction", async () => { + // await sql`create table test (a int)`; // await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql.prepare('tx1') - // }) - - // await sql`commit prepared 'tx1'` + // await sql`insert into test values(1)`; + // await sql.prepare("tx1"); + // }); - // return ['1', (await sql`select count(1) from test`)[0].count, await sql`drop table test`] - // }) + // await sql`commit prepared 'tx1'`; + // try { + // expect((await sql`select count(1) from test`)[0].count).toBe("1"); + // } finally { + // await sql`drop table test`; + // } + // }); // t('Transaction requests are executed implicitly', async() => { // const sql = postgres({ debug: true, idle_timeout: 1, fetch_types: false }) From 279d8483635f0e3b28b71a54be2342df2d431ca2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 19:53:30 -0800 Subject: [PATCH 14/35] Promise.all --- src/js/bun/sql.ts | 10 +++++++-- test/js/sql/sql.test.ts | 46 +++++++++++++++++++++-------------------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index ab8aa229a688d6..9c1920e221128b 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1136,8 +1136,11 @@ function SQL(o) { await transaction_sql(`SAVEPOINT ${save_point_name}`); try { - const result = await savepoint_callback(transaction_sql); + let result = await savepoint_callback(transaction_sql); await transaction_sql(`RELEASE SAVEPOINT ${save_point_name}`); + if (Array.isArray(result)) { + result = await Promise.all(result); + } return result; } catch (err) { if (!state.closed) { @@ -1156,7 +1159,10 @@ function SQL(o) { await transaction_sql("BEGIN"); } needs_rollback = true; - const transaction_result = await callback(transaction_sql); + let transaction_result = await callback(transaction_sql); + if (Array.isArray(transaction_result)) { + transaction_result = await Promise.all(transaction_result); + } await transaction_sql("COMMIT"); return resolve(transaction_result); } catch (err) { diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 33b66ed4b3b54e..4c2809e452adc1 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -490,7 +490,7 @@ if (!isCI && hasPsql) { expect(result[0]?.x).toBe(1); }); - // test.only("Prepared transaction", async () => { + // test("Prepared transaction", async () => { // await sql`create table test (a int)`; // await sql.begin(async sql => { @@ -506,29 +506,31 @@ if (!isCI && hasPsql) { // } // }); - // t('Transaction requests are executed implicitly', async() => { - // const sql = postgres({ debug: true, idle_timeout: 1, fetch_types: false }) - // return [ - // 'testing', - // (await sql.begin(sql => [ - // sql`select set_config('bun_sql.test', 'testing', true)`, - // sql`select current_setting('bun_sql.test') as x` - // ]))[1][0].x - // ] - // }) + test("Transaction requests are executed implicitly", async () => { + const sql = postgres({ ...options, debug: true, idle_timeout: 1, fetch_types: false }); + expect( + ( + await sql.begin(sql => [ + sql`select set_config('bun_sql.test', 'testing', true)`, + sql`select current_setting('bun_sql.test') as x`, + ]) + )[1][0].x, + ).toBe("testing"); + }); - // t('Uncaught transaction request errors bubbles to transaction', async() => [ - // '42703', - // (await sql.begin(sql => [ - // sql`select wat`, - // sql`select current_setting('bun_sql.test') as x, ${ 1 } as a` - // ]).catch(e => e.code)) - // ]) + test("Uncaught transaction request errosó rs bubbles to transaction", async () => { + const sql = postgres({ ...options, debug: true, idle_timeout: 1, fetch_types: false }); + expect( + await sql + .begin(sql => [sql`select wat`, sql`select current_setting('bun_sql.test') as x, ${1} as a`]) + .catch(e => e.errno), + ).toBe(42703); + }); - // t('Fragments in transactions', async() => [ - // true, - // (await sql.begin(sql => sql`select true as x where ${ sql`1=1` }`))[0].x - // ]) + // test.only("Fragments in transactions", async () => { + // const sql = postgres({ ...options, debug: true, idle_timeout: 1, fetch_types: false }); + // expect((await sql.begin(sql => sql`select true as x where ${sql`1=1`}`))[0].x).toBe(true); + // }); // t('Transaction rejects with rethrown error', async() => [ // 'WAT', From 91a10aa75826b2b60f3b8a1b82e366dda6215a27 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 20:45:20 -0800 Subject: [PATCH 15/35] use START TRANSACTION instead of BEGIN --- src/js/bun/sql.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 9c1920e221128b..eb3c3d1b446301 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -875,8 +875,11 @@ function SQL(o) { values = v.value; } if (!allowUnsafeTransaction) { - if (sqlString.length === 5 && connectionInfo.max !== 1 && sqlString.toUpperCase() === "BEGIN") { - throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); + if (connectionInfo.max !== 1) { + const upperCaseSqlString = sqlString.toUpperCase().trim(); + if (upperCaseSqlString.startsWith("BEGIN") || upperCaseSqlString.startsWith("START TRANSACTION")) { + throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); + } } } return createQuery(sqlString, values, new SQLResultArray(), columns); @@ -1151,12 +1154,13 @@ function SQL(o) { }; let needs_rollback = false; try { + //TODO: this works well for MySQL and POSTGRES but not for SQLite or MSSQL if (options) { //@ts-ignore - await transaction_sql(`BEGIN ${options}`); + await transaction_sql(`START TRANSACTION ${options}`); } else { //@ts-ignore - await transaction_sql("BEGIN"); + await transaction_sql("START TRANSACTION"); } needs_rollback = true; let transaction_result = await callback(transaction_sql); @@ -1215,7 +1219,8 @@ function SQL(o) { sql.begin = (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { /* - BEGIN; -- works on POSTGRES, MySQL, and SQLite (need to change to BEGIN TRANSACTION on MSSQL) + BEGIN; -- works on POSTGRES, MySQL (autocommit is true, no options accepted), and SQLite (no options accepted) (need to change to BEGIN TRANSACTION on MSSQL) + START TRANSACTION; -- works on POSTGRES, MySQL (autocommit is false, options accepted), (need to change to BEGIN TRANSACTION on MSSQL and BEGIN on SQLite) -- Create a SAVEPOINT SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to SAVE TRANSACTION on MSSQL) From 7fbb8aed77ba214b41a4297ad6835f0582ded0c2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Tue, 14 Jan 2025 21:25:46 -0800 Subject: [PATCH 16/35] make transactions adapter independent --- src/js/bun/sql.ts | 61 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index eb3c3d1b446301..31469e2ee65c46 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -839,8 +839,8 @@ function loadOptions(o) { if (adapter && !(adapter === "postgres" || adapter === "postgresql")) { throw new Error(`Unsupported adapter: ${adapter}. Only \"postgres\" is supported for now`); } - - const ret: any = { hostname, port, username, password, database, tls, query, sslMode }; + //TODO: when adding MySQL, SQLite or MSSQL we need to add the adapter to match + const ret: any = { hostname, port, username, password, database, tls, query, sslMode, adapter: "postgres" }; if (idleTimeout != null) { ret.idleTimeout = idleTimeout; } @@ -1078,6 +1078,39 @@ function SQL(o) { pooledConnection.onClose(onClose); let savepoints = 0; let transactionQueries = new Set(); + const adapter = connectionInfo.adapter; + let BEGIN_COMMAND: string = "BEGIN"; + let ROLLBACK_COMMAND: string = "COMMIT"; + let COMMIT_COMMAND: string = "ROLLBACK"; + let SAVEPOINT_COMMAND: string = "SAVEPOINT"; + let RELEASE_SAVEPOINT_COMMAND: string | null = "RELEASE SAVEPOINT"; + let ROLLBACK_TO_SAVEPOINT_COMMAND: string = "ROLLBACK TO SAVEPOINT"; + switch (adapter) { + case "postgres": + if (options) { + BEGIN_COMMAND = `BEGIN ${options}`; + } + break; + case "mysql": + // START TRANSACTION is autocommit false + BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; + break; + + case "sqlite": + // do not support options just use defaults + break; + case "mssql": + BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; + ROLLBACK_COMMAND = "ROLLBACK TRANSACTION"; + COMMIT_COMMAND = "COMMIT TRANSACTION"; + SAVEPOINT_COMMAND = "SAVE"; + RELEASE_SAVEPOINT_COMMAND = null; // mssql dont have release savepoint + ROLLBACK_TO_SAVEPOINT_COMMAND = "ROLLBACK TRANSACTION"; + break; + default: + // TODO: use ERR_ + throw new Error(`Unsupported adapter: ${adapter}.`); + } function transaction_sql(strings, ...values) { if (state.closed) { @@ -1115,7 +1148,7 @@ function SQL(o) { if (state.closed) { return Promise.reject(connectionClosedError()); } - await transaction_sql("ROLLBACK"); + await transaction_sql(ROLLBACK_COMMAND); state.closed = true; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); @@ -1136,43 +1169,39 @@ function SQL(o) { } // matchs the format of the savepoint name in postgres package const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; - await transaction_sql(`SAVEPOINT ${save_point_name}`); + await transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); try { let result = await savepoint_callback(transaction_sql); - await transaction_sql(`RELEASE SAVEPOINT ${save_point_name}`); + if (RELEASE_SAVEPOINT_COMMAND) { + // mssql dont have release savepoint + await transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); + } if (Array.isArray(result)) { result = await Promise.all(result); } return result; } catch (err) { if (!state.closed) { - await transaction_sql(`ROLLBACK TO SAVEPOINT ${save_point_name}`); + await transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); } throw err; } }; let needs_rollback = false; try { - //TODO: this works well for MySQL and POSTGRES but not for SQLite or MSSQL - if (options) { - //@ts-ignore - await transaction_sql(`START TRANSACTION ${options}`); - } else { - //@ts-ignore - await transaction_sql("START TRANSACTION"); - } + await transaction_sql(BEGIN_COMMAND); needs_rollback = true; let transaction_result = await callback(transaction_sql); if (Array.isArray(transaction_result)) { transaction_result = await Promise.all(transaction_result); } - await transaction_sql("COMMIT"); + await transaction_sql(COMMIT_COMMAND); return resolve(transaction_result); } catch (err) { try { if (!state.closed && needs_rollback) { - await transaction_sql("ROLLBACK"); + await transaction_sql(ROLLBACK_COMMAND); } } catch (err) { return reject(err); From d8ba2cbfdb92de8076e481f07b7615362577932a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Wed, 15 Jan 2025 12:27:31 -0800 Subject: [PATCH 17/35] add distributed transactions --- src/js/bun/sql.ts | 400 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 321 insertions(+), 79 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 31469e2ee65c46..cdb3890de24ad8 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1007,10 +1007,69 @@ function SQL(o) { return Promise.resolve(reserved_sql); }; + reserved_sql.commitDistributed = async function (name: string) { + if (state.closed) { + throw connectionClosedError(); + } + const adapter = connectionInfo.adapter; + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } + switch (adapter) { + case "postgres": + return await reserved_sql(`COMMIT PREPARED '${name}'`); + case "mysql": + return await reserved_sql(`XA COMMIT '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically committed.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; + reserved_sql.rollbackDistributed = async function (name: string) { + if (state.closed) { + throw connectionClosedError(); + } + const adapter = connectionInfo.adapter; + switch (adapter) { + case "postgres": + return await reserved_sql(`ROLLBACK PREPARED '${name}'`); + case "mysql": + return await reserved_sql(`XA ROLLBACK '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically rolled back.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; + // reserve is allowed to be called inside reserved connection but will return a new reserved connection from the pool // this matchs the behavior of the postgres package reserved_sql.reserve = () => sql.reserve(); + 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.closed) { + return Promise.reject(connectionClosedError()); + } + let callback = fn; + + if (typeof name !== "string") { + return Promise.reject($ERR_INVALID_ARG_VALUE("name", name, "must be a string")); + } + + if (!$isCallable(callback)) { + return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); + } + const { promise, resolve, reject } = Promise.withResolvers(); + // lets just reuse the same code path as the transaction begin + onTransactionConnected(callback, name, resolve, reject, true, true, null, pooledConnection); + return promise; + }; reserved_sql.begin = (options_or_fn: string | TransactionCallback, 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.closed) { @@ -1029,7 +1088,7 @@ function SQL(o) { } const { promise, resolve, reject } = Promise.withResolvers(); // lets just reuse the same code path as the transaction begin - onTransactionConnected(callback, options, resolve, reject, true, null, pooledConnection); + onTransactionConnected(callback, options, resolve, reject, true, false, null, pooledConnection); return promise; }; @@ -1066,7 +1125,38 @@ function SQL(o) { reserved_sql.options = sql.options; resolve(reserved_sql); } - async function onTransactionConnected(callback, options, resolve, reject, dontRelease, err, pooledConnection) { + async function onTransactionConnected( + callback, + options, + resolve, + reject, + dontRelease, + distributed, + err, + pooledConnection, + ) { + /* + BEGIN; -- works on POSTGRES, MySQL (autocommit is true, no options accepted), and SQLite (no options accepted) (need to change to BEGIN TRANSACTION on MSSQL) + START TRANSACTION; -- works on POSTGRES, MySQL (autocommit is false, options accepted), (need to change to BEGIN TRANSACTION on MSSQL and BEGIN on SQLite) + + -- Create a SAVEPOINT + SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to SAVE TRANSACTION on MSSQL) + + -- QUERY + + -- Roll back to SAVEPOINT if needed + ROLLBACK TO SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + + -- Release the SAVEPOINT + RELEASE SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (MSSQL dont have RELEASE SAVEPOINT you just need to transaction again) + + -- Commit the transaction + COMMIT; -- works on POSTGRES, MySQL, and SQLite (need to change to COMMIT TRANSACTION on MSSQL) + -- or rollback everything + ROLLBACK; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + + */ + if (err) { return reject(err); } @@ -1074,8 +1164,7 @@ function SQL(o) { closed: false, reject, }; - const onClose = onTransactionDisconnected.bind(state); - pooledConnection.onClose(onClose); + let savepoints = 0; let transactionQueries = new Set(); const adapter = connectionInfo.adapter; @@ -1085,33 +1174,76 @@ function SQL(o) { let SAVEPOINT_COMMAND: string = "SAVEPOINT"; let RELEASE_SAVEPOINT_COMMAND: string | null = "RELEASE SAVEPOINT"; let ROLLBACK_TO_SAVEPOINT_COMMAND: string = "ROLLBACK TO SAVEPOINT"; - switch (adapter) { - case "postgres": - if (options) { - BEGIN_COMMAND = `BEGIN ${options}`; - } - break; - case "mysql": - // START TRANSACTION is autocommit false - BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; - break; + // MySQL and maybe other adapters need to call XA END or some other command before commit or rollback in a distributed transaction + let BEFORE_COMMIT_OR_ROLLBACK_COMMAND: string | null = null; + if (distributed) { + if (options.indexOf("'") !== -1) { + pool.release(pooledConnection); + return reject(new Error(`Distributed transaction name cannot contain single quotes.`)); + } + // distributed transaction + // in distributed transaction options is the name/id of the transaction + switch (adapter) { + case "postgres": + // in postgres we only need to call prepare transaction instead of commit + COMMIT_COMMAND = `PREPARE TRANSACTION '${options}'`; + break; + case "mysql": + // MySQL we use XA transactions + // START TRANSACTION is autocommit false + BEGIN_COMMAND = `XA START '${options}'`; + BEFORE_COMMIT_OR_ROLLBACK_COMMAND = `XA END '${options}'`; + COMMIT_COMMAND = `XA PREPARE '${options}'`; + ROLLBACK_COMMAND = `XA ROLLBACK '${options}'`; + break; + case "sqlite": + pool.release(pooledConnection); + + // do not support options just use defaults + return reject(new Error(`SQLite dont support distributed transactions.`)); + case "mssql": + BEGIN_COMMAND = ` BEGIN DISTRIBUTED TRANSACTION ${options}`; + ROLLBACK_COMMAND = `ROLLBACK TRANSACTION ${options}`; + COMMIT_COMMAND = `COMMIT TRANSACTION ${options}`; + break; + default: + pool.release(pooledConnection); - case "sqlite": - // do not support options just use defaults - break; - case "mssql": - BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; - ROLLBACK_COMMAND = "ROLLBACK TRANSACTION"; - COMMIT_COMMAND = "COMMIT TRANSACTION"; - SAVEPOINT_COMMAND = "SAVE"; - RELEASE_SAVEPOINT_COMMAND = null; // mssql dont have release savepoint - ROLLBACK_TO_SAVEPOINT_COMMAND = "ROLLBACK TRANSACTION"; - break; - default: - // TODO: use ERR_ - throw new Error(`Unsupported adapter: ${adapter}.`); + // TODO: use ERR_ + return reject(new Error(`Unsupported adapter: ${adapter}.`)); + } + } else { + // normal transaction + switch (adapter) { + case "postgres": + if (options) { + BEGIN_COMMAND = `BEGIN ${options}`; + } + break; + case "mysql": + // START TRANSACTION is autocommit false + BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; + break; + + case "sqlite": + // do not support options just use defaults + break; + case "mssql": + BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; + ROLLBACK_COMMAND = "ROLLBACK TRANSACTION"; + COMMIT_COMMAND = "COMMIT TRANSACTION"; + SAVEPOINT_COMMAND = "SAVE"; + RELEASE_SAVEPOINT_COMMAND = null; // mssql dont have release savepoint + ROLLBACK_TO_SAVEPOINT_COMMAND = "ROLLBACK TRANSACTION"; + break; + default: + pool.release(pooledConnection); + // TODO: use ERR_ + return reject(new Error(`Unsupported adapter: ${adapter}.`)); + } } - + const onClose = onTransactionDisconnected.bind(state); + pooledConnection.onClose(onClose); function transaction_sql(strings, ...values) { if (state.closed) { return Promise.reject(connectionClosedError()); @@ -1130,13 +1262,66 @@ function SQL(o) { if (state.closed) { return Promise.reject(connectionClosedError()); } + return Promise.resolve(transaction_sql); }; + transaction_sql.commitDistributed = async function (name: string) { + if (state.closed) { + throw connectionClosedError(); + } + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } + switch (adapter) { + case "postgres": + return await transaction_sql(`COMMIT PREPARED '${name}'`); + case "mysql": + return await transaction_sql(`XA COMMIT '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically committed.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; + transaction_sql.rollbackDistributed = async function (name: string) { + if (state.closed) { + throw connectionClosedError(); + } + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } + switch (adapter) { + case "postgres": + return await transaction_sql(`ROLLBACK PREPARED '${name}'`); + case "mysql": + return await transaction_sql(`XA ROLLBACK '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically rolled back.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; // begin is not allowed on a transaction we need to use savepoint() instead transaction_sql.begin = function () { + if (distributed) { + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call begin inside a distributed transaction"); + } throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call begin inside a transaction use savepoint() instead"); }; + transaction_sql.beginDistributed = function () { + if (distributed) { + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call beginDistributed inside a distributed transaction"); + } + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE( + "cannot call beginDistributed inside a transaction use savepoint() instead", + ); + }; + transaction_sql.flush = function () { if (state.closed) { throw connectionClosedError(); @@ -1148,46 +1333,55 @@ function SQL(o) { if (state.closed) { return Promise.reject(connectionClosedError()); } + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } await transaction_sql(ROLLBACK_COMMAND); state.closed = true; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); transaction_sql.options = sql.options; - transaction_sql.savepoint = async (fn: TransactionCallback, name?: string) => { - let savepoint_callback = fn; - - if (state.closed) { - throw connectionClosedError(); - } - if ($isCallable(name)) { - savepoint_callback = name as unknown as TransactionCallback; - name = ""; - } - if (!$isCallable(savepoint_callback)) { - throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); - } - // matchs the format of the savepoint name in postgres package - const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; - await transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); + if (distributed) { + transaction_sql.savepoint = async (fn: TransactionCallback, name?: string): Promise => { + throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call savepoint inside a distributed transaction"); + }; + } else { + transaction_sql.savepoint = async (fn: TransactionCallback, name?: string): Promise => { + let savepoint_callback = fn; - try { - let result = await savepoint_callback(transaction_sql); - if (RELEASE_SAVEPOINT_COMMAND) { - // mssql dont have release savepoint - await transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); + if (state.closed) { + throw connectionClosedError(); } - if (Array.isArray(result)) { - result = await Promise.all(result); + if ($isCallable(name)) { + savepoint_callback = name as unknown as TransactionCallback; + name = ""; } - return result; - } catch (err) { - if (!state.closed) { - await transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); + if (!$isCallable(savepoint_callback)) { + throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function"); } - throw err; - } - }; + // matchs the format of the savepoint name in postgres package + const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; + await transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); + + try { + let result = await savepoint_callback(transaction_sql); + if (RELEASE_SAVEPOINT_COMMAND) { + // mssql dont have release savepoint + await transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); + } + if (Array.isArray(result)) { + result = await Promise.all(result); + } + return result; + } catch (err) { + if (!state.closed) { + await transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); + } + throw err; + } + }; + } let needs_rollback = false; try { await transaction_sql(BEGIN_COMMAND); @@ -1196,11 +1390,19 @@ function SQL(o) { if (Array.isArray(transaction_result)) { transaction_result = await Promise.all(transaction_result); } + // at this point we dont need to rollback anymore + needs_rollback = false; + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } await transaction_sql(COMMIT_COMMAND); return resolve(transaction_result); } catch (err) { try { if (!state.closed && needs_rollback) { + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } await transaction_sql(ROLLBACK_COMMAND); } } catch (err) { @@ -1245,30 +1447,70 @@ function SQL(o) { pool.connect(onReserveConnected.bind(promiseWithResolvers)); return promiseWithResolvers.promise; }; + sql.rollbackDistributed = async function (name: string) { + if (pool.closed) { + throw connectionClosedError(); + } + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } + const adapter = connectionInfo.adapter; + switch (adapter) { + case "postgres": + return await sql(`ROLLBACK PREPARED '${name}'`); + case "mysql": + return await sql(`XA ROLLBACK '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically rolled back.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; - sql.begin = (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { - /* - BEGIN; -- works on POSTGRES, MySQL (autocommit is true, no options accepted), and SQLite (no options accepted) (need to change to BEGIN TRANSACTION on MSSQL) - START TRANSACTION; -- works on POSTGRES, MySQL (autocommit is false, options accepted), (need to change to BEGIN TRANSACTION on MSSQL and BEGIN on SQLite) - - -- Create a SAVEPOINT - SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to SAVE TRANSACTION on MSSQL) - - -- QUERY - - -- Roll back to SAVEPOINT if needed - ROLLBACK TO SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + sql.commitDistributed = async function (name: string) { + if (pool.closed) { + throw connectionClosedError(); + } + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } + const adapter = connectionInfo.adapter; + switch (adapter) { + case "postgres": + return await sql(`COMMIT PREPARED '${name}'`); + case "mysql": + return await sql(`XA COMMIT '${name}'`); + case "mssql": + throw Error(`MSSQL distributed transaction is automatically committed.`); + case "sqlite": + throw Error(`SQLite dont support distributed transactions.`); + default: + throw Error(`Unsupported adapter: ${adapter}.`); + } + }; - -- Release the SAVEPOINT - RELEASE SAVEPOINT my_savepoint; -- works on POSTGRES, MySQL, and SQLite (MSSQL dont have RELEASE SAVEPOINT you just need to transaction again) + sql.beginDistributed = (name: string, fn: TransactionCallback) => { + if (pool.closed) { + return Promise.reject(connectionClosedError()); + } + let callback = fn; - -- Commit the transaction - COMMIT; -- works on POSTGRES, MySQL, and SQLite (need to change to COMMIT TRANSACTION on MSSQL) - -- or rollback everything - ROLLBACK; -- works on POSTGRES, MySQL, and SQLite (need to change to ROLLBACK TRANSACTION on MSSQL) + if (typeof name !== "string") { + return Promise.reject($ERR_INVALID_ARG_VALUE("name", name, "must be a string")); + } - */ + if (!$isCallable(callback)) { + return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); + } + const { promise, resolve, reject } = Promise.withResolvers(); + // lets just reuse the same code path as the transaction begin + pool.connect(onTransactionConnected.bind(null, callback, name, resolve, reject, false, true)); + return promise; + }; + sql.begin = (options_or_fn: string | TransactionCallback, fn?: TransactionCallback) => { if (pool.closed) { return Promise.reject(connectionClosedError()); } @@ -1284,7 +1526,7 @@ function SQL(o) { return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); } const { promise, resolve, reject } = Promise.withResolvers(); - pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false)); + pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false, false)); return promise; }; From 0425019f6f0cfe81e3ea5a7c5a540007824c8303 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Wed, 15 Jan 2025 12:36:51 -0800 Subject: [PATCH 18/35] test --- test/js/sql/sql.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 4c2809e452adc1..e253e28254e49d 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -506,6 +506,21 @@ if (!isCI && hasPsql) { // } // }); + test("Prepared transaction", async () => { + await sql`create table test (a int)`; + + try { + await sql.beginDistributed("tx1", async sql => { + await sql`insert into test values(1)`; + }); + + await sql.commitDistributed("tx1"); + expect((await sql`select count(1) from test`)[0].count).toBe("1"); + } finally { + await sql`drop table test`; + } + }); + test("Transaction requests are executed implicitly", async () => { const sql = postgres({ ...options, debug: true, idle_timeout: 1, fetch_types: false }); expect( From f27f3e3f1f0856bd04bb133808566bdffe99a785 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Wed, 15 Jan 2025 12:58:16 -0800 Subject: [PATCH 19/35] more tests --- src/sql/postgres.zig | 2 +- test/js/sql/sql.test.ts | 109 +++++++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index fce3c4a35fcf5b..69760b5b47a9d0 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -2460,7 +2460,7 @@ pub const PostgresSQLConnection = struct { DataCell.Putter.put, ); - const pending_value = PostgresSQLQuery.pendingValueGetCached(request.thisValue) orelse .zero; + const pending_value = if (request.thisValue == .zero) .zero else PostgresSQLQuery.pendingValueGetCached(request.thisValue) orelse .zero; pending_value.ensureStillAlive(); const result = putter.toJS(this.globalObject, pending_value, statement.structure(this.js_value, this.globalObject), statement.fields_flags); diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index e253e28254e49d..ba7aad37ade6f3 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -547,56 +547,75 @@ if (!isCI && hasPsql) { // expect((await sql.begin(sql => sql`select true as x where ${sql`1=1`}`))[0].x).toBe(true); // }); - // t('Transaction rejects with rethrown error', async() => [ - // 'WAT', - // await sql.begin(async sql => { - // try { - // await sql`select exception` - // } catch (ex) { - // throw new Error('WAT') - // } - // }).catch(e => e.message) - // ]) - - // t('Parallel transactions', async() => { - // await sql`create table test (a int)` - // return ['11', (await Promise.all([ - // sql.begin(sql => sql`select 1`), - // sql.begin(sql => sql`select 1`) - // ])).map(x => x.count).join(''), await sql`drop table test`] - // }) + test("Transaction rejects with rethrown error", async () => { + await using sql = postgres({ ...options }); + expect( + await sql + .begin(async sql => { + try { + await sql`select exception`; + } catch (ex) { + throw new Error("WAT"); + } + }) + .catch(e => e.message), + ).toBe("WAT"); + }); - // t("Many transactions at beginning of connection", async () => { - // const sql = postgres(options); - // const xs = await Promise.all(Array.from({ length: 100 }, () => sql.begin(sql => sql`select 1`))); - // return [100, xs.length]; - // }); + test("Parallel transactions", async () => { + await sql`create table test (a int)`; + expect( + (await Promise.all([sql.begin(sql => sql`select 1 as count`), sql.begin(sql => sql`select 1 as count`)])) + .map(x => x[0].count) + .join(""), + ).toBe("11"); + await sql`drop table test`; + }); - // t('Transactions array', async() => { - // await sql`create table test (a int)` + test("Many transactions at beginning of connection", async () => { + await using sql = postgres(options); + const xs = await Promise.all(Array.from({ length: 100 }, () => sql.begin(sql => sql`select 1`))); + return expect(xs.length).toBe(100); + }); - // return ['11', (await sql.begin(sql => [ - // sql`select 1`.then(x => x), - // sql`select 1` - // ])).map(x => x.count).join(''), await sql`drop table test`] - // }) + test("Transactions array", async () => { + await using sql = postgres(options); + await sql`create table test (a int)`; + try { + expect( + (await sql.begin(sql => [sql`select 1 as count`, sql`select 1 as count`])).map(x => x[0].count).join(""), + ).toBe("11"); + } finally { + await sql`drop table test`; + } + }); - // t('Transaction waits', async() => { - // await sql`create table test (a int)` - // await sql.begin(async sql => { - // await sql`insert into test values(1)` - // await sql.savepoint(async sql => { - // await sql`insert into test values(2)` - // throw new Error('please rollback') - // }).catch(() => { /* ignore */ }) - // await sql`insert into test values(3)` - // }) + test("Transaction waits", async () => { + await using sql = postgres({ ...options }); + await sql`create table test (a int)`; + try { + await sql.begin(async sql => { + await sql`insert into test values(1)`; + await sql + .savepoint(async sql => { + await sql`insert into test values(2)`; + throw new Error("please rollback"); + }) + .catch(() => { + /* ignore */ + }); + await sql`insert into test values(3)`; + }); - // return ['11', (await Promise.all([ - // sql.begin(sql => sql`select 1`), - // sql.begin(sql => sql`select 1`) - // ])).map(x => x.count).join(''), await sql`drop table test`] - // }) + expect( + (await Promise.all([sql.begin(sql => sql`select 1 as count`), sql.begin(sql => sql`select 1 as count`)])) + .map(x => x[0].count) + .join(""), + ).toBe("11"); + } finally { + await sql`drop table test`; + } + }); // t('Helpers in Transaction', async() => { // return ['1', (await sql.begin(async sql => From 09e6b350c571ad4604ff5a93bca340d7200faa31 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Wed, 15 Jan 2025 19:07:17 -0800 Subject: [PATCH 20/35] wip --- src/js/bun/sql.ts | 86 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index cdb3890de24ad8..e2060ddbe173d2 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -254,6 +254,8 @@ class PooledConnection { onFinish: ((err: Error | null) => void) | null = null; canBeConnected: boolean = false; connectionInfo: any; + reserved: boolean = false; + queriesUsing: number = 0; #onConnected(err, _) { const connectionInfo = this.connectionInfo; if (connectionInfo?.onconnect) { @@ -449,11 +451,21 @@ class ConnectionPool { } } } - close() { + async close(options?: { timeout?: number }) { if (this.closed) { return Promise.reject(connectionClosedError()); } - this.closed = true; + let timeout = options?.timeout; + if (timeout) { + timeout = Number(timeout); + if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { + throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); + } + this.closed = true; + await Bun.sleep(timeout * 1000); + } else { + this.closed = true; + } let pending; while ((pending = this.waitingQueue.shift())) { pending(connectionClosedError(), null); @@ -490,7 +502,12 @@ class ConnectionPool { this.waitingQueue.length = 0; return Promise.all(promises); } - connect(onConnected: (err: Error | null, result: any) => void) { + + /** + * @param {function} onConnected - The callback function to be called when the connection is established. + * @param {boolean} reserved - Whether the connection is reserved, if is reserved the connection will not be released until release is called, if not release will only decrement the queriesUsing counter + */ + connect(onConnected: (err: Error | null, result: any) => void, reserved: boolean = false) { if (this.closed) { return onConnected(connectionClosedError(), null); } @@ -1098,12 +1115,23 @@ function SQL(o) { } return pooledConnection.flush(); }; - reserved_sql.close = async () => { + reserved_sql.close = async (options?: { timeout?: number }) => { if (state.closed) { return Promise.reject(connectionClosedError()); } - // close will release the connection back to the pool but will actually close the connection if its open - state.closed = true; + let timeout = options?.timeout; + if (timeout) { + timeout = Number(timeout); + if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { + throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); + } + state.closed = true; + // no new queries will be allowed + await Bun.sleep(timeout * 1000); + } else { + // close will release the connection back to the pool but will actually close the connection if its open + state.closed = true; + } pooledConnection.queries.delete(onClose); pooledConnection.close(); @@ -1123,6 +1151,9 @@ function SQL(o) { reserved_sql[Symbol.dispose] = () => reserved_sql.release(); reserved_sql.options = sql.options; + reserved_sql.transaction = reserved_sql.begin; + reserved_sql.distributed = reserved_sql.beginDistributed; + reserved_sql.end = reserved_sql.close; resolve(reserved_sql); } async function onTransactionConnected( @@ -1226,7 +1257,10 @@ function SQL(o) { break; case "sqlite": - // do not support options just use defaults + if (options) { + // sqlite supports DEFERRED, IMMEDIATE, EXCLUSIVE + BEGIN_COMMAND = `BEGIN ${options}`; + } break; case "mssql": BEGIN_COMMAND = options ? `START TRANSACTION ${options}` : "START TRANSACTION"; @@ -1328,20 +1362,34 @@ function SQL(o) { } return pooledConnection.flush(); }; - transaction_sql.close = async function () { + transaction_sql.close = async function (options?: { timeout?: number }) { // we dont actually close the connection here, we just set the state to closed and rollback the transaction if (state.closed) { return Promise.reject(connectionClosedError()); } - if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + let timeout = options?.timeout; + if (timeout) { + timeout = Number(timeout); + if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { + throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); + } + await Bun.sleep(timeout * 1000); + } + if (!state.closed) { + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } + await transaction_sql(ROLLBACK_COMMAND); + state.closed = true; } - await transaction_sql(ROLLBACK_COMMAND); - state.closed = true; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); transaction_sql.options = sql.options; + transaction_sql.transaction = transaction_sql.begin; + transaction_sql.distributed = transaction_sql.beginDistributed; + transaction_sql.end = transaction_sql.close; + if (distributed) { transaction_sql.savepoint = async (fn: TransactionCallback, name?: string): Promise => { throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call savepoint inside a distributed transaction"); @@ -1444,7 +1492,7 @@ function SQL(o) { } const promiseWithResolvers = Promise.withResolvers(); - pool.connect(onReserveConnected.bind(promiseWithResolvers)); + pool.connect(onReserveConnected.bind(promiseWithResolvers), true); return promiseWithResolvers.promise; }; sql.rollbackDistributed = async function (name: string) { @@ -1506,7 +1554,7 @@ function SQL(o) { } const { promise, resolve, reject } = Promise.withResolvers(); // lets just reuse the same code path as the transaction begin - pool.connect(onTransactionConnected.bind(null, callback, name, resolve, reject, false, true)); + pool.connect(onTransactionConnected.bind(null, callback, name, resolve, reject, false, true), true); return promise; }; @@ -1526,10 +1574,9 @@ function SQL(o) { return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); } const { promise, resolve, reject } = Promise.withResolvers(); - pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false, false)); + pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false, false), true); return promise; }; - sql.connect = () => { if (pool.closed) { return Promise.reject(connectionClosedError()); @@ -1554,8 +1601,8 @@ function SQL(o) { return promise; }; - sql.close = async () => { - await pool.close(); + sql.close = async (options?: { timeout?: number }) => { + await pool.close(options); }; sql[Symbol.asyncDispose] = () => sql.close(); @@ -1563,6 +1610,9 @@ function SQL(o) { sql.flush = () => pool.flush(); sql.options = connectionInfo; + sql.transaction = sql.begin; + sql.distributed = sql.beginDistributed; + sql.end = sql.close; return sql; } From be7c841bfb5e34d98ce7072bd8b0fa1990d5f50c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Wed, 15 Jan 2025 19:16:45 -0800 Subject: [PATCH 21/35] wip --- src/js/bun/sql.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index e2060ddbe173d2..32f48cbd6b61e1 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -508,6 +508,7 @@ class ConnectionPool { * @param {boolean} reserved - Whether the connection is reserved, if is reserved the connection will not be released until release is called, if not release will only decrement the queriesUsing counter */ connect(onConnected: (err: Error | null, result: any) => void, reserved: boolean = false) { + // TODO: this always reserve a connection, we should only reserve if reserved is true if (this.closed) { return onConnected(connectionClosedError(), null); } From a06f824af793b810f4aed6a47fb66fe2ec88d59e Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Fri, 17 Jan 2025 17:44:01 -0800 Subject: [PATCH 22/35] wip --- src/js/bun/sql.ts | 209 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 171 insertions(+), 38 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 32f48cbd6b61e1..f6ca813485a93e 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -254,8 +254,12 @@ class PooledConnection { onFinish: ((err: Error | null) => void) | null = null; canBeConnected: boolean = false; connectionInfo: any; + /// reserved is used to indicate that the connection is currently reserved reserved: boolean = false; - queriesUsing: number = 0; + /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 + preReserved: boolean = false; + /// 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, _) { const connectionInfo = this.connectionInfo; if (connectionInfo?.onconnect) { @@ -266,6 +270,9 @@ class PooledConnection { this.state = err ? "closed" : "connected"; const onFinish = this.onFinish; if (onFinish) { + this.queryCount = 0; + this.reserved = false; + // pool is closed, lets finish the connection if (err) { onFinish(err); @@ -274,7 +281,7 @@ class PooledConnection { } return; } - this.pool.release(this); + this.pool.release(this, true); } #onClose(err) { const connectionInfo = this.connectionInfo; @@ -289,6 +296,8 @@ class PooledConnection { this.pool.readyConnections.delete(this); const queries = new Set(this.queries); this.queries.clear(); + this.queryCount = 0; + this.reserved = false; // notify all queries that the connection is closed for (const onClose of queries) { onClose(err); @@ -298,7 +307,7 @@ class PooledConnection { onFinish(err); } - this.pool.release(this); + this.pool.release(this, true); } constructor(connectionInfo, pool: ConnectionPool) { this.connection = createConnection(connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); @@ -370,6 +379,8 @@ class ConnectionPool { connections: PooledConnection[]; readyConnections: Set; waitingQueue: Array<(err: Error | null, result: any) => void> = []; + reservedQueue: Array<(err: Error | null, result: any) => void> = []; + poolStarted: boolean = false; closed: boolean = false; constructor(connectionInfo) { @@ -378,32 +389,103 @@ class ConnectionPool { this.readyConnections = new Set(); } - release(connection: PooledConnection) { + flushConcurrentQueries() { + if (this.waitingQueue.length === 0) { + return; + } + while (this.waitingQueue.length > 0) { + let endReached = true; + + const nonReservedConnections = Array.from(this.readyConnections).filter(c => !c.preReserved); + if (nonReservedConnections.length === 0) { + return; + } + // kinda balance the load between connections + const orderedConnections = nonReservedConnections.sort((a, b) => a.queryCount - b.queryCount); + const leastQueries = orderedConnections[0].queryCount; + + for (const connection of orderedConnections) { + if (connection.queryCount > leastQueries) { + endReached = false; + break; + } + + const pending = this.waitingQueue.shift(); + if (pending) { + connection.queryCount++; + pending(null, connection); + } + } + const halfPoolSize = Math.ceil(this.connections.length / 2); + if (endReached || orderedConnections.length < halfPoolSize) { + // we are able to distribute the load between connections but the connection pool is less than half of the pool size + // so we can stop here and wait for the next tick to flush the waiting queue + break; + } + } if (this.waitingQueue.length > 0) { - if (connection.storedError) { - // this connection got a error but maybe we can wait for another + // we still wanna to flush the waiting queue but lets wait for the next tick because some connections might be released + // this is better for query performance + process.nextTick(this.flushConcurrentQueries.bind(this)); + } + } + + release(connection: PooledConnection, connectingEvent: boolean = false) { + if (!connectingEvent) { + connection.queryCount--; + } + const was_reserved = connection.reserved; + connection.reserved = false; + connection.preReserved = false; + if (connection.state !== "connected") { + // connection is not ready + return; + } + if (was_reserved) { + if (this.waitingQueue.length > 0) { + if (connection.storedError) { + // this connection got a error but maybe we can wait for another - if (this.hasConnectionsAvailable()) { + if (this.hasConnectionsAvailable()) { + return; + } + + // we have no connections available so lets fails + let pending; + while ((pending = this.waitingQueue.shift())) { + pending.onConnected(connection.storedError, connection); + } return; } - - // we have no connections available so lets fails - let pending; - while ((pending = this.waitingQueue.shift())) { - pending(connection.storedError, connection); + const pendingReserved = this.reservedQueue.shift(); + if (pendingReserved) { + connection.reserved = true; + connection.queryCount++; + // we have a connection waiting for a reserved connection lets prioritize it + pendingReserved(connection.storedError, connection); + return; } - return; + this.flushConcurrentQueries(); + } else { + // connection is ready, lets add it back to the ready connections + this.readyConnections.add(connection); } - // we have some pending connections, lets connect them with the released connection - const pending = this.waitingQueue.shift(); - pending?.(connection.storedError, connection); } else { - if (connection.state !== "connected") { - // connection is not ready, lets not add it to the ready connections - return; + if (connection.queryCount == 0) { + // ok we can actually bind reserved queries to it + const pendingReserved = this.reservedQueue.shift(); + if (pendingReserved) { + connection.reserved = true; + connection.queryCount++; + // we have a connection waiting for a reserved connection lets prioritize it + pendingReserved(connection.storedError, connection); + return; + } } - // connection is ready, lets add it to the ready connections + this.readyConnections.add(connection); + + this.flushConcurrentQueries(); } } @@ -505,13 +587,13 @@ class ConnectionPool { /** * @param {function} onConnected - The callback function to be called when the connection is established. - * @param {boolean} reserved - Whether the connection is reserved, if is reserved the connection will not be released until release is called, if not release will only decrement the queriesUsing counter + * @param {boolean} reserved - Whether the connection is reserved, if is reserved the connection will not be released until release is called, if not release will only decrement the queryCount counter */ connect(onConnected: (err: Error | null, result: any) => void, reserved: boolean = false) { - // TODO: this always reserve a connection, we should only reserve if reserved is true if (this.closed) { return onConnected(connectionClosedError(), null); } + if (this.readyConnections.size === 0) { // no connection ready lets make some let retry_in_progress = false; @@ -531,7 +613,12 @@ class ConnectionPool { if (!retry_in_progress) { // avoid adding to the queue twice, we wanna to retry every available pool connection retry_in_progress = true; - this.waitingQueue.push(onConnected); + if (reserved) { + // we are not sure what connection will be available so we dont pre reserve + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } } } else { // we have some error, lets grab it and fail if unable to start a connection @@ -542,11 +629,15 @@ class ConnectionPool { all_closed = false; } } - if (!all_closed && !retry_in_progress) { // is possible to connect because we have some working connections, or we are just without network for some reason // wait for connection to be released or fail - this.waitingQueue.push(onConnected); + if (reserved) { + // we are not sure what connection will be available so we dont pre reserve + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } } else { // impossible to connect or retry onConnected(storedError, null); @@ -554,19 +645,50 @@ class ConnectionPool { return; } // we never started the pool, lets start it - this.waitingQueue.push(onConnected); + if (reserved) { + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } this.poolStarted = true; const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { + // pool is always at least 1 connection + this.connections[0] = new PooledConnection(this.connectionInfo, this); + this.connections[0].preReserved = reserved; // lets pre reserve the first connection + for (let i = 1; i < pollSize; i++) { this.connections[i] = new PooledConnection(this.connectionInfo, this); } return; } - - // we have some connection ready - const first = this.readyConnections.values().next().value; - this.readyConnections.delete(first); - onConnected(null, first); + if (reserved) { + let connectionWithLeastQueries: PooledConnection | null = null; + let leastQueries = Infinity; + for (const connection of this.readyConnections) { + if (connection.reserved || connection.preReserved) continue; + const queryCount = connection.queryCount; + if (queryCount > 0) { + if (queryCount < leastQueries) { + leastQueries = queryCount; + connectionWithLeastQueries = connection; + continue; + } + } + connection.reserved = true; + connection.queryCount++; + this.readyConnections.delete(connection); + onConnected(null, connection); + return; + } + if (connectionWithLeastQueries) { + // lets mark the connection with the least queries as preReserved if any + connectionWithLeastQueries.preReserved = true; + } + // no connection available to be reserved lets wait for a connection to be released + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + this.flushConcurrentQueries(); + } } } @@ -748,7 +870,14 @@ function loadOptions(o) { } if (url) { - ({ hostname, port, username, password, protocol: adapter } = o = url); + ({ hostname, port, username, password, adapter } = o); + // object overrides url + hostname ||= url.hostname; + port ||= url.port; + username ||= url.username; + password ||= url.password; + adapter ||= url.protocol; + if (adapter[adapter.length - 1] === ":") { adapter = adapter.slice(0, -1); } @@ -851,14 +980,18 @@ function loadOptions(o) { port = Number(port); if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid port: ${port}`); + throw $ERR_INVALID_ARG_VALUE("port", port, "must be a non-negative integer between 1 and 65535"); } - if (adapter && !(adapter === "postgres" || adapter === "postgresql")) { - throw new Error(`Unsupported adapter: ${adapter}. Only \"postgres\" is supported for now`); + switch (adapter) { + case "postgres": + case "postgresql": + adapter = "postgres"; + break; + default: + throw new Error(`Unsupported adapter: ${adapter}. Only \"postgres\" is supported for now`); } - //TODO: when adding MySQL, SQLite or MSSQL we need to add the adapter to match - const ret: any = { hostname, port, username, password, database, tls, query, sslMode, adapter: "postgres" }; + const ret: any = { hostname, port, username, password, database, tls, query, sslMode, adapter }; if (idleTimeout != null) { ret.idleTimeout = idleTimeout; } From 775632ad52676617e137d82c521da516ce30971c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Fri, 17 Jan 2025 21:10:41 -0800 Subject: [PATCH 23/35] comments --- src/js/bun/sql.ts | 12 ++++++------ src/sql/postgres.zig | 18 +++--------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index f6ca813485a93e..55e2f69a192e5b 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -47,7 +47,6 @@ const { createQuery, PostgresSQLConnection, init, - setPromiseAsHandled, // maybe we should move this to utils.zig } = $zig("postgres.zig", "createBinding"); function normalizeSSLMode(value: string): SSLMode { @@ -106,7 +105,7 @@ class Query extends PublicPromise { this[_queryStatus] = handle ? 0 : QueryStatus.cancelled; } - [_run]() { + async [_run]() { const { [_handle]: handle, [_handler]: handler, [_queryStatus]: status } = this; if (status & (QueryStatus.executed | QueryStatus.error | QueryStatus.cancelled)) { @@ -114,7 +113,8 @@ class Query extends PublicPromise { } this[_queryStatus] |= QueryStatus.executed; - // await 1; + // this avoids a infinite loop + await 1; return handler(this, handle); } @@ -185,14 +185,14 @@ class Query extends PublicPromise { then() { this[_run](); const result = super.$then.$apply(this, arguments); - setPromiseAsHandled(result); + $markPromiseAsHandled(result); return result; } catch() { this[_run](); const result = super.catch.$apply(this, arguments); - setPromiseAsHandled(result); + $markPromiseAsHandled(result); return result; } @@ -363,7 +363,7 @@ class PooledConnection { case "ERR_POSTGRES_INVALID_SERVER_SIGNATURE": case "ERR_POSTGRES_INVALID_SERVER_KEY": case "ERR_POSTGRES_AUTHENTICATION_FAILED_PBKDF2": - // we can't retry this are authentication errors + // we can't retry these are authentication errors return false; default: // we can retry diff --git a/src/sql/postgres.zig b/src/sql/postgres.zig index 69760b5b47a9d0..445ad4e654f188 100644 --- a/src/sql/postgres.zig +++ b/src/sql/postgres.zig @@ -217,7 +217,7 @@ pub const PostgresSQLQuery = struct { binary: bool = false, pub usingnamespace JSC.Codegen.JSPostgresSQLQuery; - + const log = bun.Output.scoped(.PostgresSQLQuery, false); pub fn getTarget(this: *PostgresSQLQuery, globalObject: *JSC.JSGlobalObject) JSC.JSValue { if (this.thisValue == .zero) { return .zero; @@ -586,10 +586,11 @@ pub const PostgresSQLQuery = struct { signature.deinit(); if (has_params and this.statement.?.status == .parsing) { + // if it has params, we need to wait for ParamDescription to be received before we can write the data } else { this.binary = this.statement.?.fields.len > 0; - + log("bindAndExecute", .{}); PostgresRequest.bindAndExecute(globalObject, this.statement.?, binding_value, columns_value, PostgresSQLConnection.Writer, writer) catch |err| { if (!globalObject.hasException()) return globalObject.throwError(err, "failed to bind and execute query"); @@ -3178,14 +3179,6 @@ const Signature = struct { } }; -pub fn setPromiseAsHandled(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue { - const js_promise = callframe.argument(0); - if (js_promise.asAnyPromise()) |promise| { - promise.setHandled(globalObject.vm()); - } - - return .undefined; -} pub fn createBinding(globalObject: *JSC.JSGlobalObject) JSValue { const binding = JSValue.createEmptyObjectWithNullPrototype(globalObject); binding.put(globalObject, ZigString.static("PostgresSQLConnection"), PostgresSQLConnection.getConstructor(globalObject)); @@ -3201,11 +3194,6 @@ pub fn createBinding(globalObject: *JSC.JSGlobalObject) JSValue { ZigString.static("createConnection"), JSC.JSFunction.create(globalObject, "createQuery", PostgresSQLConnection.call, 2, .{}), ); - binding.put( - globalObject, - ZigString.static("setPromiseAsHandled"), - JSC.JSFunction.create(globalObject, "setPromiseAsHandled", setPromiseAsHandled, 1, .{}), - ); return binding; } From ae5aa8823b3454f93ce74c9dc9d84b2fac5efcfe Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Fri, 17 Jan 2025 21:21:39 -0800 Subject: [PATCH 24/35] wip --- src/js/bun/sql.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 55e2f69a192e5b..2e302ec29c0cce 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -544,7 +544,8 @@ class ConnectionPool { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } this.closed = true; - await Bun.sleep(timeout * 1000); + // TODO: check queryCount to this logic + // await Bun.sleep(timeout * 1000); } else { this.closed = true; } @@ -1261,7 +1262,8 @@ function SQL(o) { } state.closed = true; // no new queries will be allowed - await Bun.sleep(timeout * 1000); + // TODO: check queryCount to this logic + // await Bun.sleep(timeout * 1000); } else { // close will release the connection back to the pool but will actually close the connection if its open state.closed = true; @@ -1507,7 +1509,8 @@ function SQL(o) { if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } - await Bun.sleep(timeout * 1000); + // TODO: check queryCount to this logic + // await Bun.sleep(timeout * 1000); } if (!state.closed) { if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { From 69409d07eaa4f5994456a9efebcdcfe9b75dfb8c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 11:08:17 -0800 Subject: [PATCH 25/35] status as enum, replace booleans for flags --- src/js/bun/sql.ts | 79 ++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 2e302ec29c0cce..e2a59071535432 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -245,19 +245,30 @@ function onQueryFinish(onClose) { this.queries.delete(onClose); this.pool.release(this); } + +enum PooledConnectionState { + pending = 0, + connected = 1, + closed = 2, +} +enum PooledConnectionFlags { + /// canBeConnected is used to indicate that at least one time we were able to connect to the database + canBeConnected = 1 << 0, + /// reserved is used to indicate that the connection is currently reserved + reserved = 1 << 1, + /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 + preReserved = 1 << 2, +} class PooledConnection { pool: ConnectionPool; connection: ReturnType; - state: "pending" | "connected" | "closed" = "pending"; + state: PooledConnectionState = PooledConnectionState.pending; storedError: Error | null = null; queries: Set<(err: Error) => void> = new Set(); onFinish: ((err: Error | null) => void) | null = null; - canBeConnected: boolean = false; connectionInfo: any; - /// reserved is used to indicate that the connection is currently reserved - reserved: boolean = false; - /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 - preReserved: boolean = false; + + flags: number = 0; /// 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, _) { @@ -266,13 +277,17 @@ class PooledConnection { connectionInfo.onconnect(err); } this.storedError = err; - this.canBeConnected = !err; - this.state = err ? "closed" : "connected"; + if (!err) { + this.flags |= PooledConnectionFlags.canBeConnected; + } + this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; const onFinish = this.onFinish; if (onFinish) { this.queryCount = 0; - this.reserved = false; + this.flags &= ~PooledConnectionFlags.reserved; + this.flags &= ~PooledConnectionFlags.preReserved; + // pool is closed, lets finish the connection // pool is closed, lets finish the connection if (err) { onFinish(err); @@ -288,7 +303,7 @@ class PooledConnection { if (connectionInfo?.onclose) { connectionInfo.onclose(err); } - this.state = "closed"; + this.state = PooledConnectionState.closed; this.connection = null; this.storedError = err; @@ -297,7 +312,8 @@ class PooledConnection { const queries = new Set(this.queries); this.queries.clear(); this.queryCount = 0; - this.reserved = false; + this.flags &= ~PooledConnectionFlags.reserved; + // notify all queries that the connection is closed for (const onClose of queries) { onClose(err); @@ -311,7 +327,7 @@ class PooledConnection { } constructor(connectionInfo, pool: ConnectionPool) { this.connection = createConnection(connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); - this.state = "pending"; + this.state = PooledConnectionState.pending; this.pool = pool; this.connectionInfo = connectionInfo; } @@ -329,7 +345,7 @@ class PooledConnection { } // reset error and state this.storedError = null; - this.state = "pending"; + this.state = PooledConnectionState.pending; // retry connection this.connection = createConnection( this.connectionInfo, @@ -338,7 +354,7 @@ class PooledConnection { ); } close() { - if (this.state === "connected") { + if (this.state === PooledConnectionState.connected) { this.connection?.close(); } } @@ -351,7 +367,7 @@ class PooledConnection { // lets use a retry strategy // we can only retry if one day we are able to connect - if (this.canBeConnected) { + if (this.flags & PooledConnectionFlags.canBeConnected) { this.#doRetry(); } else { // analyse type of error to see if we can retry @@ -434,10 +450,10 @@ class ConnectionPool { if (!connectingEvent) { connection.queryCount--; } - const was_reserved = connection.reserved; - connection.reserved = false; - connection.preReserved = false; - if (connection.state !== "connected") { + const was_reserved = connection.flags & PooledConnectionFlags.reserved; + connection.flags &= ~PooledConnectionFlags.reserved; + connection.flags &= ~PooledConnectionFlags.preReserved; + if (connection.state !== PooledConnectionState.connected) { // connection is not ready return; } @@ -459,7 +475,7 @@ class ConnectionPool { } const pendingReserved = this.reservedQueue.shift(); if (pendingReserved) { - connection.reserved = true; + connection.flags |= PooledConnectionFlags.reserved; connection.queryCount++; // we have a connection waiting for a reserved connection lets prioritize it pendingReserved(connection.storedError, connection); @@ -475,7 +491,7 @@ class ConnectionPool { // ok we can actually bind reserved queries to it const pendingReserved = this.reservedQueue.shift(); if (pendingReserved) { - connection.reserved = true; + connection.flags |= PooledConnectionFlags.reserved; connection.queryCount++; // we have a connection waiting for a reserved connection lets prioritize it pendingReserved(connection.storedError, connection); @@ -495,7 +511,7 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; - if (connection.state !== "closed") { + if (connection.state !== PooledConnectionState.closed) { // some connection is connecting or connected return true; } @@ -512,7 +528,7 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; - if (connection.state === "connected") { + if (connection.state === PooledConnectionState.connected) { return true; } } @@ -527,7 +543,7 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; - if (connection.state === "connected") { + if (connection.state === PooledConnectionState.connected) { connection.connection.flush(); } } @@ -560,14 +576,14 @@ class ConnectionPool { for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; switch (connection.state) { - case "pending": + case PooledConnectionState.pending: { const { promise, resolve } = Promise.withResolvers(); connection.onFinish = resolve; promises.push(promise); } break; - case "connected": + case PooledConnectionState.connected: { const { promise, resolve } = Promise.withResolvers(); connection.onFinish = resolve; @@ -608,7 +624,7 @@ class ConnectionPool { for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; // we need a new connection and we have some connections that can retry - if (connection.state === "closed") { + if (connection.state === PooledConnectionState.closed) { if (connection.retry()) { // lets wait for connection to be released if (!retry_in_progress) { @@ -655,7 +671,7 @@ class ConnectionPool { const pollSize = this.connections.length; // pool is always at least 1 connection this.connections[0] = new PooledConnection(this.connectionInfo, this); - this.connections[0].preReserved = reserved; // lets pre reserve the first connection + this.connections[0].flags |= PooledConnectionFlags.preReserved; // lets pre reserve the first connection for (let i = 1; i < pollSize; i++) { this.connections[i] = new PooledConnection(this.connectionInfo, this); } @@ -665,7 +681,8 @@ class ConnectionPool { let connectionWithLeastQueries: PooledConnection | null = null; let leastQueries = Infinity; for (const connection of this.readyConnections) { - if (connection.reserved || connection.preReserved) continue; + if (connection.flags & PooledConnectionFlags.reserved || connection.flags & PooledConnectionFlags.preReserved) + continue; const queryCount = connection.queryCount; if (queryCount > 0) { if (queryCount < leastQueries) { @@ -674,7 +691,7 @@ class ConnectionPool { continue; } } - connection.reserved = true; + connection.flags |= PooledConnectionFlags.reserved; connection.queryCount++; this.readyConnections.delete(connection); onConnected(null, connection); @@ -682,7 +699,7 @@ class ConnectionPool { } if (connectionWithLeastQueries) { // lets mark the connection with the least queries as preReserved if any - connectionWithLeastQueries.preReserved = true; + connectionWithLeastQueries.flags |= PooledConnectionFlags.preReserved; } // no connection available to be reserved lets wait for a connection to be released this.reservedQueue.push(onConnected); From 7387e12dc59d978b68eb95e9bd7bb3516c062143 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 11:15:01 -0800 Subject: [PATCH 26/35] more --- src/js/bun/sql.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index e2a59071535432..926d0f52db7b82 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -411,8 +411,11 @@ class ConnectionPool { } while (this.waitingQueue.length > 0) { let endReached = true; - - const nonReservedConnections = Array.from(this.readyConnections).filter(c => !c.preReserved); + // no need to filter for reserved connections because there are not in the readyConnections + // preReserved only shows that we wanna avoiding adding more queries to it + const nonReservedConnections = Array.from(this.readyConnections).filter( + c => !(c.flags & PooledConnectionFlags.preReserved), + ); if (nonReservedConnections.length === 0) { return; } From a068077f1fb8dc40df48975eb93fc11f59ef44f4 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 13:21:16 -0800 Subject: [PATCH 27/35] wip --- src/js/bun/sql.ts | 320 ++++++++++++++++++++++++++++++---------------- 1 file changed, 211 insertions(+), 109 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 926d0f52db7b82..974f36f3208cba 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -399,6 +399,7 @@ class ConnectionPool { poolStarted: boolean = false; closed: boolean = false; + onAllQueriesFinished: (() => void) | null = null; constructor(connectionInfo) { this.connectionInfo = connectionInfo; this.connections = new Array(connectionInfo.max); @@ -456,6 +457,12 @@ class ConnectionPool { const was_reserved = connection.flags & PooledConnectionFlags.reserved; connection.flags &= ~PooledConnectionFlags.reserved; connection.flags &= ~PooledConnectionFlags.preReserved; + if (this.onAllQueriesFinished) { + // we are waiting for all queries to finish, lets check if we can call it + if (!this.hasPendingQueries()) { + this.onAllQueriesFinished(); + } + } if (connection.state !== PooledConnectionState.connected) { // connection is not ready return; @@ -522,7 +529,19 @@ class ConnectionPool { } return false; } - + hasPendingQueries() { + if (this.waitingQueue.length > 0 || this.reservedQueue.length > 0) return true; + if (this.poolStarted) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + if (connection.queryCount > 0) { + return true; + } + } + } + return false; + } isConnected() { if (this.readyConnections.size > 0) { return true; @@ -552,22 +571,8 @@ class ConnectionPool { } } } - async close(options?: { timeout?: number }) { - if (this.closed) { - return Promise.reject(connectionClosedError()); - } - let timeout = options?.timeout; - if (timeout) { - timeout = Number(timeout); - if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { - throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); - } - this.closed = true; - // TODO: check queryCount to this logic - // await Bun.sleep(timeout * 1000); - } else { - this.closed = true; - } + + async #close() { let pending; while ((pending = this.waitingQueue.shift())) { pending(connectionClosedError(), null); @@ -604,6 +609,38 @@ class ConnectionPool { this.waitingQueue.length = 0; return Promise.all(promises); } + async close(options?: { timeout?: number }) { + if (this.closed) { + return Promise.reject(connectionClosedError()); + } + let timeout = options?.timeout; + if (timeout) { + timeout = Number(timeout); + if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { + throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); + } + this.closed = true; + if (timeout > 0 && this.hasPendingQueries()) { + const { promise, resolve } = Promise.withResolvers(); + const timer = setTimeout(() => { + // timeout is reached, lets close and probably fail some queries + this.#close().finally(resolve); + }, timeout * 1000); + timer.unref(); // dont block the event loop + this.onAllQueriesFinished = () => { + clearTimeout(timer); + // everything is closed, lets close the pool + this.#close().finally(resolve); + }; + + return promise; + } + } else { + this.closed = true; + } + + await this.#close(); + } /** * @param {function} onConnected - The callback function to be called when the connection is established. @@ -1033,6 +1070,16 @@ function loadOptions(o) { return ret; } +enum ReservedConnectionState { + acceptQueries = 1 << 0, + closed = 1 << 1, +} + +function assertValidTransactionName(name: string) { + if (name.indexOf("'") !== -1) { + throw Error(`Distributed transaction name cannot contain single quotes.`); + } +} function SQL(o) { var connectionInfo = loadOptions(o); var pool = new ConnectionPool(connectionInfo); @@ -1134,15 +1181,7 @@ function SQL(o) { } function onTransactionDisconnected(err) { const reject = this.reject; - this.closed = true; - if (err) { - return reject(err); - } - } - - function onReserveDisconnected(err) { - const reject = this.reject; - this.closed = true; + this.connectionState |= ReservedConnectionState.closed; if (err) { return reject(err); } @@ -1155,15 +1194,20 @@ function SQL(o) { } const state = { - closed: false, + connectionState: ReservedConnectionState.acceptQueries, reject, }; - const onClose = onReserveDisconnected.bind(state); + const onClose = onTransactionDisconnected.bind(state); pooledConnection.onClose(onClose); let reserveQueries = new Set(); + let reservedTransaction = new Set(); + function reserved_sql(strings, ...values) { - if (state.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { return Promise.reject(connectionClosedError()); } if ($isJSArray(strings) && strings[0] && typeof strings[0] === "object") { @@ -1173,20 +1217,15 @@ function SQL(o) { return queryFromTransaction(strings, values, pooledConnection, reserveQueries); } reserved_sql.connect = () => { - if (state.closed) { + if (state.connectionState & ReservedConnectionState.closed) { return Promise.reject(connectionClosedError()); } return Promise.resolve(reserved_sql); }; reserved_sql.commitDistributed = async function (name: string) { - if (state.closed) { - throw connectionClosedError(); - } const adapter = connectionInfo.adapter; - if (name.indexOf("'") !== -1) { - throw Error(`Distributed transaction name cannot contain single quotes.`); - } + assertValidTransactionName(name); switch (adapter) { case "postgres": return await reserved_sql(`COMMIT PREPARED '${name}'`); @@ -1201,9 +1240,7 @@ function SQL(o) { } }; reserved_sql.rollbackDistributed = async function (name: string) { - if (state.closed) { - throw connectionClosedError(); - } + assertValidTransactionName(name); const adapter = connectionInfo.adapter; switch (adapter) { case "postgres": @@ -1222,10 +1259,12 @@ function SQL(o) { // reserve is allowed to be called inside reserved connection but will return a new reserved connection from the pool // this matchs the behavior of the postgres package reserved_sql.reserve = () => sql.reserve(); - + function onTransactionFinished(transaction_promise: Promise) { + reservedTransaction.delete(transaction_promise); + } 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.closed) { + if (state.connectionState & ReservedConnectionState.closed) { return Promise.reject(connectionClosedError()); } let callback = fn; @@ -1240,11 +1279,16 @@ function SQL(o) { const { promise, resolve, reject } = Promise.withResolvers(); // lets just reuse the same code path as the transaction begin onTransactionConnected(callback, name, resolve, reject, true, true, null, pooledConnection); + reservedTransaction.add(promise); + promise.finally(onTransactionFinished.bind(null, promise)); return promise; }; reserved_sql.begin = (options_or_fn: string | TransactionCallback, 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.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { return Promise.reject(connectionClosedError()); } let callback = fn; @@ -1261,33 +1305,58 @@ function SQL(o) { const { promise, resolve, reject } = Promise.withResolvers(); // lets just reuse the same code path as the transaction begin onTransactionConnected(callback, options, resolve, reject, true, false, null, pooledConnection); + reservedTransaction.add(promise); + promise.finally(onTransactionFinished.bind(null, promise)); return promise; }; reserved_sql.flush = () => { - if (state.closed) { + if (state.connectionState & ReservedConnectionState.closed) { throw connectionClosedError(); } return pooledConnection.flush(); }; reserved_sql.close = async (options?: { timeout?: number }) => { - if (state.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { return Promise.reject(connectionClosedError()); } + state.connectionState &= ~ReservedConnectionState.acceptQueries; let timeout = options?.timeout; if (timeout) { timeout = Number(timeout); if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } - state.closed = true; - // no new queries will be allowed - // TODO: check queryCount to this logic - // await Bun.sleep(timeout * 1000); - } else { - // close will release the connection back to the pool but will actually close the connection if its open - state.closed = true; + if (timeout > 0 && reserveQueries.size > 0) { + const { promise, resolve } = Promise.withResolvers(); + // race all queries vs timeout + const pending_queries = Array.from(reserveQueries); + const pending_transactions = Array.from(reservedTransaction); + const timer = setTimeout(() => { + state.connectionState |= ReservedConnectionState.closed; + for (const query of reserveQueries) { + (query as Query).cancel(); + } + state.connectionState |= ReservedConnectionState.closed; + pooledConnection.queries.delete(onClose); + pooledConnection.close(); + pool.release(pooledConnection); + + resolve(); + }, timeout * 1000); + timer.unref(); // dont block the event loop + Promise.all([Promise.all(pending_queries), Promise.all(pending_transactions)]).finally(() => { + clearTimeout(timer); + resolve(); + }); + return promise; + } } + state.connectionState |= ReservedConnectionState.closed; + pooledConnection.queries.delete(onClose); pooledConnection.close(); @@ -1297,7 +1366,7 @@ function SQL(o) { }; reserved_sql.release = () => { // just release the connection back to the pool - state.closed = true; + state.connectionState |= ReservedConnectionState.closed; pooledConnection.queries.delete(onClose); pool.release(pooledConnection); return Promise.resolve(undefined); @@ -1348,12 +1417,13 @@ function SQL(o) { return reject(err); } const state = { - closed: false, + connectionState: ReservedConnectionState.acceptQueries, reject, }; let savepoints = 0; let transactionQueries = new Set(); + let transactionSavepoints = new Set(); const adapter = connectionInfo.adapter; let BEGIN_COMMAND: string = "BEGIN"; let ROLLBACK_COMMAND: string = "COMMIT"; @@ -1434,8 +1504,18 @@ function SQL(o) { } const onClose = onTransactionDisconnected.bind(state); pooledConnection.onClose(onClose); + + function run_internal_transaction_sql(strings, ...values) { + if (state.connectionState & ReservedConnectionState.closed) { + return Promise.reject(connectionClosedError()); + } + return queryFromTransaction(strings, values, pooledConnection, transactionQueries); + } function transaction_sql(strings, ...values) { - if (state.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { return Promise.reject(connectionClosedError()); } if ($isJSArray(strings) && strings[0] && typeof strings[0] === "object") { @@ -1449,19 +1529,14 @@ function SQL(o) { transaction_sql.reserve = () => sql.reserve(); transaction_sql.connect = () => { - if (state.closed) { + if (state.connectionState & ReservedConnectionState.closed) { return Promise.reject(connectionClosedError()); } return Promise.resolve(transaction_sql); }; transaction_sql.commitDistributed = async function (name: string) { - if (state.closed) { - throw connectionClosedError(); - } - if (name.indexOf("'") !== -1) { - throw Error(`Distributed transaction name cannot contain single quotes.`); - } + assertValidTransactionName(name); switch (adapter) { case "postgres": return await transaction_sql(`COMMIT PREPARED '${name}'`); @@ -1476,12 +1551,7 @@ function SQL(o) { } }; transaction_sql.rollbackDistributed = async function (name: string) { - if (state.closed) { - throw connectionClosedError(); - } - if (name.indexOf("'") !== -1) { - throw Error(`Distributed transaction name cannot contain single quotes.`); - } + assertValidTransactionName(name); switch (adapter) { case "postgres": return await transaction_sql(`ROLLBACK PREPARED '${name}'`); @@ -1513,32 +1583,56 @@ function SQL(o) { }; transaction_sql.flush = function () { - if (state.closed) { + if (state.connectionState & ReservedConnectionState.closed) { throw connectionClosedError(); } return pooledConnection.flush(); }; transaction_sql.close = async function (options?: { timeout?: number }) { // we dont actually close the connection here, we just set the state to closed and rollback the transaction - if (state.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { return Promise.reject(connectionClosedError()); } + state.connectionState &= ~ReservedConnectionState.acceptQueries; + let timeout = options?.timeout; if (timeout) { timeout = Number(timeout); if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } - // TODO: check queryCount to this logic - // await Bun.sleep(timeout * 1000); - } - if (!state.closed) { - if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + if (timeout > 0 && (transactionQueries.size > 0 || transactionSavepoints.size > 0)) { + const { promise, resolve } = Promise.withResolvers(); + // race all queries vs timeout + const pending_queries = Array.from(transactionQueries); + const pending_savepoints = Array.from(transactionSavepoints); + const timer = setTimeout(async () => { + for (const query of transactionQueries) { + (query as Query).cancel(); + } + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } + await run_internal_transaction_sql(ROLLBACK_COMMAND); + state.connectionState |= ReservedConnectionState.closed; + resolve(); + }, timeout * 1000); + timer.unref(); // dont block the event loop + Promise.all([Promise.all(pending_queries), Promise.all(pending_savepoints)]).finally(() => { + clearTimeout(timer); + resolve(); + }); + return promise; } - await transaction_sql(ROLLBACK_COMMAND); - state.closed = true; } + if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + } + await run_internal_transaction_sql(ROLLBACK_COMMAND); + state.connectionState |= ReservedConnectionState.closed; }; transaction_sql[Symbol.asyncDispose] = () => transaction_sql.close(); transaction_sql.options = sql.options; @@ -1546,7 +1640,29 @@ function SQL(o) { transaction_sql.transaction = transaction_sql.begin; transaction_sql.distributed = transaction_sql.beginDistributed; transaction_sql.end = transaction_sql.close; + function onSavepointFinished(savepoint_promise: Promise) { + transactionSavepoints.delete(savepoint_promise); + } + async function run_internal_savepoint(save_point_name: string, savepoint_callback: TransactionCallback) { + await run_internal_transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); + try { + let result = await savepoint_callback(transaction_sql); + if (RELEASE_SAVEPOINT_COMMAND) { + // mssql dont have release savepoint + await run_internal_transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); + } + if (Array.isArray(result)) { + result = await Promise.all(result); + } + return result; + } catch (err) { + if (!(state.connectionState & ReservedConnectionState.closed)) { + await run_internal_transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); + } + throw err; + } + } if (distributed) { transaction_sql.savepoint = async (fn: TransactionCallback, name?: string): Promise => { throw $ERR_POSTGRES_INVALID_TRANSACTION_STATE("cannot call savepoint inside a distributed transaction"); @@ -1555,9 +1671,13 @@ function SQL(o) { transaction_sql.savepoint = async (fn: TransactionCallback, name?: string): Promise => { let savepoint_callback = fn; - if (state.closed) { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { throw connectionClosedError(); } + if ($isCallable(name)) { savepoint_callback = name as unknown as TransactionCallback; name = ""; @@ -1567,29 +1687,15 @@ function SQL(o) { } // matchs the format of the savepoint name in postgres package const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`; - await transaction_sql(`${SAVEPOINT_COMMAND} ${save_point_name}`); - - try { - let result = await savepoint_callback(transaction_sql); - if (RELEASE_SAVEPOINT_COMMAND) { - // mssql dont have release savepoint - await transaction_sql(`${RELEASE_SAVEPOINT_COMMAND} ${save_point_name}`); - } - if (Array.isArray(result)) { - result = await Promise.all(result); - } - return result; - } catch (err) { - if (!state.closed) { - await transaction_sql(`${ROLLBACK_TO_SAVEPOINT_COMMAND} ${save_point_name}`); - } - throw err; - } + const promise = run_internal_savepoint(save_point_name, savepoint_callback); + transactionSavepoints.add(promise); + promise.finally(onSavepointFinished.bind(null, promise)); + return await promise; }; } let needs_rollback = false; try { - await transaction_sql(BEGIN_COMMAND); + await run_internal_transaction_sql(BEGIN_COMMAND); needs_rollback = true; let transaction_result = await callback(transaction_sql); if (Array.isArray(transaction_result)) { @@ -1598,24 +1704,24 @@ function SQL(o) { // at this point we dont need to rollback anymore needs_rollback = false; if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); } - await transaction_sql(COMMIT_COMMAND); + await run_internal_transaction_sql(COMMIT_COMMAND); return resolve(transaction_result); } catch (err) { try { - if (!state.closed && needs_rollback) { + if (!(state.connectionState & ReservedConnectionState.closed) && needs_rollback) { if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { - await transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); + await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); } - await transaction_sql(ROLLBACK_COMMAND); + await run_internal_transaction_sql(ROLLBACK_COMMAND); } } catch (err) { return reject(err); } return reject(err); } finally { - state.closed = true; + state.connectionState |= ReservedConnectionState.closed; pooledConnection.queries.delete(onClose); if (!dontRelease) { pool.release(pooledConnection); @@ -1656,9 +1762,7 @@ function SQL(o) { if (pool.closed) { throw connectionClosedError(); } - if (name.indexOf("'") !== -1) { - throw Error(`Distributed transaction name cannot contain single quotes.`); - } + assertValidTransactionName(name); const adapter = connectionInfo.adapter; switch (adapter) { case "postgres": @@ -1678,9 +1782,7 @@ function SQL(o) { if (pool.closed) { throw connectionClosedError(); } - if (name.indexOf("'") !== -1) { - throw Error(`Distributed transaction name cannot contain single quotes.`); - } + assertValidTransactionName(name); const adapter = connectionInfo.adapter; switch (adapter) { case "postgres": From ddabf3825818ef73c35ef8f75eaafd03cecd155a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 13:25:08 -0800 Subject: [PATCH 28/35] more --- src/js/bun/sql.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 974f36f3208cba..10872f88c3e2f2 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -577,6 +577,12 @@ class ConnectionPool { while ((pending = this.waitingQueue.shift())) { pending(connectionClosedError(), null); } + while (this.reservedQueue.length > 0) { + const pendingReserved = this.reservedQueue.shift(); + if (pendingReserved) { + pendingReserved(connectionClosedError(), null); + } + } const promises: Array> = []; if (this.poolStarted) { this.poolStarted = false; From be9b5fe7050fbce405d092d95a128a69dd3a2803 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 14:37:25 -0800 Subject: [PATCH 29/35] fix transaction close and reserved close --- src/js/bun/sql.ts | 63 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 10872f88c3e2f2..df53cb9dc40801 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -354,9 +354,14 @@ class PooledConnection { ); } close() { - if (this.state === PooledConnectionState.connected) { - this.connection?.close(); - } + try { + if (this.state === PooledConnectionState.connected) { + this.connection?.close(); + } + } catch {} + } + flush() { + this.connection?.flush(); } retry() { // if pool is closed, we can't retry @@ -1164,23 +1169,26 @@ function SQL(o) { function queryFromTransactionHandler(transactionQueries, query, handle, err) { const pooledConnection = this; if (err) { + transactionQueries.delete(query); return query.reject(err); } // query is cancelled if (query.cancelled) { + transactionQueries.delete(query); return query.reject($ERR_POSTGRES_QUERY_CANCELLED("Query cancelled")); } - // keep the query alive until we finish the transaction or the query - transactionQueries.add(query); + query.finally(onTransactionQueryDisconnected.bind(transactionQueries, query)); handle.run(pooledConnection.connection, query); } function queryFromTransaction(strings, values, pooledConnection, transactionQueries) { try { - return new Query( + const query = new Query( doCreateQuery(strings, values, true), queryFromTransactionHandler.bind(pooledConnection, transactionQueries), ); + transactionQueries.add(query); + return query; } catch (err) { return Promise.reject(err); } @@ -1188,6 +1196,10 @@ function SQL(o) { function onTransactionDisconnected(err) { const reject = this.reject; this.connectionState |= ReservedConnectionState.closed; + + for (const query of this.queries) { + (query as Query).reject(err); + } if (err) { return reject(err); } @@ -1199,16 +1211,17 @@ function SQL(o) { return reject(err); } + let reservedTransaction = new Set(); + const state = { connectionState: ReservedConnectionState.acceptQueries, reject, + storedError: null, + queries: new Set(), }; const onClose = onTransactionDisconnected.bind(state); pooledConnection.onClose(onClose); - let reserveQueries = new Set(); - let reservedTransaction = new Set(); - function reserved_sql(strings, ...values) { if ( state.connectionState & ReservedConnectionState.closed || @@ -1220,7 +1233,7 @@ function SQL(o) { return new SQLArrayParameter(strings, values); } // we use the same code path as the transaction sql - return queryFromTransaction(strings, values, pooledConnection, reserveQueries); + return queryFromTransaction(strings, values, pooledConnection, state.queries); } reserved_sql.connect = () => { if (state.connectionState & ReservedConnectionState.closed) { @@ -1323,6 +1336,7 @@ function SQL(o) { return pooledConnection.flush(); }; reserved_sql.close = async (options?: { timeout?: number }) => { + const reserveQueries = state.queries; if ( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) @@ -1336,7 +1350,7 @@ function SQL(o) { if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } - if (timeout > 0 && reserveQueries.size > 0) { + if (timeout > 0 && (reserveQueries.size > 0 || reservedTransaction.size > 0)) { const { promise, resolve } = Promise.withResolvers(); // race all queries vs timeout const pending_queries = Array.from(reserveQueries); @@ -1347,9 +1361,7 @@ function SQL(o) { (query as Query).cancel(); } state.connectionState |= ReservedConnectionState.closed; - pooledConnection.queries.delete(onClose); pooledConnection.close(); - pool.release(pooledConnection); resolve(); }, timeout * 1000); @@ -1362,17 +1374,24 @@ function SQL(o) { } } state.connectionState |= ReservedConnectionState.closed; - - pooledConnection.queries.delete(onClose); + for (const query of reserveQueries) { + (query as Query).cancel(); + } pooledConnection.close(); - pool.release(pooledConnection); return Promise.resolve(undefined); }; reserved_sql.release = () => { + if ( + state.connectionState & ReservedConnectionState.closed || + !(state.connectionState & ReservedConnectionState.acceptQueries) + ) { + return Promise.reject(connectionClosedError()); + } // just release the connection back to the pool state.connectionState |= ReservedConnectionState.closed; + state.connectionState &= ~ReservedConnectionState.acceptQueries; pooledConnection.queries.delete(onClose); pool.release(pooledConnection); return Promise.resolve(undefined); @@ -1425,10 +1444,10 @@ function SQL(o) { const state = { connectionState: ReservedConnectionState.acceptQueries, reject, + queries: new Set(), }; let savepoints = 0; - let transactionQueries = new Set(); let transactionSavepoints = new Set(); const adapter = connectionInfo.adapter; let BEGIN_COMMAND: string = "BEGIN"; @@ -1515,7 +1534,7 @@ function SQL(o) { if (state.connectionState & ReservedConnectionState.closed) { return Promise.reject(connectionClosedError()); } - return queryFromTransaction(strings, values, pooledConnection, transactionQueries); + return queryFromTransaction(strings, values, pooledConnection, state.queries); } function transaction_sql(strings, ...values) { if ( @@ -1528,7 +1547,7 @@ function SQL(o) { return new SQLArrayParameter(strings, values); } - return queryFromTransaction(strings, values, pooledConnection, transactionQueries); + return queryFromTransaction(strings, values, pooledConnection, state.queries); } // reserve is allowed to be called inside transaction connection but will return a new reserved connection from the pool and will not be part of the transaction // this matchs the behavior of the postgres package @@ -1603,13 +1622,14 @@ function SQL(o) { return Promise.reject(connectionClosedError()); } state.connectionState &= ~ReservedConnectionState.acceptQueries; - + const transactionQueries = state.queries; let timeout = options?.timeout; if (timeout) { timeout = Number(timeout); if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); } + if (timeout > 0 && (transactionQueries.size > 0 || transactionSavepoints.size > 0)) { const { promise, resolve } = Promise.withResolvers(); // race all queries vs timeout @@ -1634,6 +1654,9 @@ function SQL(o) { return promise; } } + for (const query of transactionQueries) { + (query as Query).cancel(); + } if (BEFORE_COMMIT_OR_ROLLBACK_COMMAND) { await run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND); } From 9ccafa2bc49605895f292ee97a1e0a7d90e7d59c Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:01:06 -0800 Subject: [PATCH 30/35] remove bun:sql wip types --- packages/bun-types/bun.d.ts | 56 ++++++++++++++++++++++++++++++++++++ src/bun.js/module_loader.zig | 10 ------- test/js/sql/sql.test.ts | 3 +- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 06df76e2ef91ac..2b860ee3101d15 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1995,6 +1995,62 @@ declare module "bun" { */ stat(path: string, options?: S3Options): Promise; }; + type SQLOptions = { + host: string; + port: number; + user: string; + password: string; + database: string; + url: URL | string; + adapter: string; + idleTimeout: number; + connectionTimeout: number; + maxLifetime: number; + max_lifetime: number; + connection_timeout: number; + idle_timeout: number; + tls: boolean; + onconnect: (client: SQLClient) => void; + onclose: (client: SQLClient) => void; + max: number; + }; + interface SQLQuery extends Promise { + active: boolean; + cancelled: boolean; + cancel(): SQLQuery; + execute(): SQLQuery; + raw(): SQLQuery; + values(): SQLQuery; + } + + type SQLContextCallback = (sql: (strings: string, ...values: any[]) => SQLQuery | Array) => Promise; + + type SQLClient = { + new (options?: SQLOptions | string): SQLClient; + (strings: string, ...values: any[]): SQLQuery; + commitDistributed(name: string): Promise; + rollbackDistributed(name: string): Promise; + connect(): Promise; + close(options?: { timeout?: number }): Promise; + end(options?: { timeout?: number }): Promise; + flush(): void; + reserve(): Promise; + begin(fn: SQLContextCallback): Promise; + begin(options: string, fn: SQLContextCallback): Promise; + transaction(fn: SQLContextCallback): Promise; + transaction(options: string, fn: SQLContextCallback): Promise; + beginDistributed(name: string, fn: SQLContextCallback): Promise; + distributed(name: string, fn: SQLContextCallback): Promise; + options: SQLOptions; + }; + interface ReservedSQLClient extends SQLClient { + release(): void; + } + interface TransactionSQLClient extends SQLClient { + savepoint(name: string, fn: SQLContextCallback): Promise; + } + + const sql: SQLClient; /** * This lets you use macros as regular imports diff --git a/src/bun.js/module_loader.zig b/src/bun.js/module_loader.zig index a015716e913a7f..3cce731ee00312 100644 --- a/src/bun.js/module_loader.zig +++ b/src/bun.js/module_loader.zig @@ -2514,14 +2514,7 @@ pub const ModuleLoader = struct { // These are defined in src/js/* .@"bun:ffi" => return jsSyntheticModule(.@"bun:ffi", specifier), - .@"bun:sql" => { - if (!Environment.isDebug) { - if (!is_allowed_to_use_internal_testing_apis and !bun.FeatureFlags.postgresql) - return null; - } - return jsSyntheticModule(.@"bun:sql", specifier); - }, .@"bun:sqlite" => return jsSyntheticModule(.@"bun:sqlite", specifier), .@"detect-libc" => return jsSyntheticModule(if (!Environment.isLinux) .@"detect-libc" else if (!Environment.isMusl) .@"detect-libc/linux" else .@"detect-libc/musl", specifier), .@"node:assert" => return jsSyntheticModule(.@"node:assert", specifier), @@ -2729,7 +2722,6 @@ pub const HardcodedModule = enum { @"bun:jsc", @"bun:main", @"bun:test", // usually replaced by the transpiler but `await import("bun:" + "test")` has to work - @"bun:sql", @"bun:sqlite", @"detect-libc", @"node:assert", @@ -2816,7 +2808,6 @@ pub const HardcodedModule = enum { .{ "bun:test", HardcodedModule.@"bun:test" }, .{ "bun:sqlite", HardcodedModule.@"bun:sqlite" }, .{ "bun:internal-for-testing", HardcodedModule.@"bun:internal-for-testing" }, - .{ "bun:sql", HardcodedModule.@"bun:sql" }, .{ "detect-libc", HardcodedModule.@"detect-libc" }, .{ "node-fetch", HardcodedModule.@"node-fetch" }, .{ "isomorphic-fetch", HardcodedModule.@"isomorphic-fetch" }, @@ -3056,7 +3047,6 @@ pub const HardcodedModule = enum { .{ "bun:ffi", .{ .path = "bun:ffi" } }, .{ "bun:jsc", .{ .path = "bun:jsc" } }, .{ "bun:sqlite", .{ .path = "bun:sqlite" } }, - .{ "bun:sql", .{ .path = "bun:sql" } }, .{ "bun:wrap", .{ .path = "bun:wrap" } }, .{ "bun:internal-for-testing", .{ .path = "bun:internal-for-testing" } }, .{ "ffi", .{ .path = "bun:ffi" } }, diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index ba7aad37ade6f3..4d4fd38c613264 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -1,4 +1,5 @@ -import { postgres, sql } from "bun:sql"; +import { sql } from "bun"; +const postgres = (...args) => new sql(...args); import { expect, test, mock } from "bun:test"; import { $ } from "bun"; import { bunExe, isCI, withoutAggressiveGC } from "harness"; From aadf6e2d0b739d9122ba2852b465be166b0ed544 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:14:43 -0800 Subject: [PATCH 31/35] examples --- packages/bun-types/bun.d.ts | 212 ++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2b860ee3101d15..00db4ba0b643cf 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1995,58 +1995,270 @@ declare module "bun" { */ stat(path: string, options?: S3Options): Promise; }; + /** + * Configuration options for SQL client connection and behavior + */ type SQLOptions = { + /** Database server hostname */ host: string; + /** Database server port number */ port: number; + /** Database user for authentication */ user: string; + /** Database password for authentication */ password: string; + /** Name of the database to connect to */ database: string; + /** Connection URL (can be string or URL object) */ url: URL | string; + /** Database adapter/driver to use */ adapter: string; + /** Maximum time in milliseconds to wait for connection to become available */ idleTimeout: number; + /** Maximum time in milliseconds to wait when establishing a connection */ connectionTimeout: number; + /** Maximum lifetime in milliseconds of a connection */ maxLifetime: number; + /** Alternative snake_case naming for maxLifetime */ max_lifetime: number; + /** Alternative snake_case naming for connectionTimeout */ connection_timeout: number; + /** Alternative snake_case naming for idleTimeout */ idle_timeout: number; + /** Whether to use TLS/SSL for the connection */ tls: boolean; + /** Callback function executed when a connection is established */ onconnect: (client: SQLClient) => void; + /** Callback function executed when a connection is closed */ onclose: (client: SQLClient) => void; + /** Maximum number of connections in the pool */ max: number; }; + + /** + * Represents a SQL query that can be executed, with additional control methods + * Extends Promise to allow for async/await usage + */ interface SQLQuery extends Promise { + /** Indicates if the query is currently executing */ active: boolean; + /** Indicates if the query has been cancelled */ cancelled: boolean; + /** Cancels the executing query */ cancel(): SQLQuery; + /** Executes the query */ execute(): SQLQuery; + /** Returns the raw query result */ raw(): SQLQuery; + /** Returns only the values from the query result */ values(): SQLQuery; } + /** + * Callback function type for transaction contexts + * @param sql Function to execute SQL queries within the transaction + */ type SQLContextCallback = (sql: (strings: string, ...values: any[]) => SQLQuery | Array) => Promise; + /** + * Main SQL client interface providing connection and transaction management + */ type SQLClient = { + /** Creates a new SQL client instance */ new (options?: SQLOptions | string): SQLClient; + /** Executes a SQL query using template literals */ (strings: string, ...values: any[]): SQLQuery; + /** Commits a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL + * @example + * await sql.commitDistributed("my_distributed_transaction"); + */ commitDistributed(name: string): Promise; + /** Rolls back a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL + * @example + * await sql.rollbackDistributed("my_distributed_transaction"); + */ rollbackDistributed(name: string): Promise; + /** Waits for the database connection to be established + * @example + * await sql.connect(); + */ connect(): Promise; + /** Closes the database connection with optional timeout in seconds + * @example + * await sql.close({ timeout: 1 }); + */ close(options?: { timeout?: number }): Promise; + /** Closes the database connection with optional timeout in seconds + * @alias close + * @example + * await sql.end({ timeout: 1 }); + */ end(options?: { timeout?: number }): Promise; + /** Flushes any pending operations */ flush(): void; + /** The reserve method pulls out a connection from the pool, and returns a client that wraps the single connection. + * This can be used for running queries on an isolated connection. + * Calling reserve in a reserved Sql will return a new reserved connection, not the same connection (behavior matches postgres package). + * @example + * compatible with `postgres` example + * const reserved = await sql.reserve(); + * await reserved`select * from users`; + * await reserved.release(); + * // with in a production scenario would be something more like + * const reserved = await sql.reserve(); + * try { + * // ... queries + * } finally { + * await reserved.release(); + * } + * //To make it simpler bun supportsSymbol.dispose and Symbol.asyncDispose + * { + * // always release after context (safer) + * using reserved = await sql.reserve() + * await reserved`select * from users` + * } + */ reserve(): Promise; + /** Begins a new transaction + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.begin will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @example + * const [user, account] = await sql.begin(async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ begin(fn: SQLContextCallback): Promise; + /** Begins a new transaction with options + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.begin will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @example + * const [user, account] = await sql.begin("read write", async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ begin(options: string, fn: SQLContextCallback): Promise; + /** Alternative method to begin a transaction + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.transaction will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @alias begin + * @example + * const [user, account] = await sql.transaction(async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ transaction(fn: SQLContextCallback): Promise; + /** Alternative method to begin a transaction with options + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.transaction will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @alias begin + * @example + * const [user, account] = await sql.transaction("read write", async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ transaction(options: string, fn: SQLContextCallback): Promise; + /** Begins a distributed transaction + * Also know as Two-Phase Commit, in a distributed transaction, Phase 1 involves the coordinator preparing nodes by ensuring data is written and ready to commit, while Phase 2 finalizes with nodes committing or rolling back based on the coordinator's decision, ensuring durability and releasing locks. + * In PostgreSQL and MySQL distributed transactions persist beyond the original session, allowing privileged users or coordinators to commit/rollback them, ensuring support for distributed transactions, recovery, and administrative tasks. + * beginDistributed will automatic rollback if any exception are not caught, and you can commit and rollback later if everything goes well. + * PostgreSQL natively supports distributed transactions using PREPARE TRANSACTION, while MySQL uses XA Transactions, and MSSQL also supports distributed/XA transactions. However, in MSSQL, distributed transactions are tied to the original session, the DTC coordinator, and the specific connection. These transactions are automatically committed or rolled back following the same rules as regular transactions, with no option for manual intervention from other sessions, in MSSQL distributed transactions are used to coordinate transactions using Linked Servers. + * @example + * await sql.beginDistributed("numbers", async sql => { + * await sql`create table if not exists numbers (a int)`; + * await sql`insert into numbers values(1)`; + * }); + * // later you can call + * await sql.commitDistributed("numbers"); + * // or await sql.rollbackDistributed("numbers"); + */ beginDistributed(name: string, fn: SQLContextCallback): Promise; + /** Alternative method to begin a distributed transaction + * @alias beginDistributed + */ distributed(name: string, fn: SQLContextCallback): Promise; + /** Current client options */ options: SQLOptions; }; + + /** + * Represents a reserved client from the connection pool + * Extends SQLClient with additional release functionality + */ interface ReservedSQLClient extends SQLClient { + /** Releases the client back to the connection pool */ release(): void; } + + /** + * Represents a client within a transaction context + * Extends SQLClient with savepoint functionality + */ interface TransactionSQLClient extends SQLClient { + /** Creates a savepoint within the current transaction */ savepoint(name: string, fn: SQLContextCallback): Promise; } From bee20b6a1e55ac96481125fedde8ab1d4fa35e4f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:23:25 -0800 Subject: [PATCH 32/35] constructor --- packages/bun-types/bun.d.ts | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 00db4ba0b643cf..d12f759ba63c7e 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1999,18 +1999,18 @@ declare module "bun" { * Configuration options for SQL client connection and behavior */ type SQLOptions = { + /** Connection URL (can be string or URL object) */ + url: URL | string; /** Database server hostname */ host: string; /** Database server port number */ - port: number; + port: number | string; /** Database user for authentication */ user: string; /** Database password for authentication */ password: string; /** Name of the database to connect to */ database: string; - /** Connection URL (can be string or URL object) */ - url: URL | string; /** Database adapter/driver to use */ adapter: string; /** Maximum time in milliseconds to wait for connection to become available */ @@ -2019,12 +2019,6 @@ declare module "bun" { connectionTimeout: number; /** Maximum lifetime in milliseconds of a connection */ maxLifetime: number; - /** Alternative snake_case naming for maxLifetime */ - max_lifetime: number; - /** Alternative snake_case naming for connectionTimeout */ - connection_timeout: number; - /** Alternative snake_case naming for idleTimeout */ - idle_timeout: number; /** Whether to use TLS/SSL for the connection */ tls: boolean; /** Callback function executed when a connection is established */ @@ -2063,9 +2057,23 @@ declare module "bun" { /** * Main SQL client interface providing connection and transaction management */ - type SQLClient = { - /** Creates a new SQL client instance */ - new (options?: SQLOptions | string): SQLClient; + interface SQLClient { + /** Creates a new SQL client instance + * @example + * const sql = new SQL("postgres://localhost:5432/mydb"); + * const sql = new SQL(new URL("postgres://localhost:5432/mydb")); + */ + new (connectionString: string | URL): SQLClient; + /** Creates a new SQL client instance with options + * @example + * const sql = new SQL("postgres://localhost:5432/mydb", { idleTimeout: 1000 }); + */ + new (connectionString: string | URL, options: SQLOptions): SQLClient; + /** Creates a new SQL client instance with options + * @example + * const sql = new SQL({ url: "postgres://localhost:5432/mydb", idleTimeout: 1000 }); + */ + new (options?: SQLOptions): SQLClient; /** Executes a SQL query using template literals */ (strings: string, ...values: any[]): SQLQuery; /** Commits a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL @@ -2100,7 +2108,6 @@ declare module "bun" { * This can be used for running queries on an isolated connection. * Calling reserve in a reserved Sql will return a new reserved connection, not the same connection (behavior matches postgres package). * @example - * compatible with `postgres` example * const reserved = await sql.reserve(); * await reserved`select * from users`; * await reserved.release(); @@ -2242,10 +2249,10 @@ declare module "bun" { distributed(name: string, fn: SQLContextCallback): Promise; /** Current client options */ options: SQLOptions; - }; + } /** - * Represents a reserved client from the connection pool + * Represents a reserved connection from the connection pool * Extends SQLClient with additional release functionality */ interface ReservedSQLClient extends SQLClient { From 8d7884f7bced4f3f7f6b068f5c4c0feb6e1673d8 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:24:14 -0800 Subject: [PATCH 33/35] more --- packages/bun-types/bun.d.ts | 5 ++++- src/js/bun/sql.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d12f759ba63c7e..6dae692be8bd5d 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2074,7 +2074,10 @@ declare module "bun" { * const sql = new SQL({ url: "postgres://localhost:5432/mydb", idleTimeout: 1000 }); */ new (options?: SQLOptions): SQLClient; - /** Executes a SQL query using template literals */ + /** Executes a SQL query using template literals + * @example + * const [user] = await sql`select * from users where id = ${1}`; + */ (strings: string, ...values: any[]): SQLQuery; /** Commits a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL * @example diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index df53cb9dc40801..1a7a8c7f651cd5 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1091,7 +1091,10 @@ function assertValidTransactionName(name: string) { throw Error(`Distributed transaction name cannot contain single quotes.`); } } -function SQL(o) { +function SQL(o, e = {}) { + if (typeof o === "string" || o instanceof URL) { + o = { ...e, url: o }; + } var connectionInfo = loadOptions(o); var pool = new ConnectionPool(connectionInfo); From d3861a3462e3e2a6b0b3981d61b0f3f2675cbecf Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:26:37 -0800 Subject: [PATCH 34/35] types --- packages/bun-types/bun.d.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 6dae692be8bd5d..e52cc429dbc99d 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1997,6 +1997,19 @@ declare module "bun" { }; /** * Configuration options for SQL client connection and behavior + * @example + * const config: SQLOptions = { + * host: 'localhost', + * port: 5432, + * user: 'dbuser', + * password: 'secretpass', + * database: 'myapp', + * idleTimeout: 30000, + * max: 20, + * onconnect: (client) => { + * console.log('Connected to database'); + * } + * }; */ type SQLOptions = { /** Connection URL (can be string or URL object) */ @@ -2235,7 +2248,8 @@ declare module "bun" { * Also know as Two-Phase Commit, in a distributed transaction, Phase 1 involves the coordinator preparing nodes by ensuring data is written and ready to commit, while Phase 2 finalizes with nodes committing or rolling back based on the coordinator's decision, ensuring durability and releasing locks. * In PostgreSQL and MySQL distributed transactions persist beyond the original session, allowing privileged users or coordinators to commit/rollback them, ensuring support for distributed transactions, recovery, and administrative tasks. * beginDistributed will automatic rollback if any exception are not caught, and you can commit and rollback later if everything goes well. - * PostgreSQL natively supports distributed transactions using PREPARE TRANSACTION, while MySQL uses XA Transactions, and MSSQL also supports distributed/XA transactions. However, in MSSQL, distributed transactions are tied to the original session, the DTC coordinator, and the specific connection. These transactions are automatically committed or rolled back following the same rules as regular transactions, with no option for manual intervention from other sessions, in MSSQL distributed transactions are used to coordinate transactions using Linked Servers. + * PostgreSQL natively supports distributed transactions using PREPARE TRANSACTION, while MySQL uses XA Transactions, and MSSQL also supports distributed/XA transactions. However, in MSSQL, distributed transactions are tied to the original session, the DTC coordinator, and the specific connection. + * These transactions are automatically committed or rolled back following the same rules as regular transactions, with no option for manual intervention from other sessions, in MSSQL distributed transactions are used to coordinate transactions using Linked Servers. * @example * await sql.beginDistributed("numbers", async sql => { * await sql`create table if not exists numbers (a int)`; From 66dd28ecb6164b6a18cf5c35cdac0b8cefd58aee Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 18 Jan 2025 15:57:39 -0800 Subject: [PATCH 35/35] SQL and sql --- packages/bun-types/bun.d.ts | 26 +++++++++++++------------- src/bun.js/bindings/BunObject.cpp | 15 +++++++++++++-- src/js/builtins/BunBuiltinNames.h | 1 + 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index e52cc429dbc99d..106e2a7cf4edeb 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2035,9 +2035,9 @@ declare module "bun" { /** Whether to use TLS/SSL for the connection */ tls: boolean; /** Callback function executed when a connection is established */ - onconnect: (client: SQLClient) => void; + onconnect: (client: SQL) => void; /** Callback function executed when a connection is closed */ - onclose: (client: SQLClient) => void; + onclose: (client: SQL) => void; /** Maximum number of connections in the pool */ max: number; }; @@ -2070,23 +2070,23 @@ declare module "bun" { /** * Main SQL client interface providing connection and transaction management */ - interface SQLClient { + interface SQL { /** Creates a new SQL client instance * @example * const sql = new SQL("postgres://localhost:5432/mydb"); * const sql = new SQL(new URL("postgres://localhost:5432/mydb")); */ - new (connectionString: string | URL): SQLClient; + new (connectionString: string | URL): SQL; /** Creates a new SQL client instance with options * @example * const sql = new SQL("postgres://localhost:5432/mydb", { idleTimeout: 1000 }); */ - new (connectionString: string | URL, options: SQLOptions): SQLClient; + new (connectionString: string | URL, options: SQLOptions): SQL; /** Creates a new SQL client instance with options * @example * const sql = new SQL({ url: "postgres://localhost:5432/mydb", idleTimeout: 1000 }); */ - new (options?: SQLOptions): SQLClient; + new (options?: SQLOptions): SQL; /** Executes a SQL query using template literals * @example * const [user] = await sql`select * from users where id = ${1}`; @@ -2106,7 +2106,7 @@ declare module "bun" { * @example * await sql.connect(); */ - connect(): Promise; + connect(): Promise; /** Closes the database connection with optional timeout in seconds * @example * await sql.close({ timeout: 1 }); @@ -2141,7 +2141,7 @@ declare module "bun" { * await reserved`select * from users` * } */ - reserve(): Promise; + reserve(): Promise; /** Begins a new transaction * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.begin will resolve with the returned value from the callback function. * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. @@ -2270,23 +2270,23 @@ declare module "bun" { /** * Represents a reserved connection from the connection pool - * Extends SQLClient with additional release functionality + * Extends SQL with additional release functionality */ - interface ReservedSQLClient extends SQLClient { + interface ReservedSQL extends SQL { /** Releases the client back to the connection pool */ release(): void; } /** * Represents a client within a transaction context - * Extends SQLClient with savepoint functionality + * Extends SQL with savepoint functionality */ - interface TransactionSQLClient extends SQLClient { + interface TransactionSQL extends SQL { /** Creates a savepoint within the current transaction */ savepoint(name: string, fn: SQLContextCallback): Promise; } - const sql: SQLClient; + var sql: SQL; /** * This lets you use macros as regular imports diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 5aa13b44d08808..cfe3b9fe33b709 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -292,7 +292,7 @@ static JSValue constructPluginObject(VM& vm, JSObject* bunObject) return pluginFunction; } -static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject) +static JSValue defaultBunSQLObject(VM& vm, JSObject* bunObject) { auto scope = DECLARE_THROW_SCOPE(vm); auto* globalObject = defaultGlobalObject(bunObject->globalObject()); @@ -301,6 +301,16 @@ static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject) return sqlValue.getObject()->get(globalObject, vm.propertyNames->defaultKeyword); } +static JSValue constructBunSQLObject(VM& vm, JSObject* bunObject) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + auto* globalObject = defaultGlobalObject(bunObject->globalObject()); + JSValue sqlValue = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::BunSql); + RETURN_IF_EXCEPTION(scope, {}); + auto clientData = WebCore::clientData(vm); + return sqlValue.getObject()->get(globalObject, clientData->builtinNames().SQLPublicName()); +} + extern "C" JSC::EncodedJSValue JSPasswordObject__create(JSGlobalObject*); static JSValue constructPasswordObject(VM& vm, JSObject* bunObject) @@ -745,7 +755,8 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj revision constructBunRevision ReadOnly|DontDelete|PropertyCallback semver BunObject_getter_wrap_semver ReadOnly|DontDelete|PropertyCallback s3 BunObject_callback_s3 DontDelete|Function 1 - sql constructBunSQLObject DontDelete|PropertyCallback + sql defaultBunSQLObject DontDelete|PropertyCallback + SQL constructBunSQLObject DontDelete|PropertyCallback serve BunObject_callback_serve DontDelete|Function 1 sha BunObject_callback_sha DontDelete|Function 1 shrink BunObject_callback_shrink DontDelete|Function 1 diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 7fd2016a106ffc..7d083d7b5f0930 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -259,6 +259,7 @@ using namespace JSC; macro(written) \ macro(napiDlopenHandle) \ macro(napiWrappedContents) \ + macro(SQL) \ BUN_ADDITIONAL_BUILTIN_NAMES(macro) // --- END of BUN_COMMON_PRIVATE_IDENTIFIERS_EACH_PROPERTY_NAME ---