From 9aa475329a0f31a3c868804a01edcb9d45cbc5e9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:17:44 +0100 Subject: [PATCH] feat(learner): Better Auth adapter over Bun.SQL for SQLite and Postgres Co-Authored-By: Claude Opus 5.5 --- .../src/server/auth/adapter.ts | 197 ++++++++++++++++++ packages/feature-learner/test/adapter.test.ts | 79 +++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/feature-learner/src/server/auth/adapter.ts create mode 100644 packages/feature-learner/test/adapter.test.ts diff --git a/packages/feature-learner/src/server/auth/adapter.ts b/packages/feature-learner/src/server/auth/adapter.ts new file mode 100644 index 0000000..758deac --- /dev/null +++ b/packages/feature-learner/src/server/auth/adapter.ts @@ -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; + +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 => { + const rows = await db.sql.unsafe(text, params as never[]); + return Array.from(rows as Iterable); + }; + const runCount = async (text: string, params: unknown[]): Promise => { + 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; + }, + }), + }); +} diff --git a/packages/feature-learner/test/adapter.test.ts b/packages/feature-learner/test/adapter.test.ts new file mode 100644 index 0000000..c86d5cc --- /dev/null +++ b/packages/feature-learner/test/adapter.test.ts @@ -0,0 +1,79 @@ +import { expect } from "bun:test"; +import type { Db } from "@stc/db"; +import { bunSqlAdapter } from "../src/server/auth/adapter"; +import { forEachDb } from "./helpers"; + +// biome-ignore lint/suspicious/noExplicitAny: adapter is generic over Better Auth options +const make = (db: Db): any => bunSqlAdapter(db)({}); + +async function seed(a: ReturnType) { + const people = [ + { name: "Ada", email: "ada@example.com", emailVerified: true }, + { name: "Bob", email: "bob@Example.org", emailVerified: false }, + { name: "Cyd", email: "cyd@example.com", emailVerified: false }, + ]; + const out = []; + for (const p of people) out.push(await a.create({ model: "user", data: { ...p, createdAt: new Date(), updatedAt: new Date() } })); + return out; +} + +forEachDb("adapter create and findOne round-trip types", async (db) => { + const a = make(db); + const [ada] = await seed(a); + expect(typeof ada.id).toBe("string"); + const found = await a.findOne({ model: "user", where: [{ field: "email", value: "ada@example.com" }] }); + expect(found.name).toBe("Ada"); + expect(found.emailVerified).toBe(true); + expect(found.createdAt).toBeInstanceOf(Date); + expect(await a.findOne({ model: "user", where: [{ field: "email", value: "nobody@x" }] })).toBeNull(); +}); + +forEachDb("adapter where operators", async (db) => { + const a = make(db); + await seed(a); + const names = async (where: unknown[], extra: Record = {}) => + (await a.findMany({ model: "user", where, sortBy: { field: "name", direction: "asc" }, ...extra })).map((u: { name: string }) => u.name); + expect(await names([{ field: "name", operator: "ne", value: "Ada" }])).toEqual(["Bob", "Cyd"]); + expect(await names([{ field: "name", operator: "in", value: ["Ada", "Cyd"] }])).toEqual(["Ada", "Cyd"]); + expect(await names([{ field: "name", operator: "not_in", value: ["Ada"] }])).toEqual(["Bob", "Cyd"]); + expect(await names([{ field: "name", operator: "in", value: [] }])).toEqual([]); + expect(await names([{ field: "email", operator: "contains", value: "example" }])).toEqual(["Ada", "Cyd"]); + expect(await names([{ field: "email", operator: "contains", value: "Example" }])).toEqual(["Bob"]); + expect(await names([{ field: "email", operator: "contains", value: "EXAMPLE.ORG", mode: "insensitive" }])).toEqual(["Bob"]); + expect(await names([{ field: "email", operator: "starts_with", value: "cy" }])).toEqual(["Cyd"]); + expect(await names([{ field: "email", operator: "ends_with", value: ".com" }])).toEqual(["Ada", "Cyd"]); + expect(await names([{ field: "email", value: "BOB@example.org", mode: "insensitive" }])).toEqual(["Bob"]); + expect(await names([{ field: "name", operator: "gt", value: "Ada" }])).toEqual(["Bob", "Cyd"]); + expect(await names([{ field: "name", operator: "lte", value: "Bob" }])).toEqual(["Ada", "Bob"]); + expect(await names([{ field: "emailVerified", value: false }])).toEqual(["Bob", "Cyd"]); + // AND clauses AND-ed, OR clauses grouped + expect(await names([ + { field: "emailVerified", value: false }, + { field: "name", value: "Ada", connector: "OR" }, + { field: "name", value: "Cyd", connector: "OR" }, + ])).toEqual(["Cyd"]); + expect(await names([], { limit: 1, offset: 1 })).toEqual(["Bob"]); + expect(await names([], { limit: 2, sortBy: { field: "name", direction: "desc" } })).toEqual(["Cyd", "Bob"]); + expect(await names([{ field: "image", value: null }])).toEqual(["Ada", "Bob", "Cyd"]); +}); + +forEachDb("adapter update, count, delete, consume, increment", async (db) => { + const a = make(db); + const [ada, bob] = await seed(a); + const up = await a.update({ model: "user", where: [{ field: "id", value: ada.id }], update: { name: "Ada L." } }); + expect(up.name).toBe("Ada L."); + expect(await a.updateMany({ model: "user", where: [{ field: "emailVerified", value: false }], update: { image: "x.png" } })).toBe(2); + expect(await a.count({ model: "user", where: [{ field: "image", value: "x.png" }] })).toBe(2); + expect(await a.count({ model: "user" })).toBe(3); + await a.delete({ model: "user", where: [{ field: "id", value: bob.id }] }); + expect(await a.count({ model: "user" })).toBe(2); + expect(await a.deleteMany({ model: "user", where: [{ field: "image", value: "x.png" }] })).toBe(1); + + const exp = new Date(Date.now() + 60_000); + await a.create({ model: "verification", data: { identifier: "k", value: "v1", expiresAt: exp, createdAt: new Date(), updatedAt: new Date() } }); + const consumed = await a.consumeOne({ model: "verification", where: [{ field: "identifier", value: "k" }] }); + expect(consumed?.value).toBe("v1"); + expect(await a.consumeOne({ model: "verification", where: [{ field: "identifier", value: "k" }] })).toBeNull(); + const live = await a.findMany({ model: "verification", where: [{ field: "expiresAt", operator: "gt", value: new Date() }] }); + expect(live.length).toBe(0); +});