Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
# id-strategy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encapsulates the three Better-Auth record-id strategies the adapter
supports. SurrealDB's native record-id format is `<table>:<id>`, but
Better-Auth callers may pass either a bare `id` (record-id mode), no
`id` at all (ulid mode auto-generates), or a row that stores `id` as a
regular field (plain mode). This module resolves the strategy choice into
concrete CREATE-target-and-data tuples and normalises the returned id
on read.
## Features
- `prepareCreate(strategy, table, data) → PreparedCreate` — dispatches on
the strategy and returns `{ target, data, assignedId }`.
- `record-id` strategy: requires non-empty `data.id`, rejects colons, emits
`<table>:\`<id>\`` to keep arbitrary identifier characters safe.
- `ulid` strategy: when `data.id` is absent, generates a 26-char Crockford
base32 ULID (10 chars time + 16 chars random) via `crypto.getRandomValues`.
- `plain` strategy: keeps `id` as a row field; SurrealDB assigns the
storage id; row's `assignedId` echoes `data.id` when present.
- `normaliseReturnedId` — strips the `<table>:` prefix from SurrealDB's
return shape (`RecordId` object, `<table>:<id>` string, or bare value).
- Throws `INVALID_MODEL` when the table name fails the identifier rule.
- Throws `INVALID_ID_FORMAT` when an id-strategy contract is violated.
## Usage: Basic
```ts
import { prepareCreate } from "@suaveplan/better-auth-surrealdb/id-strategy";
const p = prepareCreate("record-id", "user", { id: "u1", email: "x@y" });
// p.target === "user:`u1`"
// p.data === { email: "x@y" }
// p.assignedId === "u1"
```
## Usage: Advanced
```ts
import { prepareCreate, generateUlid } from "@suaveplan/better-auth-surrealdb/id-strategy";
// Inject deterministic id for tests by composing your own table+id outside
// of prepareCreate; or use the ulid generator directly:
const id = generateUlid();
const p = prepareCreate("record-id", "session", { id, userId: "user:u1" });
// Plain mode for schemas that store id as a regular column:
const flat = prepareCreate("plain", "session", { id: "s1", token: "t" });
// flat.target === "session"
// flat.data === { id: "s1", token: "t" }
// flat.assignedId === "s1"
```
## API Reference
- `IdStrategy = "record-id" | "plain" | "ulid"`
- `PreparedCreate = { target: string; data: Row; assignedId: string | null }`
- `prepareCreate(strategy, table, data): PreparedCreate`
- `generateUlid(now?): string` — 26-char Crockford base32 ULID.
- `normaliseReturnedId(value, table): string` — strips `<table>:` from SurrealDB
return shapes.
## Implementation Notes
The ULID alphabet matches the published spec (Crockford base32 minus I, L,
O, U). Random bytes come from `crypto.getRandomValues` so the generator is
universal (works in Bun, Node ≥18, and browsers). The record-id
target is wrapped in backticks so SurrealDB accepts ULIDs containing digits
at position 1 (otherwise the parser may treat the value as a number). The
`prepareCreate` switch is exhaustive; TypeScript narrows `IdStrategy` to
the three known cases at compile time.

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import {
generateUlid,
normaliseReturnedId,
prepareCreate,
} from "./id-strategy.js";
describe("prepareCreate", () => {
it("record-id strategy strips and reapplies prefix", {
timeout: 5000,
}, () => {
const p = prepareCreate("record-id", "user", {
id: "u1",
email: "x@y",
});
expect(p.target).toBe("user:`u1`");
expect(p.data).toEqual({ email: "x@y" });
expect(p.assignedId).toBe("u1");
});
it("record-id rejects missing id", { timeout: 5000 }, () => {
expect(() => prepareCreate("record-id", "user", {})).toThrow(
BetterAuthSurrealError
);
});
it("record-id rejects non-string id", { timeout: 5000 }, () => {
expect(() => prepareCreate("record-id", "user", { id: 5 })).toThrow(
BetterAuthSurrealError
);
});
it("colon in record-id raises INVALID_ID_FORMAT", { timeout: 5000 }, () => {
expect(() =>
prepareCreate("record-id", "user", { id: "abc:def" })
).toThrow(BetterAuthSurrealError);
try {
prepareCreate("record-id", "user", { id: "abc:def" });
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_ID_FORMAT"
);
}
});
it("invalid table name raises INVALID_MODEL", { timeout: 5000 }, () => {
expect(() =>
prepareCreate("record-id", "weird-name", { id: "u1" })
).toThrow(BetterAuthSurrealError);
});
it("ulid strategy fills missing id", { timeout: 5000 }, () => {
const p = prepareCreate("ulid", "session", { userId: "user:u1" });
expect(p.assignedId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(p.target).toBe(`session:\`${p.assignedId}\``);
});
it("ulid strategy respects supplied id", { timeout: 5000 }, () => {
const p = prepareCreate("ulid", "session", {
id: "fixed",
userId: "user:u1",
});
expect(p.assignedId).toBe("fixed");
expect(p.target).toBe("session:`fixed`");
});
it("plain strategy stores id as a field", { timeout: 5000 }, () => {
const p = prepareCreate("plain", "user", {
id: "u1",
email: "x@y",
});
expect(p.target).toBe("user");
expect(p.data).toEqual({ id: "u1", email: "x@y" });
expect(p.assignedId).toBe("u1");
});
it("plain strategy without id returns null assignedId", {
timeout: 5000,
}, () => {
const p = prepareCreate("plain", "user", { email: "x@y" });
expect(p.assignedId).toBeNull();
});
it("plain strategy validates table name", { timeout: 5000 }, () => {
expect(() => prepareCreate("plain", "weird-name", {})).toThrow(
BetterAuthSurrealError
);
});
});
describe("generateUlid", () => {
it("produces a 26-char Crockford-base32 string", { timeout: 5000 }, () => {
const id = generateUlid();
expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
it("uniqueness across 100 calls", { timeout: 5000 }, () => {
const set = new Set(Array.from({ length: 100 }, () => generateUlid()));
expect(set.size).toBe(100);
});
it("honours injected clock", { timeout: 5000 }, () => {
const fixed = generateUlid(() => 0);
expect(fixed.startsWith("0000000000")).toBe(true);
});
});
describe("normaliseReturnedId", () => {
it("strips `<table>:` prefix from bare string", { timeout: 5000 }, () => {
expect(normaliseReturnedId("user:u1", "user")).toBe("u1");
});
it("strips backtick-wrapped id", { timeout: 5000 }, () => {
expect(normaliseReturnedId("user:`u1`", "user")).toBe("u1");
});
it("passes already-bare value through", { timeout: 5000 }, () => {
expect(normaliseReturnedId("u1", "user")).toBe("u1");
});
it("extracts id from RecordId-shaped object with string id", {
timeout: 5000,
}, () => {
expect(normaliseReturnedId({ tb: "user", id: "u1" }, "user")).toBe(
"u1"
);
});
it("extracts id from RecordId-shaped object with String-wrapped id", {
timeout: 5000,
}, () => {
expect(
normaliseReturnedId({ tb: "user", id: { String: "u1" } }, "user")
).toBe("u1");
});
it("returns empty string for null/undefined", { timeout: 5000 }, () => {
expect(normaliseReturnedId(null, "user")).toBe("");
expect(normaliseReturnedId(undefined, "user")).toBe("");
});
it("coerces non-string scalars", { timeout: 5000 }, () => {
expect(normaliseReturnedId(5, "user")).toBe("5");
});
it("coerces unknown objects via String()", { timeout: 5000 }, () => {
expect(normaliseReturnedId({ foo: "bar" }, "user")).toBe(
"[object Object]"
);
});
});

View File

@@ -0,0 +1,164 @@
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;
}

View File

@@ -0,0 +1,6 @@
export type { PreparedCreate } from "./id-strategy.js";
export {
generateUlid,
normaliseReturnedId,
prepareCreate,
} from "./id-strategy.js";