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.
This commit is contained in:
ceo
2025-12-27 19:38:27 +09:00
parent 65ce45322f
commit 115386dc5a
7 changed files with 468 additions and 2 deletions

View File

@@ -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:
mysql-prisma_data:

View File

@@ -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"
],

View File

@@ -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<string, unknown>;
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<string, unknown>,
)) {
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<string, unknown>,
)) {
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<BetterAuthOptions> => {
lazyOptions = options;
return adapter(options);
};
};

View File

@@ -0,0 +1 @@
export { bunSqlAdapter, type BunSqlAdapterConfig } from "./bun-sql-adapter";

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;