diff --git a/docs/api/sql.md b/docs/api/sql.md index 385fc6c3d1..01d4d8acd3 100644 --- a/docs/api/sql.md +++ b/docs/api/sql.md @@ -604,13 +604,12 @@ const db = new SQL({ connectionTimeout: 30, // Timeout when establishing new connections // SSL/TLS options - ssl: "prefer", // or "disable", "require", "verify-ca", "verify-full" - // tls: { - // rejectUnauthorized: true, - // ca: "path/to/ca.pem", - // key: "path/to/key.pem", - // cert: "path/to/cert.pem", - // }, + tls: { + rejectUnauthorized: true, + ca: "path/to/ca.pem", + key: "path/to/key.pem", + cert: "path/to/cert.pem", + }, // Callbacks onconnect: client => { diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts index b792170381..7b0526b380 100644 --- a/packages/bun-types/sql.d.ts +++ b/packages/bun-types/sql.d.ts @@ -41,22 +41,22 @@ declare module "bun" { class PostgresError extends SQLError { public readonly code: string; - public readonly errno: string | undefined; - public readonly detail: string | undefined; - public readonly hint: string | undefined; - public readonly severity: string | undefined; - public readonly position: string | undefined; - public readonly internalPosition: string | undefined; - public readonly internalQuery: string | undefined; - public readonly where: string | undefined; - public readonly schema: string | undefined; - public readonly table: string | undefined; - public readonly column: string | undefined; - public readonly dataType: string | undefined; - public readonly constraint: string | undefined; - public readonly file: string | undefined; - public readonly line: string | undefined; - public readonly routine: string | undefined; + public readonly errno?: string | undefined; + public readonly detail?: string | undefined; + public readonly hint?: string | undefined; + public readonly severity?: string | undefined; + public readonly position?: string | undefined; + public readonly internalPosition?: string | undefined; + public readonly internalQuery?: string | undefined; + public readonly where?: string | undefined; + public readonly schema?: string | undefined; + public readonly table?: string | undefined; + public readonly column?: string | undefined; + public readonly dataType?: string | undefined; + public readonly constraint?: string | undefined; + public readonly file?: string | undefined; + public readonly line?: string | undefined; + public readonly routine?: string | undefined; constructor( message: string, @@ -84,8 +84,8 @@ declare module "bun" { class MySQLError extends SQLError { public readonly code: string; - public readonly errno: number | undefined; - public readonly sqlState: string | undefined; + public readonly errno?: number | undefined; + public readonly sqlState?: string | undefined; constructor(message: string, options: { code: string; errno: number | undefined; sqlState: string | undefined }); } @@ -143,13 +143,13 @@ declare module "bun" { /** * Database server hostname + * @deprecated Prefer {@link hostname} * @default "localhost" */ host?: string | undefined; /** - * Database server hostname (alias for host) - * @deprecated Prefer {@link host} + * Database server hostname * @default "localhost" */ hostname?: string | undefined; @@ -264,13 +264,14 @@ declare module "bun" { * Whether to use TLS/SSL for the connection * @default false */ - tls?: TLSOptions | boolean | undefined; + tls?: Bun.BunFile | TLSOptions | boolean | undefined; /** * Whether to use TLS/SSL for the connection (alias for tls) + * @deprecated Prefer {@link tls} * @default false */ - ssl?: TLSOptions | boolean | undefined; + ssl?: Bun.BunFile | TLSOptions | boolean | undefined; /** * Unix domain socket path for connection diff --git a/src/bun.js/api/bun/socket/Listener.zig b/src/bun.js/api/bun/socket/Listener.zig index cfed0821e4..755bcb16b6 100644 --- a/src/bun.js/api/bun/socket/Listener.zig +++ b/src/bun.js/api/bun/socket/Listener.zig @@ -437,6 +437,7 @@ pub fn stop(this: *Listener, _: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) fn doStop(this: *Listener, force_close: bool) void { if (this.listener == .none) return; const listener = this.listener; + defer switch (listener) { .uws => |socket| socket.close(this.ssl), .namedPipe => |namedPipe| if (Environment.isWindows) namedPipe.closePipeAndDeinit(), diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index 127915395e..db7b0eb871 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -32,6 +32,7 @@ function adapterFromOptions(options: Bun.SQL.__internal.DefinedOptions) { case "postgres": return new PostgresAdapter(options); case "mysql": + case "mariadb": return new MySQLAdapter(options); case "sqlite": return new SQLiteAdapter(options); diff --git a/src/js/internal/sql/errors.ts b/src/js/internal/sql/errors.ts index 408090085b..a628c87cc1 100644 --- a/src/js/internal/sql/errors.ts +++ b/src/js/internal/sql/errors.ts @@ -7,7 +7,26 @@ class SQLError extends Error implements Bun.SQL.SQLError { export interface PostgresErrorOptions { code: string; + detail?: string | undefined; + hint?: string | undefined; + severity?: string | undefined; + errno?: string | undefined; + position?: string | undefined; + internalPosition?: string | undefined; + internalQuery?: string | undefined; + where?: string | undefined; + schema?: string | undefined; + table?: string | undefined; + column?: string | undefined; + dataType?: string | undefined; + constraint?: string | undefined; + file?: string | undefined; + line?: string | undefined; + routine?: string | undefined; +} +// oxlint-disable-next-line typescript-eslint(no-unsafe-declaration-merging) +interface PostgresError { detail?: string | undefined; hint?: string | undefined; severity?: string | undefined; @@ -28,22 +47,6 @@ export interface PostgresErrorOptions { class PostgresError extends SQLError implements Bun.SQL.PostgresError { public readonly code: string; - public readonly detail: string | undefined; - public readonly hint: string | undefined; - public readonly severity: string | undefined; - public readonly errno: string | undefined; - public readonly position: string | undefined; - public readonly internalPosition: string | undefined; - public readonly internalQuery: string | undefined; - public readonly where: string | undefined; - public readonly schema: string | undefined; - public readonly table: string | undefined; - public readonly column: string | undefined; - public readonly dataType: string | undefined; - public readonly constraint: string | undefined; - public readonly file: string | undefined; - public readonly line: string | undefined; - public readonly routine: string | undefined; constructor(message: string, options: PostgresErrorOptions) { super(message); @@ -51,10 +54,10 @@ class PostgresError extends SQLError implements Bun.SQL.PostgresError { this.name = "PostgresError"; this.code = options.code; + if (options.errno !== undefined) this.errno = options.errno; if (options.detail !== undefined) this.detail = options.detail; if (options.hint !== undefined) this.hint = options.hint; if (options.severity !== undefined) this.severity = options.severity; - if (options.errno !== undefined) this.errno = options.errno; if (options.position !== undefined) this.position = options.position; if (options.internalPosition !== undefined) this.internalPosition = options.internalPosition; if (options.internalQuery !== undefined) this.internalQuery = options.internalQuery; @@ -76,15 +79,20 @@ export interface SQLiteErrorOptions { byteOffset?: number | undefined; } +// oxlint-disable-next-line typescript-eslint(no-unsafe-declaration-merging) +interface SQLiteError { + byteOffset?: number | undefined; +} + class SQLiteError extends SQLError implements Bun.SQL.SQLiteError { public readonly code: string; public readonly errno: number; - public readonly byteOffset: number | undefined; constructor(message: string, options: SQLiteErrorOptions) { super(message); this.name = "SQLiteError"; + this.code = options.code; this.errno = options.errno; @@ -94,22 +102,28 @@ class SQLiteError extends SQLError implements Bun.SQL.SQLiteError { export interface MySQLErrorOptions { code: string; - errno: number | undefined; - sqlState: string | undefined; + errno?: number | undefined; + sqlState?: string | undefined; +} + +// oxlint-disable-next-line typescript-eslint(no-unsafe-declaration-merging) +interface MySQLError { + errno?: number | undefined; + sqlState?: string | undefined; } class MySQLError extends SQLError implements Bun.SQL.MySQLError { public readonly code: string; - public readonly errno: number | undefined; - public readonly sqlState: string | undefined; constructor(message: string, options: MySQLErrorOptions) { super(message); this.name = "MySQLError"; this.code = options.code; - this.errno = options.errno; - this.sqlState = options.sqlState; + + if (options.errno !== undefined) this.errno = options.errno; + if (options.sqlState !== undefined) this.sqlState = options.sqlState; } } + export default { PostgresError, SQLError, SQLiteError, MySQLError }; diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index 8e4702944e..44a1002e5c 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -109,7 +109,7 @@ export interface MySQLDotZig { password: string, databae: string, sslmode: SSLMode, - tls: Bun.TLSOptions | boolean | null, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing + tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing query: string, path: string, onConnected: (err: Error | null, connection: $ZigGeneratedClasses.MySQLConnection) => void, @@ -126,7 +126,7 @@ export interface MySQLDotZig { columns: string[] | undefined, bigint: boolean, simple: boolean, - ) => $ZigGeneratedClasses.MySQLSQLQuery; + ) => $ZigGeneratedClasses.MySQLQuery; } const enum SQLCommand { @@ -276,10 +276,10 @@ function onQueryFinish(this: PooledMySQLConnection, onClose: (err: Error) => voi class PooledMySQLConnection { private static async createConnection( - options: Bun.SQL.__internal.DefinedMySQLOptions, - onConnected: (err: Error | null, connection: $ZigGeneratedClasses.MySQLSQLConnection) => void, + options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, + onConnected: (err: Error | null, connection: $ZigGeneratedClasses.MySQLConnection) => void, onClose: (err: Error | null) => void, - ): Promise<$ZigGeneratedClasses.MySQLSQLConnection | null> { + ): Promise<$ZigGeneratedClasses.MySQLConnection | null> { const { hostname, port, @@ -292,8 +292,6 @@ class PooledMySQLConnection { connectionTimeout = 30 * 1000, maxLifetime = 0, prepare = true, - - // @ts-expect-error path is currently removed from the types path, } = options; @@ -302,10 +300,10 @@ class PooledMySQLConnection { try { if (typeof password === "function") { password = password(); + } - if (password && $isPromise(password)) { - password = await password; - } + if (password && $isPromise(password)) { + password = await password; } return createMySQLConnection( @@ -336,12 +334,12 @@ class PooledMySQLConnection { } adapter: MySQLAdapter; - connection: $ZigGeneratedClasses.MySQLSQLConnection | null = null; + connection: $ZigGeneratedClasses.MySQLConnection | null = null; state: PooledConnectionState = PooledConnectionState.pending; storedError: Error | null = null; queries: Set<(err: Error) => void> = new Set(); onFinish: ((err: Error | null) => void) | null = null; - connectionInfo: Bun.SQL.__internal.DefinedMySQLOptions; + connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; 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; @@ -488,7 +486,7 @@ export class MySQLAdapter implements DatabaseAdapter { - public readonly connectionInfo: Bun.SQL.__internal.DefinedMySQLOptions; + public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; public readonly connections: PooledMySQLConnection[]; public readonly readyConnections: Set; @@ -501,7 +499,7 @@ export class MySQLAdapter public totalQueries: number = 0; public onAllQueriesFinished: (() => void) | null = null; - constructor(connectionInfo: Bun.SQL.__internal.DefinedMySQLOptions) { + constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { this.connectionInfo = connectionInfo; this.connections = new Array(connectionInfo.max); this.readyConnections = new Set(); @@ -845,7 +843,7 @@ export class MySQLAdapter return; } - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); const timer = setTimeout(() => { // timeout is reached, lets close and probably fail some queries this.#close().finally(resolve); @@ -868,7 +866,7 @@ export class MySQLAdapter } // gracefully close the pool - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); this.onAllQueriesFinished = () => { // everything is closed, lets close the pool @@ -1179,7 +1177,7 @@ export class MySQLAdapter export default { MySQLAdapter, - SQLCommand, commandToString, detectCommand, + SQLCommand, }; diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index 9dbb3f30fd..75ad2085ef 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -126,7 +126,7 @@ export interface PostgresDotZig { password: string, databae: string, sslmode: SSLMode, - tls: Bun.TLSOptions | boolean | null, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing + tls: Bun.TLSOptions | boolean | null | Bun.BunFile, // boolean true => empty TLSOptions object `{}`, boolean false or null => nothing query: string, path: string, onConnected: (err: Error | null, connection: $ZigGeneratedClasses.PostgresSQLConnection) => void, @@ -293,7 +293,7 @@ function onQueryFinish(this: PooledPostgresConnection, onClose: (err: Error) => class PooledPostgresConnection { private static async createConnection( - options: Bun.SQL.__internal.DefinedPostgresOptions, + options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, onConnected: (err: Error | null, connection: $ZigGeneratedClasses.PostgresSQLConnection) => void, onClose: (err: Error | null) => void, ): Promise<$ZigGeneratedClasses.PostgresSQLConnection | null> { @@ -309,8 +309,6 @@ class PooledPostgresConnection { connectionTimeout = 30 * 1000, maxLifetime = 0, prepare = true, - - // @ts-expect-error path is currently removed from the types path, } = options; @@ -319,10 +317,10 @@ class PooledPostgresConnection { try { if (typeof password === "function") { password = password(); + } - if (password && $isPromise(password)) { - password = await password; - } + if (password && $isPromise(password)) { + password = await password; } return createPostgresConnection( @@ -358,7 +356,7 @@ class PooledPostgresConnection { storedError: Error | null = null; queries: Set<(err: Error) => void> = new Set(); onFinish: ((err: Error | null) => void) | null = null; - connectionInfo: Bun.SQL.__internal.DefinedPostgresOptions; + connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; 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; @@ -425,7 +423,7 @@ class PooledPostgresConnection { this.adapter.release(this, true); } - constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOptions, adapter: PostgresAdapter) { + constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, adapter: PostgresAdapter) { this.state = PooledConnectionState.pending; this.adapter = adapter; this.connectionInfo = connectionInfo; @@ -509,7 +507,7 @@ export class PostgresAdapter $ZigGeneratedClasses.PostgresSQLQuery > { - public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOptions; + public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; public readonly connections: PooledPostgresConnection[]; public readonly readyConnections: Set; @@ -522,7 +520,7 @@ export class PostgresAdapter public totalQueries: number = 0; public onAllQueriesFinished: (() => void) | null = null; - constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOptions) { + constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { this.connectionInfo = connectionInfo; this.connections = new Array(connectionInfo.max); this.readyConnections = new Set(); @@ -850,7 +848,7 @@ export class PostgresAdapter return Promise.all(promises); } - async close(options?: { timeout?: number }) { + async close(options?: { timeout?: number }): Promise { if (this.closed) { return; } @@ -869,7 +867,7 @@ export class PostgresAdapter return; } - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); const timer = setTimeout(() => { // timeout is reached, lets close and probably fail some queries this.#close().finally(resolve); @@ -892,7 +890,7 @@ export class PostgresAdapter } // gracefully close the pool - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); this.onAllQueriesFinished = () => { // everything is closed, lets close the pool diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 874191aa0c..ea16b2d978 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -13,6 +13,7 @@ class SQLResultArray extends PublicArray { public command!: string | null; public lastInsertRowid!: number | bigint | null; public affectedRows!: number | bigint | null; + static [Symbol.toStringTag] = "SQLResults"; constructor(values: T[] = []) { @@ -74,7 +75,7 @@ function normalizeSSLMode(value: string): SSLMode { } } - throw $ERR_INVALID_ARG_VALUE("sslmode", value); + throw $ERR_INVALID_ARG_VALUE("sslmode", value, "must be one of: disable, prefer, require, verify-ca, verify-full"); } export type { SQLHelper }; @@ -114,72 +115,110 @@ class SQLHelper { } } +const SQLITE_MEMORY = ":memory:"; +const SQLITE_MEMORY_VARIANTS: string[] = [":memory:", "sqlite://:memory:", "sqlite:memory"]; + +const sqliteProtocols = [ + { prefix: "sqlite://", stripLength: 9 }, + { prefix: "sqlite:", stripLength: 7 }, + { prefix: "file://", stripLength: -1 }, // Special case we can use Bun.fileURLToPath + { prefix: "file:", stripLength: 5 }, +]; + function parseDefinitelySqliteUrl(value: string | URL | null): string | null { if (value === null) return null; const str = value instanceof URL ? value.toString() : value; - if (str === ":memory:" || str === "sqlite://:memory:" || str === "sqlite:memory") return ":memory:"; + if (SQLITE_MEMORY_VARIANTS.includes(str)) { + return SQLITE_MEMORY; + } - // For any URL-like string, just extract the path portion - // Strip the protocol and handle query params - let path: string; + for (const { prefix, stripLength } of sqliteProtocols) { + if (!str.startsWith(prefix)) continue; - if (str.startsWith("sqlite://")) { - path = str.slice(9); // "sqlite://".length - } else if (str.startsWith("sqlite:")) { - path = str.slice(7); // "sqlite:".length - } else if (str.startsWith("file://")) { - // For file:// URLs, use Bun's built-in converter for correct platform handling - // This properly handles Windows paths, UNC paths, etc. - try { - return Bun.fileURLToPath(str); - } catch { - // Fallback: just strip the protocol - path = str.slice(7); // "file://".length + if (stripLength === -1) { + try { + return Bun.fileURLToPath(str); + } catch { + // if it cant pass it's probably query string, we can just strip it + // slicing off the file:// at the beginning + return str.slice(7); + } } - } else if (str.startsWith("file:")) { - path = str.slice(5); // "file:".length - } else { - // Not a SQLite URL - return null; + + return str.slice(stripLength); } - // Remove query parameters if present (only looking for ?) - const queryIndex = path.indexOf("?"); - if (queryIndex !== -1) { - path = path.slice(0, queryIndex); - } - - return path; + // couldn't reliably determine this was definitely a sqlite url + // it still *could* be, but not unambigously. + return null; } -function parseSQLiteOptionsWithQueryParams( - sqliteOptions: Bun.SQL.__internal.DefinedSQLiteOptions, - urlString: string | URL | null | undefined, +function parseSQLiteOptions( + filenameOrUrl: string | URL | null | undefined, + options: Bun.SQL.__internal.OptionsWithDefinedAdapter, ): Bun.SQL.__internal.DefinedSQLiteOptions { - if (!urlString) return sqliteOptions; + // Start with base options + const sqliteOptions: Bun.SQL.__internal.DefinedSQLiteOptions = { + ...options, + adapter: "sqlite" as const, + filename: ":memory:", + }; - let params: URLSearchParams | null = null; + let filename = filenameOrUrl || ":memory:"; + let originalUrl = filename; // Keep the original URL for query parsing - if (urlString instanceof URL) { - params = urlString.searchParams; - } else { - const queryIndex = urlString.indexOf("?"); - if (queryIndex === -1) return sqliteOptions; - - const queryString = urlString.slice(queryIndex + 1); - params = new URLSearchParams(queryString); + if (filename instanceof URL) { + originalUrl = filename.toString(); + filename = filename.toString(); } - const mode = params.get("mode"); + let queryString: string | null = null; + // Parse query string from the original URL before processing + if (typeof originalUrl === "string") { + const queryIndex = originalUrl.indexOf("?"); + if (queryIndex !== -1) { + queryString = originalUrl.slice(queryIndex + 1); + // Strip query from filename for processing + if (typeof filename === "string") { + filename = filename.slice(0, queryIndex); + } + } + } - if (mode === "ro") { - sqliteOptions.readonly = true; - } else if (mode === "rw") { - sqliteOptions.readonly = false; - } else if (mode === "rwc") { - sqliteOptions.readonly = false; - sqliteOptions.create = true; + // Now parse the filename (this handles file:// URLs and other protocols) + const parsedFilename = parseDefinitelySqliteUrl(filename); + if (parsedFilename !== null) { + filename = parsedFilename; + } + + // Empty filename defaults to :memory: + sqliteOptions.filename = filename || ":memory:"; + + // Parse query parameters if present + if (queryString) { + const params = new URLSearchParams(queryString); + const mode = params.get("mode"); + + if (mode === "ro") { + sqliteOptions.readonly = true; + } else if (mode === "rw") { + sqliteOptions.readonly = false; + } else if (mode === "rwc") { + sqliteOptions.readonly = false; + sqliteOptions.create = true; + } + } + + // Apply other SQLite-specific options + if ("readonly" in options) { + sqliteOptions.readonly = options.readonly; + } + if ("create" in options) { + sqliteOptions.create = options.create; + } + if ("safeIntegers" in options) { + sqliteOptions.safeIntegers = options.safeIntegers; } return sqliteOptions; @@ -201,178 +240,281 @@ function assertIsOptionsOfAdapter( } } -function hasProtocol(url: string) { - if (typeof url !== "string") return false; - const protocols: string[] = [ - "http", - "https", - "ftp", - "postgres", - "postgresql", - "mysql", - "mysql2", - "mariadb", - "file", - "sqlite", - ]; - for (const protocol of protocols) { - if (url.startsWith(protocol + "://")) { - return true; - } +const DEFAULT_PROTOCOL: Bun.SQL.__internal.Adapter = "postgres"; + +const env = Bun.env; + +/** + * Reads environment variables to try and find a connnection string + * @param adapter If an adapter is specified in the options, pass it here and + * this function will only resolve from environment variables that are specific + * to that adapter. Otherwise it will try them all. + */ +function getConnectionDetailsFromEnvironment( + adapter: Bun.SQL.__internal.Adapter | undefined, +): [url: string | null, sslMode: SSLMode | null, adapter: Bun.SQL.__internal.Adapter | null] { + let url: string | null = null; + let sslMode: SSLMode.require | null = null; + + url ||= env.DATABASE_URL || env.DATABASEURL || null; + if (!url) { + url = env.TLS_DATABASE_URL || null; + if (url) sslMode = SSLMode.require; } - return false; + if (url) return [url, sslMode, adapter || null]; + + if (!adapter || adapter === "postgres") { + url ||= env.POSTGRES_URL || env.PGURL || env.PG_URL || env.PGURL || null; + if (!url) { + url = env.TLS_POSTGRES_DATABASE_URL || null; + if (url) sslMode = SSLMode.require; + } + if (url) return [url, sslMode, "postgres"]; + } + + if (!adapter || adapter === "mysql") { + url ||= env.MYSQL_URL || env.MYSQLURL || null; + if (!url) { + url = env.TLS_MYSQL_DATABASE_URL || null; + if (url) sslMode = SSLMode.require; + } + if (url) return [url, sslMode, "mysql"]; + } + + if (!adapter || adapter === "mariadb") { + url ||= env.MARIADB_URL || env.MARIADBURL || null; + if (!url) { + url = env.TLS_MARIADB_DATABASE_URL || null; + if (url) sslMode = SSLMode.require; + } + if (url) return [url, sslMode, "mariadb"]; + } + + if (!adapter || adapter === "sqlite") { + url ||= env.SQLITE_URL || env.SQLITEURL || null; + // No TLS_ check because SQLite has no applicable sslMode + if (url) return [url, sslMode, "sqlite"]; + } + + return [url, sslMode, adapter || null]; } -function defaultToPostgresIfNoProtocol(url: string | URL | null): URL { +function ensureUrlHasProtocol( + url: T | null, + protocol: string, +): (T extends string ? string : T extends URL ? URL : never) | null { + if (url === null) return null; if (url instanceof URL) { - return url; + url.protocol = protocol; + return url as never; } - if (hasProtocol(url as string)) { - return new URL(url as string); - } - return new URL("postgres://" + url); + return `${protocol}://${url}` as never; } -function parseOptions( + +function hasProtocol(url: string | URL): boolean { + if (url instanceof URL) { + return true; + } + + return url.includes("://"); +} + +/** + * @returns A tuple containing the parsed adapter (this is always correct) and a + * url string, that you should continue to use for further options. In some + * cases the it will be a parsed URL instance, and in others a string. This is + * to save unnecessary parses in some cases. The third value is the SSL mode The last value is the options object + * resolved from the possible overloads of the Bun.SQL constructor, it may have modifications + */ +function parseConnectionDetailsFromOptionsOrEnvironment( stringOrUrlOrOptions: Bun.SQL.Options | string | URL | undefined, definitelyOptionsButMaybeEmpty: Bun.SQL.Options, -): Bun.SQL.__internal.DefinedOptions { - const env = Bun.env; +): [url: string | URL | null, sslMode: SSLMode | null, options: Bun.SQL.__internal.OptionsWithDefinedAdapter] { + // Step 1: Determine the options object and initial URL + let options: Bun.SQL.Options; + let stringOrUrl: string | URL | null = null; + let sslMode: SSLMode | null = null; + let adapter: Bun.SQL.__internal.Adapter | null = null; - let [ - stringOrUrl = env.POSTGRES_URL || env.DATABASE_URL || env.PGURL || env.PG_URL || env.MYSQL_URL || null, - options, - ]: [string | URL | null, Bun.SQL.Options] = - typeof stringOrUrlOrOptions === "string" || stringOrUrlOrOptions instanceof URL - ? [stringOrUrlOrOptions, definitelyOptionsButMaybeEmpty] - : stringOrUrlOrOptions - ? [null, { ...stringOrUrlOrOptions, ...definitelyOptionsButMaybeEmpty }] - : [null, definitelyOptionsButMaybeEmpty]; + if (typeof stringOrUrlOrOptions === "string" || stringOrUrlOrOptions instanceof URL) { + stringOrUrl = stringOrUrlOrOptions; + options = definitelyOptionsButMaybeEmpty; + } else { + options = stringOrUrlOrOptions + ? { ...stringOrUrlOrOptions, ...definitelyOptionsButMaybeEmpty } + : definitelyOptionsButMaybeEmpty; + [stringOrUrl, sslMode, adapter] = getConnectionDetailsFromEnvironment(options.adapter); + } - if (options.adapter === undefined && stringOrUrl !== null) { - const sqliteUrl = parseDefinitelySqliteUrl(stringOrUrl); + // Resolve URL based on adapter type + let resolvedUrl: string | URL | null = stringOrUrl; - if (sqliteUrl !== null) { - const sqliteOptions: Bun.SQL.__internal.DefinedSQLiteOptions = { - ...options, - adapter: "sqlite", - filename: sqliteUrl, - }; - - return parseSQLiteOptionsWithQueryParams(sqliteOptions, stringOrUrl); + if (options.adapter === "sqlite") { + // SQLite adapter - only check filename (not url) + if ("filename" in options && options.filename) { + resolvedUrl = options.filename; + } + } else if (!options.adapter) { + // Unknown adapter - check both, filename first (more specific) + if ("filename" in options && options.filename) { + resolvedUrl = options.filename; + } else if ("url" in options && options.url) { + resolvedUrl = options.url; + } + } else { + // Known non-SQLite adapter - only check url (not filename) + if ("url" in options && options.url) { + resolvedUrl = options.url; } } if (options.adapter === "sqlite") { - let filenameFromOptions = options.filename || stringOrUrl; - - // Parse sqlite:// URLs when adapter is explicitly sqlite - if (typeof filenameFromOptions === "string" || filenameFromOptions instanceof URL) { - const parsed = parseDefinitelySqliteUrl(filenameFromOptions); - if (parsed !== null) { - filenameFromOptions = parsed; - } - } - - const sqliteOptions: Bun.SQL.__internal.DefinedSQLiteOptions = { - ...options, - adapter: "sqlite", - filename: filenameFromOptions || ":memory:", - }; - - return parseSQLiteOptionsWithQueryParams(sqliteOptions, stringOrUrl); + return [resolvedUrl, null, options as Bun.SQL.__internal.OptionsWithDefinedAdapter]; } - if (!stringOrUrl) { - const url = options?.url; - if (typeof url === "string") { - stringOrUrl = defaultToPostgresIfNoProtocol(url); - } else if (url instanceof URL) { - stringOrUrl = url; + if (!options.adapter && resolvedUrl !== null) { + const parsedPath = parseDefinitelySqliteUrl(resolvedUrl); + + if (parsedPath !== null) { + // Return the original URL (with query params) for SQLite parsing + return [resolvedUrl, null, { ...options, adapter: "sqlite" }]; } } - let hostname: string | undefined, - port: number | string | undefined, - username: string | null | undefined, - password: string | (() => Bun.MaybePromise) | undefined | null, - database: string | undefined, - tls: Bun.TLSOptions | boolean | undefined, - url: URL | undefined, - query: string, - idleTimeout: number | null | undefined, - connectionTimeout: number | null | undefined, - maxLifetime: number | null | undefined, - onconnect: ((client: Bun.SQL) => void) | undefined, - onclose: ((client: Bun.SQL) => void) | undefined, - max: number | null | undefined, - bigint: boolean | undefined, - path: string, - adapter: Bun.SQL.__internal.Adapter; + // Step 3: Parse protocol and ensure URL format for non-SQLite databases + let protocol: Bun.SQL.__internal.Adapter | (string & {}) = options.adapter || DEFAULT_PROTOCOL; - let prepare = true; - let sslMode: SSLMode = SSLMode.disable; + let urlToProcess = resolvedUrl || stringOrUrl; - if (!stringOrUrl || (typeof stringOrUrl === "string" && stringOrUrl.length === 0)) { - let urlString = env.POSTGRES_URL || env.DATABASE_URL || env.PGURL || env.PG_URL; + if (urlToProcess instanceof URL) { + protocol = urlToProcess.protocol.replace(/:$/, ""); + } else if (urlToProcess !== null) { + if (hasProtocol(urlToProcess)) { + try { + urlToProcess = new URL(urlToProcess); + protocol = urlToProcess.protocol.replace(/:$/, ""); + } catch (e) { + // options.adpater won't be sqlite here, we already did the special case check for it + if (options.adapter && typeof urlToProcess === "string" && urlToProcess.includes("sqlite")) { + throw new Error( + `Invalid URL '${urlToProcess}' for ${options.adapter}. Did you mean to specify \`{ adapter: "sqlite" }\`?`, + { cause: e }, + ); + } - if (!urlString) { - urlString = env.TLS_POSTGRES_DATABASE_URL || env.TLS_DATABASE_URL; - if (urlString) { - sslMode = SSLMode.require; + // unrelated error to do with url parsing, we should re-throw. This is a real user error + throw e; } - } - - if (urlString) { - // Check if it's a SQLite URL before trying to parse as regular URL - const sqliteUrl = parseDefinitelySqliteUrl(urlString); - if (sqliteUrl !== null) { - const sqliteOptions: Bun.SQL.__internal.DefinedSQLiteOptions = { - ...options, - adapter: "sqlite", - filename: sqliteUrl, - }; - return parseSQLiteOptionsWithQueryParams(sqliteOptions, urlString); - } - - url = new URL(urlString); - } - } else if (stringOrUrl && typeof stringOrUrl === "object") { - if (stringOrUrl instanceof URL) { - url = stringOrUrl; - } else if (options?.url) { - const _url = options.url; - if (typeof _url === "string") { - url = defaultToPostgresIfNoProtocol(_url); - } else if (_url && typeof _url === "object" && _url instanceof URL) { - url = _url; - } - } - if (options?.tls) { - sslMode = SSLMode.require; - tls = options.tls; - } - } else if (typeof stringOrUrl === "string") { - try { - url = defaultToPostgresIfNoProtocol(stringOrUrl); - } catch (e) { - throw new Error(`Invalid URL '${stringOrUrl}' for postgres. Did you mean to specify \`{ adapter: "sqlite" }\`?`, { - cause: e, - }); + } else { + // Add protocol if missing + urlToProcess = ensureUrlHasProtocol(urlToProcess, protocol); } } - query = ""; - adapter = options.adapter; + + // Step 4: Set adapter from environment if not already set, but ONLY if not + // already set (options object is highest priority) + if (options.adapter === undefined && adapter !== null) { + options.adapter = adapter; + } + + // Step 5: Return early if adapter is explicitly specified + if (options.adapter) { + // Validate that the adapter is supported + const supportedAdapters = ["postgres", "sqlite", "mysql", "mariadb"]; + if (!supportedAdapters.includes(options.adapter)) { + throw new Error( + `Unsupported adapter: ${options.adapter}. Supported adapters: "postgres", "sqlite", "mysql", "mariadb"`, + ); + } + return [urlToProcess, sslMode, options as Bun.SQL.__internal.OptionsWithDefinedAdapter]; + } + + // Step 6: Infer adapter from protocol + const parsedAdapterFromProtocol = parseAdapterFromProtocol(protocol); + + if (!parsedAdapterFromProtocol) { + throw new Error(`Unsupported protocol: ${protocol}. Supported adapters: "postgres", "sqlite", "mysql", "mariadb"`); + } + + return [urlToProcess, sslMode, { ...options, adapter: parsedAdapterFromProtocol }]; +} + +function parseAdapterFromProtocol(protocol: string): Bun.SQL.__internal.Adapter | null { + switch (protocol) { + case "http": + case "https": + case "ftp": + case "postgres": + case "postgresql": + return "postgres"; + + case "mysql": + case "mysql2": + return "mysql"; + + case "mariadb": + return "mariadb"; + + case "file": + case "sqlite": + return "sqlite"; + + default: + return null; + } +} + +function parseOptions( + stringOrUrlOrOptions: Bun.SQL.Options | string | URL | undefined, + definitelyOptionsButMaybeEmpty: Bun.SQL.Options, +): Bun.SQL.__internal.DefinedOptions { + const [_url, sslModeFromConnectionDetails, options] = parseConnectionDetailsFromOptionsOrEnvironment( + stringOrUrlOrOptions, + definitelyOptionsButMaybeEmpty, + ); + + const adapter = options.adapter; + + if (adapter === "sqlite") { + return parseSQLiteOptions(_url, options); + } + + // The rest of this function is logic specific to postgres/mysql/mariadb (they have the same options object) + + let sslMode: SSLMode = sslModeFromConnectionDetails || SSLMode.disable; + + let url = _url; + + let hostname: string | undefined; + let port: number | string | undefined; + let username: string | null | undefined; + let password: string | (() => Bun.MaybePromise) | undefined | null; + let database: string | undefined; + let tls: Bun.TLSOptions | boolean | undefined; + let query: string = ""; + let idleTimeout: number | null | undefined; + let connectionTimeout: number | null | undefined; + let maxLifetime: number | null | undefined; + let onconnect: ((error?: Error | undefined) => void) | undefined; + let onclose: ((error?: Error | undefined) => void) | undefined; + let max: number | null | undefined; + let bigint: boolean | undefined; + let path: string; + let prepare: boolean = true; + + if (url !== null) { + url = url instanceof URL ? url : new URL(url); + } + if (url) { - ({ hostname, port, username, password, adapter } = options); - // object overrides url - hostname ||= url.hostname; - port ||= url.port; - username ||= decodeIfValid(url.username); - password ||= decodeIfValid(url.password); - adapter ||= url.protocol as Bun.SQL.__internal.Adapter; - if (adapter && adapter[adapter.length - 1] === ":") { - adapter = adapter.slice(0, -1) as Bun.SQL.__internal.Adapter; - } + // TODO(@alii): Move this logic into the switch statements below + // options object is always higher priority + hostname ||= options.host || options.hostname || url.hostname; + port ||= options.port || url.port; + username ||= options.user || options.username || decodeIfValid(url.username); + password ||= options.pass || options.password || decodeIfValid(url.password); + + path ||= options.path || url.pathname; const queryObject = url.searchParams.toJSON(); for (const key in queryObject) { @@ -390,38 +532,38 @@ function parseOptions( } query = query.trim(); } - if (adapter) { - switch (adapter) { - case "http": - case "https": - case "ftp": - case "postgres": - case "postgresql": - adapter = "postgres"; - break; - case "mysql": - case "mysql2": - case "mariadb": - adapter = "mysql"; - break; - case "file": - case "sqlite": - adapter = "sqlite"; - break; - default: - options.adapter satisfies never; // This will type error if we support a new adapter in the future, which will let us know to update this check - throw new Error(`Unsupported adapter: ${options.adapter}. Supported adapters: "postgres", "sqlite", "mysql"`); + + switch (adapter) { + case "postgres": { + hostname ||= options.hostname || options.host || env.PG_HOST || env.PGHOST || "localhost"; + break; + } + case "mysql": { + hostname ||= options.hostname || options.host || env.MYSQL_HOST || env.MYSQLHOST || "localhost"; + break; + } + case "mariadb": { + hostname ||= options.hostname || options.host || env.MARIADB_HOST || env.MARIADBHOST || "localhost"; + break; } - } else { - adapter = "postgres"; } - options.adapter = adapter; - assertIsOptionsOfAdapter(options, adapter); - hostname ||= options.hostname || options.host || env.PGHOST || "localhost"; - port ||= Number(options.port || env.PGPORT || (adapter === "mysql" ? 3306 : 5432)); + switch (adapter) { + case "postgres": { + port ||= Number(options.port || env.PG_PORT || env.PGPORT || "5432"); + break; + } + case "mysql": { + port ||= Number(options.port || env.MYSQL_PORT || env.MYSQLPORT || "3306"); + break; + } + case "mariadb": { + port ||= Number(options.port || env.MARIADB_PORT || env.MARIADBPORT || "3306"); + break; + } + } - path ||= (options as { path?: string }).path || ""; + path ||= options.path || ""; if (adapter === "postgres") { // add /.s.PGSQL.${port} if the unix domain socket is listening on that path @@ -437,21 +579,74 @@ function parseOptions( } } - username ||= - options.username || - options.user || - env.PGUSERNAME || - env.PGUSER || - env.USER || - env.USERNAME || - (adapter === "mysql" ? "root" : "postgres"); // default username for mysql is root and for postgres is postgres; - database ||= - options.database || - options.db || - decodeIfValid((url?.pathname ?? "").slice(1)) || - env.PGDATABASE || - (adapter === "mysql" ? "mysql" : username); // default database; - password ||= options.password || options.pass || env.PGPASSWORD || ""; + switch (adapter) { + case "mysql": { + username ||= options.username || options.user || env.MYSQL_USER || env.MYSQLUSER || env.USER || "root"; + break; + } + case "mariadb": { + username ||= options.username || options.user || env.MARIADB_USER || env.MARIADBUSER || env.USER || "root"; + break; + } + case "postgres": { + username ||= options.username || options.user || env.PG_USER || env.PGUSER || env.USER || "postgres"; + break; + } + } + + switch (adapter) { + case "mysql": { + password ||= options.password || options.pass || env.MYSQL_PASSWORD || env.MYSQLPASSWORD || env.PASSWORD || ""; + break; + } + + case "mariadb": { + password ||= + options.password || options.pass || env.MARIADB_PASSWORD || env.MARIADBPASSWORD || env.PASSWORD || ""; + break; + } + + case "postgres": { + password ||= options.password || options.pass || env.PG_PASSWORD || env.PGPASSWORD || env.PASSWORD || ""; + break; + } + } + + switch (adapter) { + case "postgres": { + database ||= + options.database || + options.db || + env.PG_DATABASE || + env.PGDATABASE || + decodeIfValid((url?.pathname ?? "").slice(1)) || + username; + break; + } + + case "mysql": { + database ||= + options.database || + options.db || + env.MYSQL_DATABASE || + env.MYSQLDATABASE || + decodeIfValid((url?.pathname ?? "").slice(1)) || + "mysql"; + break; + } + + case "mariadb": { + database ||= + options.database || + options.db || + env.MARIADB_DATABASE || + env.MARIADBDATABASE || + decodeIfValid((url?.pathname ?? "").slice(1)) || + "mariadb"; + break; + } + } + const connection = options.connection; if (connection && $isObject(connection)) { for (const key in connection) { @@ -473,6 +668,7 @@ function parseOptions( maxLifetime ??= options.maxLifetime; maxLifetime ??= options.max_lifetime; bigint ??= options.bigint; + // we need to explicitly set prepare to false if it is false if (options.prepare === false) { if (adapter === "mysql") { @@ -483,6 +679,7 @@ function parseOptions( onconnect ??= options.onconnect; onclose ??= options.onclose; + if (onconnect !== undefined) { if (!$isCallable(onconnect)) { throw $ERR_INVALID_ARG_TYPE("onconnect", "function", onconnect); @@ -549,6 +746,7 @@ function parseOptions( if (tls && sslMode === SSLMode.disable) { sslMode = SSLMode.prefer; } + port = Number(port); if (!Number.isSafeInteger(port) || port < 1 || port > 65535) { @@ -617,9 +815,10 @@ export interface DatabaseAdapter { normalizeQuery(strings: string | TemplateStringsArray, values: unknown[]): [sql: string, values: unknown[]]; createQueryHandle(sql: string, values: unknown[], flags: number): QueryHandle; connect(onConnected: OnConnected, reserved?: boolean): void; - release(connection: ConnectionHandle, connectingEvent?: boolean): void; + release(connection: Connection, connectingEvent?: boolean): void; close(options?: { timeout?: number }): Promise; flush(): void; + isConnected(): boolean; get closed(): boolean; @@ -649,7 +848,10 @@ export default { assertIsOptionsOfAdapter, parseOptions, SQLHelper, - SSLMode, normalizeSSLMode, SQLResultArray, + + // @ts-expect-error we're exporting a const enum which works in our builtins + // generator but not in typescript officially + SSLMode, }; diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 1c56c4f754..d2d8aebad0 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -533,7 +533,7 @@ var access = function access(path, mode, callback) { copyFileSync = fs.copyFileSync.bind(fs), // This behavior - never throwing -- matches Node.js behavior. // https://github.com/nodejs/node/blob/c82f3c9e80f0eeec4ae5b7aedd1183127abda4ad/lib/fs.js#L275C1-L295C1 - existsSync = function existsSync() { + existsSync = function existsSync(_path: string) { try { return fs.existsSync.$apply(fs, arguments); } catch { diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 738743c842..7209a66f3a 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -12,9 +12,11 @@ declare function $bundleError(...message: any[]): never; declare module "bun" { namespace SQL.__internal { - type Define = T & { - [Key in K | "adapter"]: NonNullable; - } & {}; + type Define = T extends any + ? T & { + [Key in K | "adapter"]: NonNullable; + } & {} + : never; type Adapter = NonNullable; @@ -24,16 +26,15 @@ declare module "bun" { type DefinedSQLiteOptions = Define; /** - * Represents the result of the `parseOptions()` function in the postgres path + * Represents the result of the `parseOptions()` function in the postgres, mysql or mariadb path */ - type DefinedPostgresOptions = Define & { + type DefinedPostgresOrMySQLOptions = Define & { sslMode: import("internal/sql/shared").SSLMode; query: string; }; - type DefinedMySQLOptions = DefinedPostgresOptions; - - type DefinedOptions = DefinedSQLiteOptions | DefinedPostgresOptions | DefinedMySQLOptions; + type DefinedOptions = DefinedSQLiteOptions | DefinedPostgresOrMySQLOptions; + type OptionsWithDefinedAdapter = Define; } } diff --git a/test/harness.ts b/test/harness.ts index 01b5e79373..19ddb1d7b3 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -8,12 +8,11 @@ import { gc as bunGC, sleepSync, spawnSync, unsafe, which, write } from "bun"; import { heapStats } from "bun:jsc"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { ChildProcess, fork } from "child_process"; +import { ChildProcess, execSync, fork } from "child_process"; import { readdir, readFile, readlink, rm, writeFile } from "fs/promises"; import fs, { closeSync, openSync, rmSync } from "node:fs"; import os from "node:os"; import { dirname, isAbsolute, join } from "path"; -import { execSync } from "child_process"; type Awaitable = T | Promise; @@ -31,7 +30,7 @@ export const libcFamily: "glibc" | "musl" = process.platform !== "linux" ? "glibc" : // process.report.getReport() has incorrect type definitions. - (process.report.getReport() as any).header.glibcVersionRuntime + (process.report.getReport() as { header: { glibcVersionRuntime: boolean } }).header.glibcVersionRuntime ? "glibc" : "musl"; diff --git a/test/integration/bun-types/fixture/sql.ts b/test/integration/bun-types/fixture/sql.ts index ccac825fd6..3d72c748e4 100644 --- a/test/integration/bun-types/fixture/sql.ts +++ b/test/integration/bun-types/fixture/sql.ts @@ -273,3 +273,7 @@ expectType>; expectType; expectType; expectType>; + +declare const aSqlInstance: Bun.SQL; +expectType(aSqlInstance.options.host).is(); // property exists in postgres/mysql/mariadb options +expectType(aSqlInstance.options.safeIntegers).is(); // property exits in sqlite options diff --git a/test/js/sql/adapter-env-var-precedence.test.ts b/test/js/sql/adapter-env-var-precedence.test.ts new file mode 100644 index 0000000000..4f3fa796d2 --- /dev/null +++ b/test/js/sql/adapter-env-var-precedence.test.ts @@ -0,0 +1,475 @@ +import { SQL } from "bun"; +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { isWindows } from "harness"; +import { unlinkSync } from "js/node/fs/export-star-from"; + +declare module "bun" { + namespace SQL { + export interface PostgresOrMySQLOptions { + sslMode?: number; + } + } +} + +describe("SQL adapter environment variable precedence", () => { + const originalEnv = { ...process.env }; + + // prettier-ignore + const SQL_ENV_VARS = [ + 'DATABASE_URL', 'DATABASEURL', + 'TLS_DATABASE_URL', + 'POSTGRES_URL', 'PGURL', 'PG_URL', + 'TLS_POSTGRES_DATABASE_URL', + 'MYSQL_URL', 'MYSQLURL', + 'TLS_MYSQL_DATABASE_URL', + 'MARIADB_URL', 'MARIADBURL', + 'TLS_MARIADB_DATABASE_URL', + 'SQLITE_URL', 'SQLITEURL', + 'PGHOST', 'PGUSER', 'PGPASSWORD', 'PGDATABASE', 'PGPORT', + 'MYSQL_HOST', 'MYSQL_USER', 'MYSQL_PASSWORD', 'MYSQL_DATABASE', 'MYSQL_PORT' + ]; + + beforeEach(() => { + for (const key of Object.keys(process.env).concat(...Object.keys(Bun.env), ...Object.keys(import.meta.env))) { + delete process.env[key]; + delete Bun.env[key]; + delete import.meta.env[key]; + } + + for (const key in originalEnv) { + process.env[key] = originalEnv[key]; + Bun.env[key] = originalEnv[key]; + import.meta.env[key] = originalEnv[key]; + } + + for (const key of SQL_ENV_VARS) { + delete process.env[key]; + delete Bun.env[key]; + delete import.meta.env[key]; + } + }); + + afterAll(() => { + for (const key of Object.keys(process.env).concat(...Object.keys(Bun.env), ...Object.keys(import.meta.env))) { + delete process.env[key]; + delete Bun.env[key]; + delete import.meta.env[key]; + } + + for (const key in originalEnv) { + process.env[key] = originalEnv[key]; + Bun.env[key] = originalEnv[key]; + import.meta.env[key] = originalEnv[key]; + } + + for (const key of SQL_ENV_VARS) { + delete process.env[key]; + delete Bun.env[key]; + delete import.meta.env[key]; + } + }); + + test("should not prioritize DATABASE_URL over explicit options (issue #22147)", () => { + process.env.DATABASE_URL = "foo_url"; + + const options = new SQL({ + hostname: "bar_url", + username: "postgres", + password: "postgres", + port: 5432, + }); + + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("bar_url"); + expect(options.options.port).toBe(5432); + expect(options.options.username).toBe("postgres"); + }); + + test("should only read PostgreSQL env vars when adapter is postgres", () => { + process.env.PGHOST = "pg-host"; + process.env.PGUSER = "pg-user"; + process.env.PGPASSWORD = "pg-pass"; + process.env.MYSQL_URL = "mysql://mysql-host/db"; + + const options = new SQL({ + adapter: "postgres", + }); + + expect(options.options.hostname).toBe("pg-host"); + expect(options.options.username).toBe("pg-user"); + expect(options.options.password).toBe("pg-pass"); + // Should not use MYSQL_URL + expect(options.options.hostname).not.toBe("mysql-host"); + }); + + test("should only read MySQL env vars when adapter is mysql", () => { + process.env.PGHOST = "pg-host"; + process.env.PGUSER = "pg-user"; + process.env.MYSQL_URL = "mysql://mysql-host/db"; + + const options = new SQL({ + adapter: "mysql", + }); + + // Should use MYSQL_URL and not read PostgreSQL env vars + expect(options.options.hostname).toBe("mysql-host"); + expect(options.options.username).not.toBe("pg-user"); + }); + + test("should infer postgres adapter from postgres:// protocol", () => { + const options = new SQL("postgres://user:pass@host:5432/db"); + expect(options.options.adapter).toBe("postgres"); + }); + + test("should infer mysql adapter from mysql:// protocol", () => { + const options = new SQL("mysql://user:pass@host:3306/db"); + expect(options.options.adapter).toBe("mysql"); + }); + + test("should default to postgres when no protocol specified", () => { + const options = new SQL("user:pass@host/db"); + expect(options.options.adapter).toBe("postgres"); + }); + + test("adapter-specific env vars should take precedence over generic ones", () => { + process.env.USER = "generic-user"; + process.env.PGUSER = "postgres-user"; + + const options = new SQL({ + adapter: "postgres", + }); + + expect(options.options.username).toBe("postgres-user"); + }); + + test("should infer mysql adapter from MYSQL_URL env var", () => { + process.env.MYSQL_URL = "mysql://user:pass@host:3306/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + }); + + test("should default to port 3306 for MySQL when no port specified", () => { + process.env.MYSQL_URL = "mysql://user:pass@host/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); // Should default to MySQL port + }); + + test("should default to port 3306 for explicit MySQL adapter", () => { + const options = new SQL({ + adapter: "mysql", + hostname: "localhost", + }); + + expect(options.options.adapter).toBe("mysql"); + expect(options.options.port).toBe(3306); // Should default to MySQL port + }); + + test("should infer postgres adapter from POSTGRES_URL env var", () => { + process.env.POSTGRES_URL = "postgres://user:pass@host:5432/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(5432); + }); + + test("POSTGRES_URL should take precedence over MYSQL_URL", () => { + process.env.POSTGRES_URL = "postgres://pg-host:5432/pgdb"; + process.env.MYSQL_URL = "mysql://mysql-host:3306/mysqldb"; + + const options = new SQL(); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("pg-host"); + expect(options.options.port).toBe(5432); + }); + + test("should infer mysql from MYSQL_URL even without protocol", () => { + process.env.MYSQL_URL = "root@localhost:3306/test"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("localhost"); + expect(options.options.port).toBe(3306); + expect(options.options.username).toBe("root"); + }); + + test("should infer postgres from POSTGRES_URL even without protocol", () => { + process.env.POSTGRES_URL = "user@localhost:5432/test"; + + const options = new SQL(); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("localhost"); + expect(options.options.port).toBe(5432); + expect(options.options.username).toBe("user"); + }); + + test("environment variable name should override protocol (PGURL with mysql protocol should be postgres)", () => { + process.env.PGURL = "mysql://host:3306/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + }); + + test("environment variable name should override protocol (MYSQL_URL with postgres protocol should be mysql)", () => { + process.env.MYSQL_URL = "postgres://host:5432/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(5432); + }); + test("should use MySQL-specific environment variables", () => { + process.env.MYSQL_HOST = "mysql-server"; + process.env.MYSQL_PORT = "3307"; + process.env.MYSQL_USER = "admin"; + process.env.MYSQL_PASSWORD = "secret"; + process.env.MYSQL_DATABASE = "production"; + + const options = new SQL({ adapter: "mysql" }); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("mysql-server"); + expect(options.options.port).toBe(3307); + expect(options.options.username).toBe("admin"); + expect(options.options.password).toBe("secret"); + expect(options.options.database).toBe("production"); + }); + + test("MySQL-specific env vars should take precedence over generic ones", () => { + process.env.USER = "generic-user"; + process.env.MYSQL_USER = "mysql-user"; + + const options = new SQL({ adapter: "mysql" }); + expect(options.options.username).toBe("mysql-user"); + }); + + test("should default to database name 'mysql' for MySQL adapter", () => { + const options = new SQL({ adapter: "mysql", hostname: "localhost" }); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.database).toBe("mysql"); + }); + + test("should default to username as database name for PostgreSQL adapter", () => { + const options = new SQL({ adapter: "postgres", hostname: "localhost", username: "testuser" }); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.database).toBe("testuser"); + }); + + test("should infer mysql adapter from TLS_MYSQL_DATABASE_URL", () => { + process.env.TLS_MYSQL_DATABASE_URL = "mysql://user:pass@host:3306/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + expect(options.options.sslMode).toBe(2); // SSLMode.require + }); + + test("should infer postgres adapter from TLS_POSTGRES_DATABASE_URL", () => { + process.env.TLS_POSTGRES_DATABASE_URL = "postgres://user:pass@host:5432/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("postgres"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(5432); + expect(options.options.sslMode).toBe(2); // SSLMode.require + }); + + test("should infer adapter from TLS_DATABASE_URL using protocol", () => { + process.env.TLS_DATABASE_URL = "mysql://user:pass@host:3306/db"; + + const options = new SQL(); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + expect(options.options.sslMode).toBe(2); // SSLMode.require + }); + + describe("Adapter-Protocol Validation", () => { + test("should work with explicit adapter and URL without protocol", () => { + const options = new SQL("user:pass@host:3306/db", { adapter: "mysql" }); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + }); + + test("should work with explicit adapter and matching protocol", () => { + const options = new SQL("mysql://user:pass@host:3306/db", { adapter: "mysql" }); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("host"); + expect(options.options.port).toBe(3306); + }); + + test.skipIf(isWindows)("should work with unix:// protocol and explicit adapter", () => { + using sock = Bun.listen({ + unix: "/tmp/thisisacoolmysql.sock", + socket: { + data: console.log, + }, + }); + + const options = new SQL(`unix://${sock.unix}`, { adapter: "mysql" }); + expect(options.options.adapter).toBe("mysql"); + expect(options.options.path).toBe("/tmp/thisisacoolmysql.sock"); + + unlinkSync(sock.unix); + }); + + test("should work with sqlite:// protocol and sqlite adapter", () => { + const options = new SQL("sqlite:///tmp/test.db", { adapter: "sqlite" }); + expect(options.options.adapter).toBe("sqlite"); + expect(options.options.filename).toBe("/tmp/test.db"); + }); + + test("should work with sqlite:// protocol without adapter", () => { + const options = new SQL("sqlite:///tmp/test.db"); + expect(options.options.adapter).toBe("sqlite"); + expect(options.options.filename).toBe("/tmp/test.db"); + }); + + describe("Explicit options override URL parameters", () => { + test("explicit hostname should override URL hostname", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + hostname: "explicithost", + }); + + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.port).toBe(1234); // URL port should remain + expect(options.options.username).toBe("urluser"); // URL username should remain + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("explicit port should override URL port", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + port: 5432, + }); + + expect(options.options.hostname).toBe("urlhost"); // URL hostname should remain + expect(options.options.port).toBe(5432); + expect(options.options.username).toBe("urluser"); // URL username should remain + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("explicit username should override URL username", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + username: "explicituser", + }); + + expect(options.options.hostname).toBe("urlhost"); // URL hostname should remain + expect(options.options.port).toBe(1234); // URL port should remain + expect(options.options.username).toBe("explicituser"); + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("explicit password should override URL password", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + password: "explicitpass", + }); + + expect(options.options.hostname).toBe("urlhost"); // URL hostname should remain + expect(options.options.port).toBe(1234); // URL port should remain + expect(options.options.username).toBe("urluser"); // URL username should remain + expect(options.options.password).toBe("explicitpass"); + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("explicit database should override URL database", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + database: "explicitdb", + }); + + expect(options.options.hostname).toBe("urlhost"); // URL hostname should remain + expect(options.options.port).toBe(1234); // URL port should remain + expect(options.options.username).toBe("urluser"); // URL username should remain + expect(options.options.database).toBe("explicitdb"); + }); + + test("multiple explicit options should override corresponding URL parameters", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + hostname: "explicithost", + port: 5432, + username: "explicituser", + password: "explicitpass", + database: "explicitdb", + }); + + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.port).toBe(5432); + expect(options.options.username).toBe("explicituser"); + expect(options.options.password).toBe("explicitpass"); + expect(options.options.database).toBe("explicitdb"); + }); + + test("should work with MySQL URLs and explicit options", () => { + const options = new SQL("mysql://urluser:urlpass@urlhost:3306/urldb", { + hostname: "explicithost", + port: 3307, + username: "explicituser", + }); + + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.port).toBe(3307); + expect(options.options.username).toBe("explicituser"); + expect(options.options.password).toBe("urlpass"); // URL password should remain + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("should work with alternative option names (user, pass, db, host)", () => { + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + host: "explicithost", + user: "explicituser", + pass: "explicitpass", + db: "explicitdb", + }); + + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.username).toBe("explicituser"); + expect(options.options.password).toBe("explicitpass"); + expect(options.options.database).toBe("explicitdb"); + }); + + test("explicit options should override URL even when environment variables are present", () => { + process.env.PGHOST = "envhost"; + process.env.PGPORT = "9999"; + process.env.PGUSER = "envuser"; + + const options = new SQL("postgres://urluser:urlpass@urlhost:1234/urldb", { + hostname: "explicithost", + port: 5432, + username: "explicituser", + }); + + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.port).toBe(5432); + expect(options.options.username).toBe("explicituser"); + expect(options.options.password).toBe("urlpass"); // URL password should remain since no explicit password + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + + test("explicit options should have higher precedence than environment-specific variables", () => { + process.env.MYSQL_HOST = "mysqlhost"; + process.env.MYSQL_USER = "mysqluser"; + process.env.MYSQL_PASSWORD = "mysqlpass"; + + const options = new SQL("mysql://urluser:urlpass@urlhost:3306/urldb", { + hostname: "explicithost", + username: "explicituser", + }); + + expect(options.options.adapter).toBe("mysql"); + expect(options.options.hostname).toBe("explicithost"); + expect(options.options.username).toBe("explicituser"); + expect(options.options.password).toBe("urlpass"); // URL password (not env) + expect(options.options.database).toBe("urldb"); // URL database should remain + }); + }); + }); +}); diff --git a/test/js/sql/sql-mysql.test.ts b/test/js/sql/sql-mysql.test.ts index cdce289482..42a5e5635b 100644 --- a/test/js/sql/sql-mysql.test.ts +++ b/test/js/sql/sql-mysql.test.ts @@ -44,7 +44,7 @@ if (docker) { env: image.env, }, (port: number) => { - const options = { + const options: Bun.SQL.Options = { url: `mysql://root:bun@localhost:${port}`, max: 1, tls: @@ -143,11 +143,13 @@ if (docker) { onconnect, onclose, }); - expect(await sql`select 123 as x`).toEqual([{ x: 123 }]); + expect<[{ x: number }]>(await sql`select 123 as x`).toEqual([{ x: 123 }]); expect(onconnect).toHaveBeenCalledTimes(1); expect(onclose).not.toHaveBeenCalled(); const err = await onClosePromise.promise; - expect(err.code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`); + expect(err).toBeInstanceOf(SQL.SQLError); + expect(err).toBeInstanceOf(SQL.MySQLError); + expect((err as SQL.MySQLError).code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`); }); test("Max lifetime works", async () => { @@ -162,8 +164,8 @@ if (docker) { onconnect, onclose, }); - let error: any; - expect(await sql`select 1 as x`).toEqual([{ x: 1 }]); + let error: unknown; + expect<[{ x: number }]>(await sql`select 1 as x`).toEqual([{ x: 1 }]); expect(onconnect).toHaveBeenCalledTimes(1); try { while (true) { @@ -177,7 +179,9 @@ if (docker) { expect(onclose).toHaveBeenCalledTimes(1); - expect(error.code).toBe(`ERR_MYSQL_LIFETIME_TIMEOUT`); + expect(error).toBeInstanceOf(SQL.SQLError); + expect(error).toBeInstanceOf(SQL.MySQLError); + expect((error as SQL.MySQLError).code).toBe(`ERR_MYSQL_LIFETIME_TIMEOUT`); }); // Last one wins. diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 9d7deee6d2..12d9189dc6 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -226,46 +226,62 @@ describe("Connection & Initialization", () => { await sql.close(); }); + }); - test("should NOT use PG_URL for SQLite", async () => { - Bun.env.PG_URL = "postgres://user:pass@localhost:5432/mydb"; + describe("options.url overrides first argument", () => { + test("should use options.url for postgres when it overrides first argument", () => { + const sql = new SQL("http://wrong-host/db", { + adapter: "postgres", + url: "postgres://correct-host:5432/mydb", + }); - const sql = new SQL({ adapter: "sqlite", filename: ":memory:" }); - expect(sql.options.adapter).toBe("sqlite"); - expect(sql.options.filename).toBe(":memory:"); - - await sql.close(); - }); - - test("should throw error when POSTGRES_URL is used without adapter specification", () => { - Bun.env.POSTGRES_URL = "postgres://user:pass@localhost:5432/mydb"; - Bun.env.DATABASE_URL = undefined; - - // This should create a postgres connection, not sqlite - const sql = new SQL(); expect(sql.options.adapter).toBe("postgres"); + expect(sql.options.hostname).toBe("correct-host"); + expect(sql.options.port).toBe(5432); + expect(sql.options.database).toBe("mydb"); + sql.close(); }); - test("should handle multiple env vars with precedence", async () => { - // Test precedence: POSTGRES_URL > DATABASE_URL > PGURL > PG_URL - Bun.env.PG_URL = "postgres://pg_url@localhost:5432/pg_db"; - Bun.env.PGURL = "postgres://pgurl@localhost:5432/pgurl_db"; - Bun.env.DATABASE_URL = "sqlite://:memory:"; - Bun.env.POSTGRES_URL = "postgres://postgres@localhost:5432/postgres_db"; + test("should use options.url for mysql when it overrides first argument", () => { + const sql = new SQL("http://wrong-host/wrongdb", { + adapter: "mysql", + url: "mysql://user:pass@mysql-host:3306/correctdb", + }); + + expect(sql.options.adapter).toBe("mysql"); + expect(sql.options.hostname).toBe("mysql-host"); + expect(sql.options.port).toBe(3306); + expect(sql.options.database).toBe("correctdb"); + + sql.close(); + }); + + test("should use options.url for mariadb when it overrides first argument", () => { + const sql = new SQL("http://wrong-host:1234/wrongdb", { + adapter: "mariadb", + url: "mariadb://maria-host:3307/mariadb", + }); + + expect(sql.options.adapter).toBe("mariadb"); + expect(sql.options.hostname).toBe("maria-host"); + expect(sql.options.port).toBe(3307); + expect(sql.options.database).toBe("mariadb"); + + sql.close(); + }); + + test("should use first argument when options.url is not provided", () => { + const sql = new SQL("postgres://first-arg-host:5432/firstdb", { + adapter: "postgres", + }); - const sql = new SQL(); - // POSTGRES_URL takes precedence expect(sql.options.adapter).toBe("postgres"); - await sql.close(); + expect(sql.options.hostname).toBe("first-arg-host"); + expect(sql.options.port).toBe(5432); + expect(sql.options.database).toBe("firstdb"); - // Remove POSTGRES_URL - delete Bun.env.POSTGRES_URL; - const sql2 = new SQL(); - // DATABASE_URL takes next precedence and it's SQLite (detected via :memory:) - expect(sql2.options.adapter).toBe("sqlite"); - expect(sql2.options.filename).toBe(":memory:"); - await sql2.close(); + sql.close(); }); }); @@ -579,14 +595,14 @@ describe("Connection & Initialization", () => { test("should handle sqlite: without path", () => { const sql = new SQL("sqlite:"); expect(sql.options.adapter).toBe("sqlite"); - expect(sql.options.filename).toBe(""); + expect(sql.options.filename).toBe(":memory:"); sql.close(); }); test("should handle sqlite:// without path", () => { const sql = new SQL("sqlite://"); expect(sql.options.adapter).toBe("sqlite"); - expect(sql.options.filename).toBe(""); + expect(sql.options.filename).toBe(":memory:"); sql.close(); }); @@ -671,7 +687,7 @@ describe("Connection & Initialization", () => { describe("Error Cases", () => { test("should throw for unsupported adapter", () => { expect(() => new SQL({ adapter: "mssql" as any })).toThrowErrorMatchingInlineSnapshot( - `"Unsupported adapter: mssql. Supported adapters: "postgres", "sqlite", "mysql""`, + `"Unsupported adapter: mssql. Supported adapters: "postgres", "sqlite", "mysql", "mariadb""`, ); }); diff --git a/test/js/sql/sqlite-url-parsing.test.ts b/test/js/sql/sqlite-url-parsing.test.ts index 006bd73d50..2ad42a02a7 100644 --- a/test/js/sql/sqlite-url-parsing.test.ts +++ b/test/js/sql/sqlite-url-parsing.test.ts @@ -22,7 +22,7 @@ describe("SQLite URL Parsing Matrix", () => { { input: "test@symbol.db", expected: "test@symbol.db", name: "@ in filename" }, { input: "test&.db", expected: "test&.db", name: "ampersand in filename" }, { input: "test%20encoded.db", expected: "test%20encoded.db", name: "percent encoding" }, - { input: "", expected: "", name: "empty path" }, + { input: "", expected: ":memory:", name: "empty path" }, ] as const; const testMatrix = protocols @@ -67,6 +67,10 @@ describe("SQLite URL Parsing Matrix", () => { // Not a valid file:// URL, so implementation just strips the prefix expected = testCase.url.slice(7); // "file://".length } + // Empty filename should default to :memory: + if (expected === "") { + expected = ":memory:"; + } expect(filename).toBe(expected); } else { expect(sql.options.filename).toBe(testCase.expected); diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 9257b1d3f4..88c0653b4a 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -34,6 +34,11 @@ for (const options of [ transactionPool: false, }, ] satisfies (Bun.SQL.Options & { transactionPool?: boolean })[]) { + if (options.url === undefined) { + console.log("SKIPPING TEST", JSON.stringify(options), "BECAUSE MISSING THE URL SECRET"); + continue; + } + describe(`${options.transactionPool ? "Transaction Pooling" : `Prepared Statements (${options.prepare ? "on" : "off"})`}`, () => { test("default sql", async () => { expect(sql.reserve).toBeDefined(); @@ -199,7 +204,7 @@ for (const options of [ expect( await sql .begin(sql => [sql`select wat`, sql`select current_setting('bun_sql.test') as x, ${1} as a`]) - .catch(e => e.errno || e), + .catch(e => e.errno), ).toBe("42703"); }); diff --git a/test/js/third_party/next-auth/next-auth.test.ts b/test/js/third_party/next-auth/next-auth.test.ts index bed77f2275..89c117547f 100644 --- a/test/js/third_party/next-auth/next-auth.test.ts +++ b/test/js/third_party/next-auth/next-auth.test.ts @@ -23,12 +23,30 @@ describe("next-auth", () => { }, }); + console.log("running bun install"); await runBunInstall(bunEnv, testDir, { savesLockfile: false }); - console.log(testDir); + console.log("starting server"); const result = bunRun(join(testDir, "server.js"), { AUTH_SECRET: "I7Jiq12TSMlPlAzyVAT+HxYX7OQb/TTqIbfTTpr1rg8=", }); + + try { + const stat = require("node:fs").statSync("/tmp/mysql.sock"); + console.log(stat); + } catch (e) { + console.log("couldnt stat mysql.sock", e); + } + + try { + const file = await Bun.file("/tmp/mysql.sock").arrayBuffer(); + console.log(file); + } catch (e) { + console.log("couldnt read mysql.sock", e); + } + + console.log(result.stdout); + console.log(result.stderr); expect(result.stderr).toBe(""); expect(result.stdout).toBeDefined(); const lines = result.stdout?.split("\n") ?? []; diff --git a/test/js/third_party/pg-gateway/pglite.test.ts b/test/js/third_party/pg-gateway/pglite.test.ts index 9c63350053..7b1fcfd976 100644 --- a/test/js/third_party/pg-gateway/pglite.test.ts +++ b/test/js/third_party/pg-gateway/pglite.test.ts @@ -2,25 +2,19 @@ import { PGlite } from "@electric-sql/pglite"; import { SQL, randomUUIDv7 } from "bun"; import { expect, test } from "bun:test"; import { once } from "events"; -import { isCI, isLinux } from "harness"; import net, { AddressInfo } from "node:net"; import { fromNodeSocket } from "pg-gateway/node"; -// TODO(@190n) linux-x64 sometimes fails due to JavaScriptCore bug -// https://github.com/oven-sh/bun/issues/17841#issuecomment-2695792567 -// https://bugs.webkit.org/show_bug.cgi?id=289009 -test.todoIf(isCI && isLinux && process.arch == "x64")( - "pglite should be able to query using pg-gateway and Bun.SQL", - async () => { - const name = "test_" + randomUUIDv7("hex").replaceAll("-", ""); - const dataDir = `memory://${name}`; - const db = new PGlite(dataDir); +test("pglite should be able to query using pg-gateway and Bun.SQL", async () => { + const name = "test_" + randomUUIDv7("hex").replaceAll("-", ""); + const dataDir = `memory://${name}`; + const db = new PGlite(dataDir); - // Wait for the database to initialize - await db.waitReady; + // Wait for the database to initialize + await db.waitReady; - // Create a simple test table - await db.exec(` + // Create a simple test table + await db.exec(` CREATE TABLE IF NOT EXISTS test_table ( id SERIAL PRIMARY KEY, name TEXT NOT NULL @@ -29,70 +23,74 @@ test.todoIf(isCI && isLinux && process.arch == "x64")( INSERT INTO test_table (name) VALUES ('Test 1'), ('Test 2'), ('Test 3'); `); - // Create a simple server using pg-gateway - const server = net.createServer(async socket => { - await fromNodeSocket(socket, { - serverVersion: "16.3", - auth: { - method: "trust", - }, - async onStartup() { - // Wait for PGlite to be ready before further processing - await db.waitReady; - }, - async onMessage(data, { isAuthenticated }: { isAuthenticated: boolean }) { - // Only forward messages to PGlite after authentication - if (!isAuthenticated) { - return; - } + // Create a simple server using pg-gateway + const server = net.createServer(async socket => { + await fromNodeSocket(socket, { + serverVersion: "16.3", + auth: { + method: "trust", + }, + async onStartup() { + // Wait for PGlite to be ready before further processing + await db.waitReady; + }, + async onMessage(data, { isAuthenticated }: { isAuthenticated: boolean }) { + // Only forward messages to PGlite after authentication + if (!isAuthenticated) { + return; + } - return await db.execProtocolRaw(data); - }, - }); + return await db.execProtocolRaw(data); + }, }); + }); - // Start listening - await once(server.listen(0), "listening"); + // Start listening + await once(server.listen(0), "listening"); - const port = (server.address() as AddressInfo).port; + const port = (server.address() as AddressInfo).port; - await using sql = new SQL({ - hostname: "localhost", - port: port, - database: name, - max: 1, - }); + await using sql = new SQL({ + hostname: "localhost", + port: port, + database: name, + max: 1, + }); - { - // prepared statement without parameters - const result = await sql`SELECT * FROM test_table WHERE id = 1`; - expect(result).toBeDefined(); - expect(result.length).toBe(1); - expect(result[0]).toEqual({ id: 1, name: "Test 1" }); - } + expect(sql.options.hostname).toBe("localhost"); + expect(sql.options.port).toBe(port); + expect(sql.options.database).toBe(name); + expect(sql.options.max).toBe(1); - { - // using prepared statement - const result = await sql`SELECT * FROM test_table WHERE id = ${1}`; - expect(result).toBeDefined(); - expect(result.length).toBe(1); - expect(result[0]).toEqual({ id: 1, name: "Test 1" }); - } + { + // prepared statement without parameters + const result = await sql`SELECT * FROM test_table WHERE id = 1`; + expect(result).toBeDefined(); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ id: 1, name: "Test 1" }); + } - { - // using simple query - const result = await sql`SELECT * FROM test_table WHERE id = 1`.simple(); - expect(result).toBeDefined(); - expect(result.length).toBe(1); - expect(result[0]).toEqual({ id: 1, name: "Test 1" }); - } + { + // using prepared statement + const result = await sql`SELECT * FROM test_table WHERE id = ${1}`; + expect(result).toBeDefined(); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ id: 1, name: "Test 1" }); + } - { - // using unsafe with parameters - const result = await sql.unsafe("SELECT * FROM test_table WHERE id = $1", [1]); - expect(result).toBeDefined(); - expect(result.length).toBe(1); - expect(result[0]).toEqual({ id: 1, name: "Test 1" }); - } - }, -); + { + // using simple query + const result = await sql`SELECT * FROM test_table WHERE id = 1`.simple(); + expect(result).toBeDefined(); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ id: 1, name: "Test 1" }); + } + + { + // using unsafe with parameters + const result = await sql.unsafe("SELECT * FROM test_table WHERE id = $1", [1]); + expect(result).toBeDefined(); + expect(result.length).toBe(1); + expect(result[0]).toEqual({ id: 1, name: "Test 1" }); + } +}); diff --git a/test/regression/issue/21311.test.ts b/test/regression/issue/21311.test.ts index 214ea70879..7b9cfb81cf 100644 --- a/test/regression/issue/21311.test.ts +++ b/test/regression/issue/21311.test.ts @@ -6,102 +6,90 @@ const databaseUrl = getSecret("TLS_POSTGRES_DATABASE_URL"); describe("postgres batch insert crash fix #21311", () => { test("should handle large batch inserts without crashing", async () => { - const sql = postgres(databaseUrl!); - try { - // Create a test table - await sql`DROP TABLE IF EXISTS test_batch_21311`; - await sql`CREATE TABLE test_batch_21311 ( + await using sql = postgres(databaseUrl!); + // Create a test table + await sql`DROP TABLE IF EXISTS test_batch_21311`; + await sql`CREATE TABLE test_batch_21311 ( id serial PRIMARY KEY, data VARCHAR(100) );`; - // Generate a large batch of data to insert - const batchSize = 100; - const values = Array.from({ length: batchSize }, (_, i) => `('batch_data_${i}')`).join(", "); + // Generate a large batch of data to insert + const batchSize = 100; + const values = Array.from({ length: batchSize }, (_, i) => `('batch_data_${i}')`).join(", "); - // This query would previously crash with "index out of bounds: index 0, len 0" - // on Windows when the fields metadata wasn't properly initialized - const insertQuery = `INSERT INTO test_batch_21311 (data) VALUES ${values} RETURNING id, data`; + // This query would previously crash with "index out of bounds: index 0, len 0" + // on Windows when the fields metadata wasn't properly initialized + const insertQuery = `INSERT INTO test_batch_21311 (data) VALUES ${values} RETURNING id, data`; - const results = await sql.unsafe(insertQuery); + const results = await sql.unsafe(insertQuery); - expect(results).toHaveLength(batchSize); - expect(results[0]).toHaveProperty("id"); - expect(results[0]).toHaveProperty("data"); - expect(results[0].data).toBe("batch_data_0"); - expect(results[batchSize - 1].data).toBe(`batch_data_${batchSize - 1}`); + expect(results).toHaveLength(batchSize); + expect(results[0]).toHaveProperty("id"); + expect(results[0]).toHaveProperty("data"); + expect(results[0].data).toBe("batch_data_0"); + expect(results[batchSize - 1].data).toBe(`batch_data_${batchSize - 1}`); - // Cleanup - await sql`DROP TABLE test_batch_21311`; - } finally { - await sql.end(); - } + // Cleanup + await sql`DROP TABLE test_batch_21311`; }); test("should handle empty result sets without crashing", async () => { - const sql = postgres(databaseUrl!); - try { - // Create a temporary table that will return no results - await sql`DROP TABLE IF EXISTS test_empty_21311`; - await sql`CREATE TABLE test_empty_21311 ( + await using sql = postgres(databaseUrl!); + // Create a temporary table that will return no results + await sql`DROP TABLE IF EXISTS test_empty_21311`; + await sql`CREATE TABLE test_empty_21311 ( id serial PRIMARY KEY, data VARCHAR(100) );`; - // Query that returns no rows - this tests the empty fields scenario - const results = await sql`SELECT * FROM test_empty_21311 WHERE id = -1`; + // Query that returns no rows - this tests the empty fields scenario + const results = await sql`SELECT * FROM test_empty_21311 WHERE id = -1`; - expect(results).toHaveLength(0); + expect(results).toHaveLength(0); - // Cleanup - await sql`DROP TABLE test_empty_21311`; - } finally { - await sql.end(); - } + // Cleanup + await sql`DROP TABLE test_empty_21311`; }); test("should handle mixed date formats in batch operations", async () => { - const sql = postgres(databaseUrl!); - try { - // Create test table - await sql`DROP TABLE IF EXISTS test_concurrent_21311`; - await sql`CREATE TABLE test_concurrent_21311 ( + await using sql = postgres(databaseUrl!); + // Create test table + await sql`DROP TABLE IF EXISTS test_concurrent_21311`; + await sql`CREATE TABLE test_concurrent_21311 ( id serial PRIMARY KEY, should_be_null INT, date DATE NULL );`; - // Run multiple concurrent batch operations - // This tests potential race conditions in field metadata setup - const concurrentOperations = Array.from({ length: 100 }, async (_, threadId) => { - const batchSize = 20; - const values = Array.from( - { length: batchSize }, - (_, i) => `(${i % 2 === 0 ? 1 : 0}, ${i % 2 === 0 ? "'infinity'::date" : "NULL"})`, - ).join(", "); + // Run multiple concurrent batch operations + // This tests potential race conditions in field metadata setup + const concurrentOperations = Array.from({ length: 100 }, async (_, threadId) => { + const batchSize = 20; + const values = Array.from( + { length: batchSize }, + (_, i) => `(${i % 2 === 0 ? 1 : 0}, ${i % 2 === 0 ? "'infinity'::date" : "NULL"})`, + ).join(", "); - const insertQuery = `INSERT INTO test_concurrent_21311 (should_be_null, date) VALUES ${values} RETURNING id, should_be_null, date`; - return sql.unsafe(insertQuery); - }); + const insertQuery = `INSERT INTO test_concurrent_21311 (should_be_null, date) VALUES ${values} RETURNING id, should_be_null, date`; + return sql.unsafe(insertQuery); + }); - await Promise.all(concurrentOperations); + await Promise.all(concurrentOperations); - // Run multiple concurrent queries + // Run multiple concurrent queries - const allQueryResults = await sql`SELECT * FROM test_concurrent_21311`; - allQueryResults.forEach((row, i) => { - expect(row.should_be_null).toBeNumber(); - if (row.should_be_null) { - expect(row.date).toBeDefined(); - expect(row.date?.getTime()).toBeNaN(); - } else { - expect(row.date).toBeNull(); - } - }); - // Cleanup - await sql`DROP TABLE test_concurrent_21311`; - } finally { - await sql.end(); - } + const allQueryResults = await sql`SELECT * FROM test_concurrent_21311`; + allQueryResults.forEach((row, i) => { + expect(row.should_be_null).toBeNumber(); + if (row.should_be_null) { + expect(row.date).toBeDefined(); + expect(row.date?.getTime()).toBeNaN(); + } else { + expect(row.date).toBeNull(); + } + }); + // Cleanup + await sql`DROP TABLE test_concurrent_21311`; }); });