Skip to content

Commit

Permalink
feat(sql) transactions, savepoints, connection pooling and reserve (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
cirospaciari authored Jan 19, 2025
1 parent 87dedd1 commit ba930ad
Show file tree
Hide file tree
Showing 9 changed files with 1,856 additions and 283 deletions.
292 changes: 292 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,298 @@ declare module "bun" {
*/
stat(path: string, options?: S3Options): Promise<S3Stats>;
};
/**
* 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) */
url: URL | string;
/** Database server hostname */
host: string;
/** Database server 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;
/** 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;
/** Whether to use TLS/SSL for the connection */
tls: boolean;
/** Callback function executed when a connection is established */
onconnect: (client: SQL) => void;
/** Callback function executed when a connection is closed */
onclose: (client: SQL) => 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<any> {
/** 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<SQLQuery>) => Promise<any>;

/**
* Main SQL client interface providing connection and transaction management
*/
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): 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): SQL;
/** Creates a new SQL client instance with options
* @example
* const sql = new SQL({ url: "postgres://localhost:5432/mydb", idleTimeout: 1000 });
*/
new (options?: SQLOptions): SQL;
/** 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
* await sql.commitDistributed("my_distributed_transaction");
*/
commitDistributed(name: string): Promise<undefined>;
/** 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<undefined>;
/** Waits for the database connection to be established
* @example
* await sql.connect();
*/
connect(): Promise<SQL>;
/** Closes the database connection with optional timeout in seconds
* @example
* await sql.close({ timeout: 1 });
*/
close(options?: { timeout?: number }): Promise<undefined>;
/** Closes the database connection with optional timeout in seconds
* @alias close
* @example
* await sql.end({ timeout: 1 });
*/
end(options?: { timeout?: number }): Promise<undefined>;
/** 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
* 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<ReservedSQL>;
/** 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<any>;
/** 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<any>;
/** 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<any>;
/** 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<any>;
/** 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<any>;
/** Alternative method to begin a distributed transaction
* @alias beginDistributed
*/
distributed(name: string, fn: SQLContextCallback): Promise<any>;
/** Current client options */
options: SQLOptions;
}

/**
* Represents a reserved connection from the connection pool
* Extends SQL with additional release functionality
*/
interface ReservedSQL extends SQL {
/** Releases the client back to the connection pool */
release(): void;
}

/**
* Represents a client within a transaction context
* Extends SQL with savepoint functionality
*/
interface TransactionSQL extends SQL {
/** Creates a savepoint within the current transaction */
savepoint(name: string, fn: SQLContextCallback): Promise<undefined>;
}

var sql: SQL;

/**
* This lets you use macros as regular imports
Expand Down
15 changes: 13 additions & 2 deletions src/bun.js/bindings/BunObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/bun.js/bindings/ErrorCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ 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"],
["ERR_POSTGRES_QUERY_CANCELLED", Error, "PostgresError"],
["ERR_POSTGRES_UNSAFE_TRANSACTION", Error, "PostgresError"],

// S3
["ERR_S3_MISSING_CREDENTIALS", Error],
Expand Down
1 change: 0 additions & 1 deletion src/bun.js/event_loop.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 0 additions & 10 deletions src/bun.js/module_loader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2517,14 +2517,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),
Expand Down Expand Up @@ -2732,7 +2725,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",
Expand Down Expand Up @@ -2819,7 +2811,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" },
Expand Down Expand Up @@ -3059,7 +3050,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" } },
Expand Down
1 change: 1 addition & 0 deletions src/js/builtins/BunBuiltinNames.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down
Loading

0 comments on commit ba930ad

Please sign in to comment.