Files
suaveplan/packages/identity/auth/better-auth-surrealdb/src/id-strategy/id-strategy.ts
2026-05-27 23:47:53 +01:00

165 lines
5.2 KiB
TypeScript

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 `<table>:<id>` (record-id /
* ulid) or just `<table>` (plain). The adapter splices this verbatim
* into `CREATE <target> 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 `<table>:<id>`, 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<string, unknown>)
) {
return String(
(maybeRecord.id as Record<string, unknown>)["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;
}