import { BETTER_AUTH_SURREALDB_CODES, BetterAuthSurrealError, } from "../error-codes/error-codes.js"; import type { IdStrategy, Row } from "../types/adapter.js"; /** * Crockford-base32 alphabet (per ULID spec). * I, L, O, U are intentionally absent. */ const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /** * Generate a 26-character Crockford-base32 ULID: * - 10 chars time (48-bit ms-since-epoch), * - 16 chars random (80 bits via `crypto.getRandomValues`). */ export function generateUlid(now: () => number = Date.now): string { let time = now(); const timeChars: string[] = new Array(10); for (let i = 9; i >= 0; i--) { timeChars[i] = CROCKFORD32[time % 32] as string; time = Math.floor(time / 32); } const rand = new Uint8Array(16); crypto.getRandomValues(rand); const randChars = new Array(16) as string[]; for (let i = 0; i < 16; i++) { randChars[i] = CROCKFORD32[(rand[i] as number) % 32] as string; } return timeChars.join("") + randChars.join(""); } /** * Outcome of `prepareCreate` — fully-resolved CREATE arguments. * * - `target`: the SurrealQL target — either `:` (record-id / * ulid) or just `
` (plain). The adapter splices this verbatim * into `CREATE CONTENT $data RETURN AFTER`. * - `data`: row content excluding the id (record-id) or including it * (plain). * - `assignedId`: the id Better-Auth should see after the create returns * (either supplied, ULID-generated, or `null` when SurrealDB will assign * one in `plain` mode). */ export interface PreparedCreate { readonly target: string; readonly data: Row; readonly assignedId: string | null; } const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; /** * Resolve a `(strategy, table, data)` tuple into the CREATE-arguments * Better-Auth's adapter needs. Encapsulates the three id-strategy * branches. */ export function prepareCreate( strategy: IdStrategy, table: string, data: Row ): PreparedCreate { switch (strategy) { case "record-id": return prepareRecordId(table, data, null); case "ulid": return prepareRecordId(table, data, generateUlid); case "plain": return preparePlain(table, data); } } function prepareRecordId( table: string, data: Row, fallback: (() => string) | null ): PreparedCreate { const rawId = data["id"]; let id: string; if (rawId === undefined || rawId === null || rawId === "") { if (fallback === null) { throw new BetterAuthSurrealError( BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT, "idStrategy='record-id' requires data.id to be a non-empty string" ); } id = fallback(); } else { if (typeof rawId !== "string") { throw new BetterAuthSurrealError( BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT, `data.id must be a string, got ${typeof rawId}` ); } if (rawId.includes(":")) { throw new BetterAuthSurrealError( BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT, `data.id "${rawId}" must not contain ":" — pass the bare id` ); } id = rawId; } if (!IDENT.test(table)) { throw new BetterAuthSurrealError( BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL, `table "${table}" is not a valid SurrealQL identifier` ); } const { id: _omit, ...rest } = data; return { target: `${table}:\`${id}\``, data: rest, assignedId: id, }; } function preparePlain(table: string, data: Row): PreparedCreate { if (!IDENT.test(table)) { throw new BetterAuthSurrealError( BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL, `table "${table}" is not a valid SurrealQL identifier` ); } const assignedId = typeof data["id"] === "string" ? (data["id"] as string) : null; return { target: table, data: { ...data }, assignedId, }; } /** * Normalise an `id` value returned by SurrealDB. SurrealDB returns either * a `RecordId` object (`{ tb, id }`), a string `
:`, or a * bare value. This function returns the bare id portion as a string. */ export function normaliseReturnedId(value: unknown, table: string): string { if (value === null || value === undefined) return ""; if (typeof value === "object") { const maybeRecord = value as { id?: unknown; tb?: unknown }; if (typeof maybeRecord.id === "string") return maybeRecord.id; if ( typeof maybeRecord.id === "object" && maybeRecord.id !== null && "String" in (maybeRecord.id as Record) ) { return String( (maybeRecord.id as Record)["String"] ); } return String(value); } if (typeof value !== "string") return String(value); const prefix = `${table}:`; if (value.startsWith(prefix)) { const stripped = value.slice(prefix.length); return stripped.startsWith("`") && stripped.endsWith("`") ? stripped.slice(1, -1) : stripped; } return value; }