Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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]"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { PreparedCreate } from "./id-strategy.js";
|
||||
export {
|
||||
generateUlid,
|
||||
normaliseReturnedId,
|
||||
prepareCreate,
|
||||
} from "./id-strategy.js";
|
||||
Reference in New Issue
Block a user