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:
co-authored by
Claude Opus 5.5
parent
5a215744e5
commit
9aa475329a
@@ -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;
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user