UNPKG

surrealdb-better-auth

Version:
478 lines (408 loc) 18.9 kB
import { createAdapterFactory, type DBAdapterDebugLogOption } from "better-auth/adapters"; import { jsonify, StringRecordId } from "surrealdb"; import { Surreal } from "surrealdb"; /** * SurrealDB adapter configuration */ export interface SurrealAdapterConfig { /** SurrealDB connection address (e.g., "http://localhost:8000") */ address: string; /** Database username */ username: string; /** Database password */ password: string; /** Namespace to use */ ns: string; /** Database to use */ db: string; /** Enable debug logging */ debugLogs?: DBAdapterDebugLogOption; /** Use plural table names */ usePlural?: boolean; } /** * SurrealDB adapter for Better-Auth * * @example * ```typescript * import { surrealAdapter } from "surrealdb-better-auth"; * * const auth = betterAuth({ * database: surrealAdapter({ * address: "http://localhost:8000", * username: "root", * password: "root", * ns: "test", * db: "test", * }), * }); * ``` */ export const surrealAdapter = (config: SurrealAdapterConfig) => { // Connection management let db: Surreal | null = null; let isConnecting = false; let connectionPromise: Promise<Surreal> | null = null; const ensureConnection = async (): Promise<Surreal> => { if (db) { try { // Test if connection is still alive await db.query("SELECT 1"); return db; } catch { // 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; }; /** * Check if a string looks like a SurrealDB RecordId (format: table:id) */ const isRecordIdString = (value: string): boolean => { // RecordId format: table:id where table is alphanumeric and id is alphanumeric/uuid // e.g., "user:abc123", "session:xyz", "account:microsoft-12345" const recordIdPattern = /^[a-zA-Z_][a-zA-Z0-9_]*:[a-zA-Z0-9_\-⟨⟩]+$/; return recordIdPattern.test(value); }; /** * Fields that are explicitly NOT RecordIds (OAuth/external identifiers) */ const nonRecordIdFields = new Set(["accountId", "providerId"]); /** * Create serialization functions with schema-aware RecordId detection */ const createSerializationFunctions = (recordIdFieldsMap: Map<string, Set<string>>) => { /** * Check if a field should be treated as a RecordId * Uses schema metadata when available, falls back to heuristics */ const isRecordIdField = (field: string, model?: string): boolean => { // id is always a RecordId if (field === "id") return true; // OAuth/external identifiers are never RecordIds if (nonRecordIdFields.has(field)) return false; // Check schema-based map if model is provided if (model && recordIdFieldsMap.has(model)) { return recordIdFieldsMap.get(model)!.has(field); } // Fallback: field ends with Id (for fields not in schema) return field.endsWith("Id"); }; /** * Serialize a value for SurrealQL queries * - RecordId strings (table:id format) in RecordId fields are NOT quoted * - Regular strings get JSON-quoted * - Dates get converted to ISO strings * - Arrays and objects use JSON.stringify * - Numbers and booleans are used as-is */ const serializeValue = (value: unknown, field?: string, model?: string): string => { if (value === undefined || value === null) { return "NONE"; } if (typeof value === "string") { // Only treat as RecordId if: // 1. The field is a known RecordId field // 2. AND the value looks like a RecordId if (field && isRecordIdField(field, model) && isRecordIdString(value)) { return value; } return JSON.stringify(value); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (value instanceof Date) { return `d"${value.toISOString()}"`; } if (Array.isArray(value)) { // Pass the field through so array elements are properly handled as RecordIds if needed return `[${value.map(v => serializeValue(v, field, model)).join(", ")}]`; } if (typeof value === "object" && value !== null) { // Check if it's a RecordId (has tb property) if ("tb" in value) { return jsonify(value); } return JSON.stringify(value); } return JSON.stringify(value); }; /** * Convert a where clause array to SurrealDB WHERE string */ const buildWhereClause = ( where: Array<{ field: string; value: unknown; operator?: string }>, model?: string ): string => { if (!where || where.length === 0) return ""; return where .map((clause) => { const field = clause.field; const value = clause.value; const operator = clause.operator || "eq"; // Handle null/undefined values if (value === undefined || value === null) { return `${field} = NONE`; } switch (operator) { case "eq": return `${field} = ${serializeValue(value, field, model)}`; case "ne": return `${field} != ${serializeValue(value, field, model)}`; case "gt": return `${field} > ${serializeValue(value, field, model)}`; case "gte": return `${field} >= ${serializeValue(value, field, model)}`; case "lt": return `${field} < ${serializeValue(value, field, model)}`; case "lte": return `${field} <= ${serializeValue(value, field, model)}`; case "in": return `${field} IN ${serializeValue(value, field, model)}`; case "contains": return `${field} CONTAINS ${serializeValue(value, field, model)}`; case "starts_with": return `string::starts_with(${field}, ${serializeValue(value, field, model)})`; case "ends_with": return `string::ends_with(${field}, ${serializeValue(value, field, model)})`; default: return `${field} = ${serializeValue(value, field, model)}`; } }) .join(" AND "); }; return { isRecordIdField, serializeValue, buildWhereClause }; }; /** * Transform field values for SurrealDB * - Convert *Id fields to StringRecordId (using schema-aware detection) */ const createTransformValueForDB = (isRecordIdField: (field: string, model?: string) => boolean) => { return (field: string, value: unknown, model?: string): unknown => { if (value === undefined || value === null) { return value; } // Convert foreign key fields to RecordId using schema-aware detection if (typeof value === "string" && isRecordIdField(field, model) && field !== "id") { // Extract the referenced model from field name (e.g., "userId" -> "user") const refModel = field.slice(0, -2); // Don't convert if it's already a RecordId string format if (!value.includes(":")) { return new StringRecordId(`${refModel}:${value}`); } return new StringRecordId(value); } return value; }; }; return createAdapterFactory({ config: { adapterId: "surrealdb", adapterName: "SurrealDB", debugLogs: config.debugLogs ?? false, usePlural: config.usePlural ?? false, supportsJSON: true, supportsDates: true, supportsBooleans: true, supportsNumericIds: false, // SurrealDB uses string RecordIds // Let the factory handle joins via multiple queries supportsJoin: false, }, adapter: ({ debugLog, schema }) => { // Build RecordId fields map from schema metadata // Fields with `references` property are foreign keys and should be RecordIds const recordIdFieldsMap = new Map<string, Set<string>>(); if (schema) { for (const [modelName, modelDef] of Object.entries(schema)) { const recordIdFields = new Set<string>(["id"]); const fields = (modelDef as { fields?: Record<string, { references?: { model: string } }> }).fields; if (fields) { for (const [fieldName, fieldDef] of Object.entries(fields)) { // Check if field has references (foreign key) if (fieldDef.references?.model) { recordIdFields.add(fieldName); } } } recordIdFieldsMap.set(modelName, recordIdFields); } } // Create schema-aware functions const { isRecordIdField, buildWhereClause } = createSerializationFunctions(recordIdFieldsMap); const transformValueForDB = createTransformValueForDB(isRecordIdField); return { create: async ({ model, data }) => { const conn = await ensureConnection(); // Transform foreign key references to RecordIds const transformedData: Record<string, unknown> = {}; for (const [key, value] of Object.entries(data)) { transformedData[key] = transformValueForDB(key, value, model); } debugLog?.("create", { model, data: transformedData }); const [result] = await conn.create(model, transformedData); if (!result) { throw new SurrealDBError("Failed to create record"); } // Transform output - convert RecordId to string const output: Record<string, unknown> = {}; for (const [key, value] of Object.entries(result)) { output[key] = jsonify(value); } return output; }, findOne: async ({ model, where }) => { const conn = await ensureConnection(); const whereClause = buildWhereClause(where, model); const query = `SELECT * FROM ${model} WHERE ${whereClause} LIMIT 1`; debugLog?.("findOne", { model, query }); const [results] = await conn.query<[Record<string, unknown>[]]>(query); const record = results?.[0]; if (!record) { return null; } // Transform output - convert RecordId to string const output: Record<string, unknown> = {}; for (const [key, value] of Object.entries(record)) { output[key] = jsonify(value); } return output; }, findMany: async ({ model, where, limit, offset, sortBy }) => { const conn = await ensureConnection(); let query = `SELECT * FROM ${model}`; if (where && where.length > 0) { const whereClause = buildWhereClause(where, model); query += ` WHERE ${whereClause}`; } if (sortBy) { query += ` ORDER BY ${sortBy.field} ${sortBy.direction.toUpperCase()}`; } if (limit !== undefined) { query += ` LIMIT ${limit}`; } if (offset !== undefined) { query += ` START ${offset}`; } debugLog?.("findMany", { model, query }); const [results] = await conn.query<[Record<string, unknown>[]]>(query); // Transform output - convert RecordId to string return (results || []).map((record) => { const output: Record<string, unknown> = {}; for (const [key, value] of Object.entries(record)) { output[key] = jsonify(value); } return output; }); }, update: async ({ model, where, update }) => { const conn = await ensureConnection(); const whereClause = buildWhereClause(where, model); // Transform foreign key references to RecordIds const transformedUpdate: Record<string, unknown> = {}; for (const [key, value] of Object.entries(update)) { transformedUpdate[key] = transformValueForDB(key, value, model); } const query = `UPDATE ${model} MERGE $data WHERE ${whereClause}`; debugLog?.("update", { model, query, data: transformedUpdate }); const [results] = await conn.query<[Record<string, unknown>[]]>(query, { data: transformedUpdate, }); const record = results?.[0]; if (!record) { throw new SurrealDBError("Failed to update record"); } // Transform output - convert RecordId to string const output: Record<string, unknown> = {}; for (const [key, value] of Object.entries(record)) { output[key] = jsonify(value); } return output; }, updateMany: async ({ model, where, update }) => { const conn = await ensureConnection(); const whereClause = buildWhereClause(where, model); // Transform foreign key references to RecordIds const transformedUpdate: Record<string, unknown> = {}; for (const [key, value] of Object.entries(update)) { transformedUpdate[key] = transformValueForDB(key, value, model); } const query = `UPDATE ${model} MERGE $data WHERE ${whereClause}`; debugLog?.("updateMany", { model, query, data: transformedUpdate }); const [results] = await conn.query<[Record<string, unknown>[]]>(query, { data: transformedUpdate, }); return results?.length || 0; }, delete: async ({ model, where }) => { const conn = await ensureConnection(); const whereClause = buildWhereClause(where, model); const query = `DELETE FROM ${model} WHERE ${whereClause}`; debugLog?.("delete", { model, query }); await conn.query(query); }, deleteMany: async ({ model, where }) => { const conn = await ensureConnection(); const whereClause = buildWhereClause(where, model); const query = `DELETE FROM ${model} WHERE ${whereClause}`; debugLog?.("deleteMany", { model, query }); const [results] = await conn.query<[Record<string, unknown>[]]>(query); return results?.length || 0; }, count: async ({ model, where }) => { const conn = await ensureConnection(); let query: string; if (where && where.length > 0) { const whereClause = buildWhereClause(where, model); query = `SELECT count() FROM ${model} WHERE ${whereClause} GROUP ALL`; } else { query = `SELECT count() FROM ${model} GROUP ALL`; } debugLog?.("count", { model, query }); const [results] = await conn.query<[Array<{ count: number }>]>(query); return results?.[0]?.count || 0; }, options: config, }; }, }); }; /** * Custom error class for SurrealDB adapter errors */ export class SurrealDBError extends Error { constructor(message: string) { super(message); this.name = "SurrealDBError"; } } // Re-export for backwards compatibility export { SurrealDBError as SurrealDBQueryError };