import { generateId } from "better-auth"; import { getAuthTables } from "better-auth/db"; import type { Adapter, BetterAuthOptions, Where } from "better-auth/types"; import { jsonify, type RecordId } from "surrealdb"; import { Surreal } from "surrealdb"; import { withApplyDefault } from "./utils"; interface SurrealConfig { address: string; username: string; password: string; ns: string; db: string; } const createTransform = (options: BetterAuthOptions) => { const schema = getAuthTables(options); function transformSelect(select: string[], model: string): string[] { if (!select || select.length === 0) return []; return select.map((field) => getField(model, field)); } function getField(model: string, field: string) { if (field === "id") { return field; } const f = schema[model].fields[field]; return f.fieldName || field; } return { transformInput>( data: T, model: string, action: "update" | "create", ) { const transformedData: Record = action === "update" ? {} : { id: options.advanced?.generateId ? options.advanced.generateId({ model }) : data.id || generateId(), }; const fields = schema[model].fields; for (const field in fields) { const value = data[field]; if (value === undefined && !fields[field].defaultValue) { continue; } transformedData[fields[field].fieldName || field] = withApplyDefault( value, { ...fields[field], fieldName: fields[field].fieldName || field, }, action, model, ); } return transformedData; }, transformOutput>( data: T, model: string, select: string[] = [], ) { if (!data) return null; const transformedData: Record = data.id || data._id ? select.length === 0 || select.includes("id") ? { id: jsonify(data.id) } : {} : {}; const tableSchema = schema[model].fields; for (const key in tableSchema) { if (select.length && !select.includes(key)) { continue; } const field = tableSchema[key]; if (field) { transformedData[key] = jsonify(data[field.fieldName || key]); } } return transformedData as T; }, convertWhereClause(where: Where[], model: string) { return where .map((clause) => { const { field: _field, value, operator } = clause; const field = getField(model, _field); const v = value as unknown as RecordId; const isRecordId = !!v.tb; switch (operator) { case "eq": return field === "id" || isRecordId ? `${field} = ${jsonify(value)}` : `${field} = '${jsonify(value)}'`; case "in": return `${field} IN [${jsonify(value)}]`; case "contains": return `${field} CONTAINS '${jsonify(value)}'`; case "starts_with": return `string::starts_with(${field},'${value}')`; case "ends_with": return `string::ends_with(${field},'${value}')`; default: if (field.endsWith("Id") || isRecordId || field === "id") { return `${field} = ${jsonify(value)}`; } return `${field} = '${jsonify(value)}'`; } }) .join(" AND "); }, transformSelect, getField, }; }; export const surrealAdapter = (config: SurrealConfig) => (options: BetterAuthOptions): Adapter => { let db: Surreal | null = null; let isConnecting = false; let connectionPromise: Promise | null = null; const ensureConnection = async () => { if (db) { try { // Test if connection is still alive await db.query("SELECT * FROM user LIMIT 1"); return db; } catch (error) { console.error( "Connection is dead, resetting and reconnecting", error, ); // Connection is dead, reset and reconnect db = null; } } if (isConnecting && connectionPromise) { return connectionPromise; } isConnecting = true; connectionPromise = new Promise((resolve, reject) => { const newDb = new Surreal(); newDb .connect(config.address, { namespace: config.ns, database: config.db, auth: { username: config.username, password: config.password, }, }) .then(() => { db = newDb; isConnecting = false; connectionPromise = null; resolve(newDb); }) .catch((error) => { isConnecting = false; connectionPromise = null; reject(error); }); }); return connectionPromise; }; const { transformInput, transformOutput, convertWhereClause, getField } = createTransform(options); return { id: "surreal", create: async , R = T>({ model, data, }: { model: string; data: T }) => { const db = await ensureConnection(); const transformed = transformInput(data, model, "create"); const [result] = await db.create(model, transformed); return transformOutput(result, model) as R; }, findOne: async ({ model, where, select = [], }: { model: string; where: Where[]; select?: string[] }) => { const db = await ensureConnection(); const whereClause = convertWhereClause(where, model); const selectClause = (select.length > 0 && select.map((f) => getField(model, f))) || []; const query = select.length > 0 ? `SELECT ${selectClause.join(", ")} FROM ${model} WHERE ${whereClause} LIMIT 1` : `SELECT * FROM ${model} WHERE ${whereClause} LIMIT 1`; const result = await db.query<[Record[]]>(query); return transformOutput(result[0][0], model, select) as T | null; }, findMany: async ({ model, where, sortBy, limit, offset, }: { model: string; where?: Where[]; sortBy?: { field: string; direction: "asc" | "desc" }; limit?: number; offset?: number; }) => { const db = await ensureConnection(); let query = `SELECT * FROM ${model}`; if (where) { const whereClause = convertWhereClause(where, model); query += ` WHERE ${whereClause}`; } if (sortBy) { query += ` ORDER BY ${getField(model, sortBy.field)} ${sortBy.direction}`; } if (limit !== undefined) { query += ` LIMIT ${limit}`; } if (offset !== undefined) { query += ` START ${offset}`; } const [results] = await db.query<[Record[]]>(query); return results.map((record) => transformOutput(record, model) as T); }, count: async ({ model, where }: { model: string; where?: Where[] }) => { const db = await ensureConnection(); const whereClause = where ? convertWhereClause(where, model) : ""; const query = `SELECT count(${whereClause}) FROM ${model} GROUP ALL`; const [result] = await db.query<[Record[]]>(query); const res = result[0]; return Number(res.count); }, update: async , R = T>({ model, where, update, }: { model: string; where: Where[]; update: T }) => { const db = await ensureConnection(); const whereClause = convertWhereClause(where, model); const transformedUpdate = transformInput(update, model, "update"); const [result] = await db.query<[Record[]]>( `UPDATE ${model} MERGE $transformedUpdate WHERE ${whereClause}`, { transformedUpdate, }, ); return transformOutput(result[0], model) as R; }, delete: async ({ model, where }: { model: string; where: Where[] }) => { const db = await ensureConnection(); const whereClause = convertWhereClause(where, model); await db.query(`DELETE FROM ${model} WHERE ${whereClause}`); }, deleteMany: async ({ model, where, }: { model: string; where: Where[] }) => { const db = await ensureConnection(); const whereClause = convertWhereClause(where, model); const [result] = await db.query<[Record[]]>( `DELETE FROM ${model} WHERE ${whereClause}`, ); return result.length; }, updateMany: async , R = T>({ model, where, update, }: { model: string; where: Where[]; update: T }) => { const db = await ensureConnection(); const whereClause = convertWhereClause(where, model); const transformedUpdate = transformInput(update, model, "update"); const [result] = await db.query<[Record[]]>( `UPDATE ${model} MERGE $transformedUpdate WHERE ${whereClause}`, { transformedUpdate, }, ); return transformOutput(result[0], model) as R; }, } satisfies Adapter; };