feat(learner): Better Auth adapter over Bun.SQL for SQLite and Postgres

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:17:44 +01:00
co-authored by Claude Opus 5.5
parent 5a215744e5
commit 9aa475329a
2 changed files with 276 additions and 0 deletions
@@ -0,0 +1,197 @@
import { createAdapterFactory, type CleanedWhere } from "better-auth/adapters";
import type { Db } from "@stc/db";
/**
* Better Auth database adapter over `Bun.SQL`.
*
* Better Auth's built-in SQL support goes through Kysely, which has no `Bun.SQL` dialect, and its
* Postgres path needs `pg` (banned here). `@stc/db` already gives one `Bun.SQL` handle for SQLite and
* Postgres, so this adapter writes portable SQL against it:
* - model `ssoProvider` → table `auth_sso_provider`, field `emailVerified` → column `email_verified`;
* - dates are ISO-8601 TEXT, booleans INTEGER 0/1, JSON TEXT (Better Auth converts them for us);
* - no transactions: the SQLite handle has a single connection, and a hook issuing a query while a
* transaction holds it would deadlock. Better Auth then runs the steps sequentially.
*/
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const snake = (s: string): string => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
export const camel = (s: string): string => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
function ident(name: string): string {
if (!IDENT.test(name)) throw new Error(`unsafe SQL identifier: ${name}`);
return `"${name}"`;
}
const table = (model: string) => ident(`auth_${snake(model)}`);
const col = (field: string) => ident(snake(field));
type Row = Record<string, unknown>;
function fromRow(r: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(r)) out[camel(k)] = typeof v === "bigint" ? Number(v) : v;
return out;
}
function toValue(v: unknown): unknown {
if (v instanceof Date) return v.toISOString();
if (typeof v === "boolean") return v ? 1 : 0;
if (v !== null && typeof v === "object" && !Array.isArray(v)) return JSON.stringify(v);
return v === undefined ? null : v;
}
class Query {
params: unknown[] = [];
constructor(private dialect: Db["dialect"]) {}
p(v: unknown): string {
this.params.push(toValue(v));
return this.dialect === "postgres" ? `$${this.params.length}` : "?";
}
clause(w: CleanedWhere): string {
const c = col(w.field);
const op = w.operator ?? "eq";
const ci = w.mode === "insensitive";
const lhs = ci ? `LOWER(${c})` : c;
const val = (v: unknown) => (ci && typeof v === "string" ? this.p(v.toLowerCase()) : this.p(v));
const v = w.value;
switch (op) {
case "eq": return v === null ? `${c} IS NULL` : `${lhs} = ${val(v)}`;
case "ne": return v === null ? `${c} IS NOT NULL` : `(${lhs} <> ${val(v)} OR ${c} IS NULL)`;
case "lt": return `${c} < ${this.p(v)}`;
case "lte": return `${c} <= ${this.p(v)}`;
case "gt": return `${c} > ${this.p(v)}`;
case "gte": return `${c} >= ${this.p(v)}`;
case "in":
case "not_in": {
const list = Array.isArray(v) ? (v as unknown[]) : [v];
if (list.length === 0) return op === "in" ? "1 = 0" : "1 = 1";
return `${lhs} ${op === "in" ? "IN" : "NOT IN"} (${list.map((x) => val(x)).join(", ")})`;
}
case "contains": {
const s = String(v);
const needle = ci ? s.toLowerCase() : s;
return this.dialect === "postgres" ? `strpos(${lhs}, ${this.p(needle)}) > 0` : `instr(${lhs}, ${this.p(needle)}) > 0`;
}
case "starts_with": {
const s = ci ? String(v).toLowerCase() : String(v);
return `substr(${lhs}, 1, ${this.p(s.length)}) = ${this.p(s)}`;
}
case "ends_with": {
const s = ci ? String(v).toLowerCase() : String(v);
return `(length(${c}) >= ${this.p(s.length)} AND substr(${lhs}, length(${c}) - ${this.p(s.length)} + 1) = ${this.p(s)})`;
}
default:
throw new Error(`unsupported where operator: ${op as string}`);
}
}
/** AND clauses joined with AND, OR clauses grouped together (same as Better Auth's Kysely adapter). */
where(where: CleanedWhere[] | undefined): string {
if (!where || where.length === 0) return "";
const and = where.filter((w) => w.connector !== "OR").map((w) => this.clause(w));
const or = where.filter((w) => w.connector === "OR").map((w) => this.clause(w));
const parts = [...and];
if (or.length) parts.push(`(${or.join(" OR ")})`);
return ` WHERE ${parts.join(" AND ")}`;
}
}
export function bunSqlAdapter(db: Db) {
const run = async (text: string, params: unknown[]): Promise<Row[]> => {
const rows = await db.sql.unsafe(text, params as never[]);
return Array.from(rows as Iterable<Row>);
};
const runCount = async (text: string, params: unknown[]): Promise<number> => {
const res = await db.sql.unsafe(text, params as never[]);
return Number((res as { count?: number }).count ?? 0);
};
const q = () => new Query(db.dialect);
const selectList = (select?: string[]) => (select && select.length ? select.map(col).join(", ") : "*");
return createAdapterFactory({
config: {
adapterId: "bun-sql",
adapterName: "Bun.SQL adapter",
usePlural: false,
supportsDates: false,
supportsBooleans: false,
supportsJSON: false,
supportsArrays: false,
supportsNumericIds: false,
transaction: false,
},
adapter: () => ({
async create({ model, data, select }) {
const qq = q();
const keys = Object.keys(data).filter((k) => (data as Row)[k] !== undefined);
const vals = keys.map((k) => qq.p((data as Row)[k]));
const rows = await run(
`INSERT INTO ${table(model)} (${keys.map(col).join(", ")}) VALUES (${vals.join(", ")}) RETURNING ${selectList(select)}`,
qq.params,
);
return fromRow(rows[0]!) as never;
},
async findOne({ model, where, select }) {
const qq = q();
const w = qq.where(where);
const rows = await run(`SELECT ${selectList(select)} FROM ${table(model)}${w} LIMIT 1`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async findMany({ model, where, limit, select, sortBy, offset }) {
const qq = q();
let text = `SELECT ${selectList(select)} FROM ${table(model)}${qq.where(where)}`;
if (sortBy) text += ` ORDER BY ${col(sortBy.field)} ${sortBy.direction === "desc" ? "DESC" : "ASC"}`;
if (limit !== undefined && limit !== null) text += ` LIMIT ${qq.p(limit)}`;
else if (offset) text += db.dialect === "sqlite" ? " LIMIT -1" : "";
if (offset) text += ` OFFSET ${qq.p(offset)}`;
return (await run(text, qq.params)).map(fromRow) as never;
},
async count({ model, where }) {
const qq = q();
const rows = await run(`SELECT COUNT(*) AS n FROM ${table(model)}${qq.where(where)}`, qq.params);
return Number(rows[0]?.n ?? 0);
},
async update({ model, where, update }) {
const qq = q();
const sets = Object.entries(update as Row).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`);
if (sets.length === 0) {
const q2 = q();
const rows = await run(`SELECT * FROM ${table(model)}${q2.where(where)} LIMIT 1`, q2.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
}
const rows = await run(`UPDATE ${table(model)} SET ${sets.join(", ")}${qq.where(where)} RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async updateMany({ model, where, update }) {
const qq = q();
const sets = Object.entries(update).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`);
if (sets.length === 0) return 0;
return runCount(`UPDATE ${table(model)} SET ${sets.join(", ")}${qq.where(where)}`, qq.params);
},
async delete({ model, where }) {
const qq = q();
await run(`DELETE FROM ${table(model)}${qq.where(where)}`, qq.params);
},
async deleteMany({ model, where }) {
const qq = q();
return runCount(`DELETE FROM ${table(model)}${qq.where(where)}`, qq.params);
},
async consumeOne({ model, where }) {
const qq = q();
const t = table(model);
const rows = await run(`DELETE FROM ${t} WHERE "id" IN (SELECT "id" FROM ${t}${qq.where(where)} LIMIT 1) RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
async incrementOne({ model, where, increment, set }) {
const qq = q();
const sets = [
...Object.entries(increment).map(([k, d]) => `${col(k)} = ${col(k)} + ${qq.p(d)}`),
...Object.entries(set ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${col(k)} = ${qq.p(v)}`),
];
const t = table(model);
const rows = await run(`UPDATE ${t} SET ${sets.join(", ")} WHERE "id" IN (SELECT "id" FROM ${t}${qq.where(where)} LIMIT 1) RETURNING *`, qq.params);
return (rows[0] ? fromRow(rows[0]) : null) as never;
},
}),
});
}