From 115386dc5aa6d60447fec2b801bf76035126ddee Mon Sep 17 00:00:00 2001 From: ceo Date: Sat, 27 Dec 2025 19:38:27 +0900 Subject: [PATCH] feat(adapters): add Bun SQL adapter for PostgreSQL Add a native Bun SQL adapter using Bun's built-in PostgreSQL driver (`bun:sql`). Features: - Full CRUD operations (create, findOne, findMany, update, updateMany, delete, deleteMany, count) - Transaction support with automatic rollback on error - Support for all WHERE operators (eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with) - Support for numeric IDs (useNumberId option) - Support for JSON, dates, and booleans Test results: - 70/70 main tests passed - 60/60 number-id tests passed Also fixes a bug in create-test-suite.ts where transaction function was incorrectly referenced, causing transaction tests to fail on subsequent calls. --- docker-compose.yml | 14 +- packages/better-auth/package.json | 14 + .../bun-sql-adapter/bun-sql-adapter.ts | 319 ++++++++++++++++++ .../src/adapters/bun-sql-adapter/index.ts | 1 + .../test/adapter.bun-sql.pg.number-id.test.ts | 52 +++ .../test/adapter.bun-sql.pg.test.ts | 66 ++++ .../src/adapters/create-test-suite.ts | 4 +- 7 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 packages/better-auth/src/adapters/bun-sql-adapter/bun-sql-adapter.ts create mode 100644 packages/better-auth/src/adapters/bun-sql-adapter/index.ts create mode 100644 packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.number-id.test.ts create mode 100644 packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index f29df64bf0..9aa745f455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,17 @@ services: volumes: - mysql-prisma_data:/var/lib/mysql + postgres-bun-sql: + image: postgres:latest + container_name: postgres-bun-sql + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: better_auth + ports: + - "5435:5432" + volumes: + - postgres-bun-sql_data:/var/lib/postgresql mssql: image: mcr.microsoft.com/mssql/server:latest @@ -104,7 +115,8 @@ volumes: postgres_data: postgres-kysely_data: postgres-prisma_data: + postgres-bun-sql_data: mysql_data: mssql_data: mysql-kysely_data: - mysql-prisma_data: \ No newline at end of file + mysql-prisma_data: \ No newline at end of file diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index d175c3abd1..116a3ce448 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -317,6 +317,17 @@ "default": "./dist/adapters/memory-adapter/index.cjs" } }, + "./adapters/bun-sql": { + "better-auth-dev-source": "./src/adapters/bun-sql-adapter/index.ts", + "import": { + "types": "./dist/adapters/bun-sql-adapter/index.d.ts", + "default": "./dist/adapters/bun-sql-adapter/index.js" + }, + "require": { + "types": "./dist/adapters/bun-sql-adapter/index.d.cts", + "default": "./dist/adapters/bun-sql-adapter/index.cjs" + } + }, "./adapters/test": { "better-auth-dev-source": "./src/adapters/test.ts", "import": { @@ -670,6 +681,9 @@ "adapters/memory": [ "./dist/adapters/memory-adapter/index.d.ts" ], + "adapters/bun-sql": [ + "./dist/adapters/bun-sql-adapter/index.d.ts" + ], "plugins": [ "./dist/plugins/index.d.ts" ], diff --git a/packages/better-auth/src/adapters/bun-sql-adapter/bun-sql-adapter.ts b/packages/better-auth/src/adapters/bun-sql-adapter/bun-sql-adapter.ts new file mode 100644 index 0000000000..5307a85dff --- /dev/null +++ b/packages/better-auth/src/adapters/bun-sql-adapter/bun-sql-adapter.ts @@ -0,0 +1,319 @@ +import type { BetterAuthOptions } from "@better-auth/core"; +import type { + CleanedWhere, + DBAdapter, + DBAdapterDebugLogOption, +} from "@better-auth/core/db/adapter"; +import type { SQL } from "bun"; +import { + type AdapterFactoryCustomizeAdapterCreator, + type AdapterFactoryOptions, + createAdapterFactory, +} from "../adapter-factory"; + +export interface BunSqlAdapterConfig { + /** + * The Bun SQL instance to use for database operations. + */ + sql: SQL; + /** + * Enable debug logs for the adapter + * + * @default false + */ + debugLogs?: DBAdapterDebugLogOption; + /** + * Use plural for table names. + * + * @default false + */ + usePlural?: boolean; + /** + * Whether to execute multiple operations in a transaction. + * + * @default false + */ + transaction?: boolean; +} + +export const bunSqlAdapter = (config: BunSqlAdapterConfig) => { + const { sql } = config; + let lazyOptions: BetterAuthOptions | null = null; + + const createCustomAdapter = ( + sqlInstance: SQL, + ): AdapterFactoryCustomizeAdapterCreator => { + return ({ getFieldName }) => { + function buildWhereClause( + model: string, + where: CleanedWhere[] | undefined, + startIdx: number, + ): { whereClause: string; whereValues: unknown[] } { + if (!where || where.length === 0) { + return { whereClause: "", whereValues: [] }; + } + + const conditions: string[] = []; + const values: unknown[] = []; + let idx = startIdx; + + for (let i = 0; i < where.length; i++) { + const condition = where[i]!; + const { field: _field, value, operator = "eq", connector } = condition; + const field = getFieldName({ model, field: _field }); + + let conditionStr = ""; + + switch (operator) { + case "eq": + if (value === null) { + conditionStr = `"${field}" IS NULL`; + } else { + conditionStr = `"${field}" = $${idx++}`; + values.push(value); + } + break; + case "ne": + if (value === null) { + conditionStr = `"${field}" IS NOT NULL`; + } else { + conditionStr = `"${field}" != $${idx++}`; + values.push(value); + } + break; + case "gt": + conditionStr = `"${field}" > $${idx++}`; + values.push(value); + break; + case "gte": + conditionStr = `"${field}" >= $${idx++}`; + values.push(value); + break; + case "lt": + conditionStr = `"${field}" < $${idx++}`; + values.push(value); + break; + case "lte": + conditionStr = `"${field}" <= $${idx++}`; + values.push(value); + break; + case "in": + if (Array.isArray(value)) { + if (value.length === 0) { + // Empty array: nothing can match, return no rows + conditionStr = "FALSE"; + } else { + const inPlaceholders = value + .map(() => `$${idx++}`) + .join(", "); + conditionStr = `"${field}" IN (${inPlaceholders})`; + values.push(...value); + } + } + break; + case "not_in": + if (Array.isArray(value)) { + if (value.length === 0) { + // Empty array: nothing to exclude, match all (skip condition) + } else { + const notInPlaceholders = value + .map(() => `$${idx++}`) + .join(", "); + conditionStr = `"${field}" NOT IN (${notInPlaceholders})`; + values.push(...value); + } + } + break; + case "contains": + conditionStr = `"${field}" LIKE $${idx++}`; + values.push(`%${value}%`); + break; + case "starts_with": + conditionStr = `"${field}" LIKE $${idx++}`; + values.push(`${value}%`); + break; + case "ends_with": + conditionStr = `"${field}" LIKE $${idx++}`; + values.push(`%${value}`); + break; + default: + conditionStr = `"${field}" = $${idx++}`; + values.push(value); + } + + if (conditionStr) { + if (i === 0) { + conditions.push(conditionStr); + } else { + const logicalOp = connector === "OR" ? "OR" : "AND"; + conditions.push(`${logicalOp} ${conditionStr}`); + } + } + } + + return { + whereClause: + conditions.length > 0 ? `WHERE ${conditions.join(" ")}` : "", + whereValues: values, + }; + } + + return { + async create({ model, data }) { + const record = data as Record; + const columns = Object.keys(record); + const values = Object.values(record); + const placeholders = columns.map((_, i) => `$${i + 1}`).join(", "); + + const result = await sqlInstance.unsafe( + `INSERT INTO "${model}" (${columns.map((c) => `"${c}"`).join(", ")}) + VALUES (${placeholders}) + RETURNING *`, + values, + ); + return result[0]; + }, + + async findOne({ model, where }) { + const { whereClause, whereValues } = buildWhereClause(model, where, 1); + const result = await sqlInstance.unsafe( + `SELECT * FROM "${model}" ${whereClause} LIMIT 1`, + whereValues, + ); + return result[0] ?? null; + }, + + async findMany({ model, where, limit, sortBy, offset }) { + const { whereClause, whereValues } = buildWhereClause(model, where, 1); + let query = `SELECT * FROM "${model}" ${whereClause}`; + + if (sortBy) { + const field = getFieldName({ model, field: sortBy.field }); + const dir = sortBy.direction === "desc" ? "DESC" : "ASC"; + query += ` ORDER BY "${field}" ${dir}`; + } + if (limit) query += ` LIMIT ${limit}`; + if (offset) query += ` OFFSET ${offset}`; + + return await sqlInstance.unsafe(query, whereValues); + }, + + async update({ model, where, update }) { + const setClauses: string[] = []; + const values: unknown[] = []; + let idx = 1; + + for (const [key, value] of Object.entries( + update as Record, + )) { + const field = getFieldName({ model, field: key }); + setClauses.push(`"${field}" = $${idx++}`); + values.push(value); + } + + const { whereClause, whereValues } = buildWhereClause( + model, + where, + idx, + ); + values.push(...whereValues); + + const result = await sqlInstance.unsafe( + `UPDATE "${model}" SET ${setClauses.join(", ")} ${whereClause} RETURNING *`, + values, + ); + return result[0] ?? null; + }, + + async updateMany({ model, where, update }) { + const setClauses: string[] = []; + const values: unknown[] = []; + let idx = 1; + + for (const [key, value] of Object.entries( + update as Record, + )) { + const field = getFieldName({ model, field: key }); + setClauses.push(`"${field}" = $${idx++}`); + values.push(value); + } + + const { whereClause, whereValues } = buildWhereClause( + model, + where, + idx, + ); + values.push(...whereValues); + + const result = await sqlInstance.unsafe( + `UPDATE "${model}" SET ${setClauses.join(", ")} ${whereClause}`, + values, + ); + return result.count; + }, + + async delete({ model, where }) { + const { whereClause, whereValues } = buildWhereClause(model, where, 1); + await sqlInstance.unsafe( + `DELETE FROM "${model}" ${whereClause}`, + whereValues, + ); + }, + + async deleteMany({ model, where }) { + const { whereClause, whereValues } = buildWhereClause(model, where, 1); + const result = await sqlInstance.unsafe( + `DELETE FROM "${model}" ${whereClause}`, + whereValues, + ); + return result.count; + }, + + async count({ model, where }) { + const { whereClause, whereValues } = buildWhereClause(model, where, 1); + const result = await sqlInstance.unsafe( + `SELECT COUNT(*)::int as count FROM "${model}" ${whereClause}`, + whereValues, + ); + return result[0]?.count ?? 0; + }, + + options: config, + }; + }; + }; + + let adapterOptions: AdapterFactoryOptions | null = null; + + adapterOptions = { + config: { + adapterId: "bun-sql", + adapterName: "Bun SQL Adapter", + usePlural: config.usePlural, + debugLogs: config.debugLogs, + supportsJSON: true, + supportsDates: true, + supportsBooleans: true, + supportsNumericIds: true, + transaction: config.transaction + ? (cb) => + sql.begin(async (trx) => { + const txAdapter = createCustomAdapter(trx as unknown as SQL); + const adapter = createAdapterFactory({ + config: { ...adapterOptions!.config, transaction: false }, + adapter: txAdapter, + })(lazyOptions!); + return await cb(adapter); + }) + : false, + }, + adapter: createCustomAdapter(sql), + }; + + const adapter = createAdapterFactory(adapterOptions); + + return (options: BetterAuthOptions): DBAdapter => { + lazyOptions = options; + return adapter(options); + }; +}; diff --git a/packages/better-auth/src/adapters/bun-sql-adapter/index.ts b/packages/better-auth/src/adapters/bun-sql-adapter/index.ts new file mode 100644 index 0000000000..17e60a0b0b --- /dev/null +++ b/packages/better-auth/src/adapters/bun-sql-adapter/index.ts @@ -0,0 +1 @@ +export { bunSqlAdapter, type BunSqlAdapterConfig } from "./bun-sql-adapter"; diff --git a/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.number-id.test.ts b/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.number-id.test.ts new file mode 100644 index 0000000000..7077155a2f --- /dev/null +++ b/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.number-id.test.ts @@ -0,0 +1,52 @@ +import type { BetterAuthOptions } from "@better-auth/core"; +import { SQL } from "bun"; +import { Pool } from "pg"; +import { getMigrations } from "../../../db"; +import { testAdapter } from "../../test-adapter"; +import { numberIdTestSuite } from "../../tests"; +import { bunSqlAdapter } from "../bun-sql-adapter"; + +// Use pg Pool for migrations (getMigrations uses Kysely internally) +const pgPool = new Pool({ + connectionString: "postgres://user:password@localhost:5435/better_auth", +}); + +// Use Bun SQL for the actual adapter operations +// prepare: false disables prepared statement caching which causes +// "cached plan must not change result type" errors when schema changes mid-test +const bunSql = new SQL({ + hostname: "localhost", + port: 5435, + database: "better_auth", + username: "user", + password: "password", + prepare: false, +}); + +const cleanupDatabase = async () => { + await pgPool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); +}; + +const { execute } = await testAdapter({ + adapter: () => + bunSqlAdapter({ + sql: bunSql, + debugLogs: { isRunningAdapterTests: true }, + }), + prefixTests: "pg", + async runMigrations(betterAuthOptions) { + await cleanupDatabase(); + const opts = Object.assign(betterAuthOptions, { + database: pgPool, + } satisfies BetterAuthOptions); + const { runMigrations } = await getMigrations(opts); + await runMigrations(); + }, + tests: [numberIdTestSuite()], + async onFinish() { + await pgPool.end(); + bunSql.close(); + }, +}); + +execute(); diff --git a/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.test.ts b/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.test.ts new file mode 100644 index 0000000000..bdd7cba360 --- /dev/null +++ b/packages/better-auth/src/adapters/bun-sql-adapter/test/adapter.bun-sql.pg.test.ts @@ -0,0 +1,66 @@ +import type { BetterAuthOptions } from "@better-auth/core"; +import { SQL } from "bun"; +import { Pool } from "pg"; +import { getMigrations } from "../../../db"; +import { testAdapter } from "../../test-adapter"; +import { + authFlowTestSuite, + normalTestSuite, + numberIdTestSuite, + performanceTestSuite, + transactionsTestSuite, +} from "../../tests"; +import { bunSqlAdapter } from "../bun-sql-adapter"; + +// Use pg Pool for migrations (getMigrations uses Kysely internally) +const pgPool = new Pool({ + connectionString: "postgres://user:password@localhost:5435/better_auth", +}); + +// Use Bun SQL for the actual adapter operations +// prepare: false disables prepared statement caching which causes +// "cached plan must not change result type" errors when schema changes mid-test +const bunSql = new SQL({ + hostname: "localhost", + port: 5435, + database: "better_auth", + username: "user", + password: "password", + prepare: false, +}); + +const cleanupDatabase = async () => { + await pgPool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); +}; + +const { execute } = await testAdapter({ + adapter: () => + bunSqlAdapter({ + sql: bunSql, + debugLogs: { isRunningAdapterTests: true }, + transaction: true, + }), + prefixTests: "pg", + async runMigrations(betterAuthOptions) { + await cleanupDatabase(); + const opts = Object.assign(betterAuthOptions, { + database: pgPool, + } satisfies BetterAuthOptions); + const { runMigrations } = await getMigrations(opts); + await runMigrations(); + }, + tests: [ + normalTestSuite(), + transactionsTestSuite(), + authFlowTestSuite(), + // number-id tests are in a separate file (adapter.bun-sql.pg.number-id.test.ts) + // to avoid Bun SQL's prepared statement cache conflicts when schema changes + performanceTestSuite({ dialect: "pg" }), + ], + async onFinish() { + await pgPool.end(); + bunSql.close(); + }, +}); + +execute(); diff --git a/packages/better-auth/src/adapters/create-test-suite.ts b/packages/better-auth/src/adapters/create-test-suite.ts index b79a1b5117..f2fa37d16e 100644 --- a/packages/better-auth/src/adapters/create-test-suite.ts +++ b/packages/better-auth/src/adapters/create-test-suite.ts @@ -181,7 +181,9 @@ export const createTestSuite = < createAdapterFactory({ config: { ...adapterConfig, - transaction: adapter.transaction, + // Use adapterConfig.transaction instead of adapter.transaction + // because adapter.transaction gets set to undefined after first call + transaction: adapter.options?.adapterConfig.transaction, }, adapter: ({ getDefaultModelName }) => { adapter.transaction = undefined as any;