first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
# @suaveplan/better-auth-surrealdb - Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth Where[] filters to parameterised SurrealQL, auto-prefixes record-link fields on write, strips prefixes on read, supports record-id/plain/ulid id strategies, and ships an OTel-instrumented conformance harness for cross-package consumption.
Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table
SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised
SurrealQL, auto-prefixes record-link fields on write, strips prefixes on
read, supports `record-id` / `plain` / `ulid` id strategies, and ships an
OTel-instrumented conformance harness for cross-package reuse.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
- [internals](./src/adapter/internals.md) — Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the per-method operation runners (`operations.…
- [model-to-table](./src/adapter/model-to-table.md) — Memoising mapper from Better-Auth model name to SurrealDB table name. The mapper function is invoked once per distinct…
- [operations](./src/adapter/operations.md) — Hosts the five primitive runners (`runCreate`, `runFindMany`, `runUpdateMany`, `runDeleteMany`, `runCount`) that transl…
- [v1.6](./src/adapter/v1.6.md) — `betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration surface for `@suaveplan/better-auth-surreal…
- [compile-select](./src/compile/compile-select.md) — Compiles a Better-Auth `select?: string[]` projection into a SurrealQL column list. Used inside the adapter's `SELECT`…
- [compile-sort](./src/compile/compile-sort.md) — Compiles a Better-Auth `sortBy?: { field; direction }` argument into a SurrealQL `ORDER BY` fragment, validating the fi…
- [compile-where](./src/compile/compile-where.md) — Translates Better-Auth's `Where[]` filter union into a parameterised SurrealQL `WHERE` fragment plus a binding record.…
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
- [otel](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
- [instruments](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
- [link-fields](./src/schemas/link-fields.md) — Zod boundary schema asserting the structural shape of a `LinkFieldMap`. Used to validate the optional `linkFields` argu…
- [options](./src/schemas/options.md) — Zod boundary schema for `BetterAuthSurrealAdapterOptions`. Drives the runtime validation step inside `createBetterAuthS…
- [testing](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
- [run-adapter-conformance](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
- [types](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
- [adapter](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
<!-- end:auto-docs -->
## Quick start
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
import { betterAuth } from "better-auth";
const client = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "auth",
database: "auth",
username: "root",
password: process.env.SURREAL_PASSWORD,
});
await client.connect();
await applyBetterAuthSchema(client);
export const auth = betterAuth({
database: betterAuthSurrealAdapter({ client }),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
});
```
### Advanced — direct conformance harness usage
The v1.4-shape `createBetterAuthSurrealAdapter` returns the 10-method
adapter directly (without the `createAdapterFactory` wrapper). This is the
surface the `./testing` conformance harness consumes and the right
choice when you want to bypass Better-Auth's field-transformation layer:
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const adapter = createBetterAuthSurrealAdapter({ client });
await adapter.create({ model: "user", data: { id: "u1", email: "a@b" } });
```
## Bundled schema — 17 Better-Auth tables
Mirrored byte-for-byte from
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`. Schema
version moves in lockstep with this package version. Upstream changes to
the canonical schema require a coordinated package version bump.
| Table | `userId` shape | Notes |
|-----------------------|-------------------------|--------------------------------------------------------|
| `user` (extended) | n/a | Extends host's user table with `emailVerified`/`name`/`image` |
| `session` | `record<user>` | Has default `linkFields.session.userId = "user"` |
| `account` | `string` | Flat-string userId — NOT in `DEFAULT_BETTER_AUTH_LINK_FIELDS` |
| `verification` | n/a | Single-use token storage |
| `passkey` | `record<user>` | WebAuthn credentials |
| `jwks` | n/a | Server JWK pairs |
| `sso_provider` | `option<record<user>>` | Optional record-link |
| `oauth_application` | `string` (option) | Flat-string userId |
| `oauth_access_token` | `string` (option) | Flat-string userId |
| `oauth_consent` | `string` (option) | Flat-string userId |
| `organization` | n/a | Slug-unique tenant root |
| `member` | record × 2 | `userId`+`organizationId` both record-links |
| `invitation` | record × 3 | `inviterId`/`organizationId`/`teamId` record-links |
| `team` | `record<organization>` | Belongs to org |
| `team_member` | record × 2 | `userId`+`teamId` |
| `two_factor` | `record<user>` | TOTP secret + backup codes |
| `api_key` | `record<user>` | Long-lived API keys with rate-limit fields |
Plus 13 indexes (account provider, verification identifier, SSO provider
id, oauth client/access/refresh tokens, organization slug, passkey
credential, user phone, api_key prefix/user, member/team uniqueness,
invitation email/org).
## `linkFields` — overriding the default record-link map
`DEFAULT_BETTER_AUTH_LINK_FIELDS` covers every `record<...>` column in
the bundled schema. Pass your own `linkFields` when your schema differs:
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
// Flat-string schema everywhere — disable prefix manipulation:
createBetterAuthSurrealAdapter({ client, linkFields: {} });
// Mixed override:
createBetterAuthSurrealAdapter({
client,
linkFields: {
session: { userId: "user" }, // keep default
member: { userId: "user", organizationId: "tenant" }, // rename target
},
});
```
## `idStrategy` — record-id format
| Strategy | Behaviour |
|-------------|--------------------------------------------------------------------------------------------|
| `record-id` (default) | `data.id` becomes the SurrealDB record component (`<table>:<id>`). Rejects `id` containing `:`. Returned `id` is bare. |
| `plain` | `id` stored as a normal field; SurrealDB auto-generates the storage id. |
| `ulid` | When `data.id` missing, generates a 26-char Crockford-base32 ULID and applies `record-id`. |
## Conformance harness
```ts
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
const fixture = new SurrealDBFixture();
let adapter: ReturnType<typeof createBetterAuthSurrealAdapter>;
let client: SurrealAdapter;
beforeAll(async () => {
const cfg = await fixture.start();
client = new SurrealAdapter(cfg);
await client.connect();
await applyBetterAuthSchema(client);
adapter = createBetterAuthSurrealAdapter({ client });
}, 120_000);
afterAll(async () => {
await client.disconnect();
await fixture.stop();
});
runAdapterConformance(() => ({ client, adapter }));
```
## Peer deps
- `better-auth` ^1.4.0 — supplied by the consuming app. Type-only at this
package's boundary; never imported at runtime so the adapter ships
cleanly to apps using any 1.4+ minor.
## Subpath exports
`.`, `./adapter`, `./adapter/v1.6`, `./compile`, `./id-strategy`,
`./link-fields`, `./schema`, `./schemas/options`, `./schemas/link-fields`,
`./testing`, `./error-codes`, `./otel`, `./types`.

View File

@@ -0,0 +1,98 @@
# adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package's flagship export. `createBetterAuthSurrealAdapter(options)`
returns a Better-Auth `Adapter` whose 8 contract methods plus
`consumeOne` and `id` are wired against a SurrealDB v3 connection. The
adapter validates options through Zod at the boundary, dispatches every
CRUD method through OTel-instrumented spans, applies link-field
prefix/strip on writes/reads, and compiles `Where[]` filters into
parameterised SurrealQL — no string interpolation, no mocks, no
`vi.mock`.
## Features
- Stable `id` constant `"@suaveplan/better-auth-surrealdb"`.
- All 8 Better-Auth methods: `create`, `findOne`, `findMany`, `count`,
`update`, `updateMany`, `delete`, `deleteMany`, plus the `consumeOne`
primitive Better-Auth v1.6+ adds atop the older 8 methods.
- Link-field-aware writes and reads — `userId: "u1"` becomes
`userId: "user:u1"` at rest for `record<user>` columns.
- Three id-strategies (`record-id` default, `plain`, `ulid`).
- OTel counter + histogram via `instrument()`.
- Zod-validated options at the boundary; failures raise
`BetterAuthSurrealError(INVALID_INPUT)` with the `ZodError` as `cause`.
- Connection-readiness check: every method validates `client.isConnected`
and raises `CONNECTION_NOT_READY` when the underlying SurrealAdapter
has not connected.
- Underlying `SurrealError` failures wrap in `QUERY_FAILED` preserving the
`cause` chain.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
await client.connect();
await applyBetterAuthSchema(client);
const adapter = createBetterAuthSurrealAdapter({ client });
await adapter.create({
model: "session",
data: { id: "s1", userId: "u1", token: "t" },
});
```
## Usage: Advanced
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const adapter = createBetterAuthSurrealAdapter({
client: surrealAdapter,
idStrategy: "ulid",
modelToTable: (m) => `bauth_${m}`,
linkFields: {
// override default — match a flat-string schema:
session: {},
member: { organizationId: "organization" },
},
});
const user = await adapter.findOne({
model: "user",
where: [{ field: "email", value: "x@y" }],
select: ["id", "email"],
});
```
## API Reference
- `createBetterAuthSurrealAdapter(options): BetterAuthSurrealAdapter`
- `BETTER_AUTH_SURREAL_ADAPTER_ID = "@suaveplan/better-auth-surrealdb"`
- `BetterAuthSurrealAdapter` — structural type satisfying Better-Auth's
`DBAdapter` minus `transaction` / `createSchema` (the host's own
transaction primitive remains reachable via `SurrealAdapter.transaction`).
## Implementation Notes
The adapter's `client.query()` calls pass the binding record as
`[vars]` so both `SurrealAdapter` (which flattens a trailing record) and
hand-rolled `SurrealClientView` implementations receive the same shape.
`update` is implemented atop `updateMany`'s `RETURN AFTER` row stream and
returns the first row — `count() FROM <table> WHERE … GROUP ALL` returns
a `{ c: <num> }` row by aliasing; bare `count()` returns the count
unnamed which trips SurrealDB v3's stricter row shape. `consumeOne`
fetches the row first, then deletes by the same predicate; this is the
fallback path Better-Auth's factory installs when an adapter does not
expose a native consume primitive, and gives the same race semantics
under SurrealDB's read-then-delete pipeline.

View File

@@ -0,0 +1,66 @@
# adapter/internals
## Purpose
Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the
per-method operation runners (`operations.ts`). Encapsulates option
normalisation (Zod validation + default fill-in), connection-readiness
assertion, parameter packaging for the SurrealDB client surface, error
wrapping, and the read-side row post-processing that strips link prefixes
and normalises the returned `id`.
## Features
- `normaliseOptions(options): NormalisedOptions` — Zod-parses the boundary
options, applies the default `LinkFieldMap` when none supplied, defaults
`idStrategy` to `"record-id"`, instantiates a `ModelToTableCache`.
- `ensureConnected(client)` — throws `CONNECTION_NOT_READY` when the client
reports `isConnected === false`.
- `paramsFromVars(vars)` — packs the binding record as a single trailing
argument so both `SurrealAdapter.query` and bespoke `SurrealClientView`
implementations see identical call shapes.
- `wrapQuery(err)` — converts an underlying `SurrealError` (or any throw) to
`BetterAuthSurrealError(QUERY_FAILED)` preserving the cause chain.
- `postRead(row, table, linkFields, model, idStrategy)` — strips link
prefixes, normalises the returned `id` per the strategy.
## Usage: Basic
```ts
import { normaliseOptions, ensureConnected } from "./internals.js";
const opts = normaliseOptions(userSuppliedOptions);
ensureConnected(opts.client);
```
## Usage: Advanced
```ts
import { wrapQuery, postRead } from "./internals.js";
try {
const rows = await opts.client.query("SELECT * FROM user;");
return rows.map((r) => postRead(r, "user", opts.linkFields, "user", opts.idStrategy));
} catch (err) {
wrapQuery(err); // never returns
}
```
## API Reference
- `interface NormalisedOptions { client; idStrategy; linkFields; modelCache; }`
- `normaliseOptions(opts): NormalisedOptions`
- `ensureConnected(client): void`
- `paramsFromVars(vars): unknown[]`
- `wrapQuery(err): never`
- `postRead(row, table, linkFields, model, idStrategy): Row`
## Implementation Notes
`normaliseOptions` re-validates through Zod on every call so test seams
that mutate options between adapter instantiations still surface boundary
issues. The function returns the originally-supplied `client` reference
(not Zod's parsed clone) to preserve method identity — Zod's structural
`custom` validator does not reconstruct the input. `wrapQuery` is typed
`never` so callers can call it inside a `try/catch` and TypeScript still
narrows the surrounding block to the success path.

View File

@@ -0,0 +1,72 @@
# adapter/model-to-table
## Purpose
Memoising mapper from Better-Auth model name to SurrealDB table name. The
mapper function is invoked once per distinct model and the resolved name
is validated against the SurrealQL identifier rule
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). Caching avoids re-running operator-supplied
prefix logic on every call site while validation prevents accidental
injection through a hand-rolled `modelToTable` that returns SurrealQL
syntax.
## Features
- Identity-default — `new ModelToTableCache()` maps model→model verbatim.
- Caches resolved name in a `Map<string,string>`.
- Identifier-rule validation: invalid names raise `INVALID_MODEL` on first
use of the offending model.
- Decoupled from the adapter shell so unit tests can exercise mapping
semantics in isolation.
## Usage: Basic
```ts
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
const cache = new ModelToTableCache();
cache.resolve("user"); // "user"
cache.resolve("session"); // "session"
```
## Usage: Advanced
```ts
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
let calls = 0;
const cache = new ModelToTableCache((m) => {
calls++;
return `bauth_${m}`;
});
cache.resolve("user"); // "bauth_user" — calls === 1
cache.resolve("user"); // "bauth_user" — calls still 1 (cached)
cache.resolve("session"); // "bauth_session" — calls === 2
```
## API Reference
- `class ModelToTableCache`
- `constructor(resolver?: ModelToTableResolver)` — defaults to identity.
- `resolve(model: string): string` — cached + validated.
- `type ModelToTableResolver = (model: string) => string`.
## Implementation Notes
The cache is a plain `Map` because invalidation is unnecessary: model
names are stable Better-Auth contracts and the adapter is constructed per
process. Throwing on first use of an invalid name (rather than at adapter
construction) preserves the "lazy" semantics so callers wiring a
multi-tenant prefix function can return different names per request
without paying the validation cost upfront. The identifier check uses
the same regex as every other validator in this package
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`) so operators get consistent feedback when
a name shape is wrong — whether the offending input is a model alias, a
SurrealDB table name, a link-field target, or a `select` projection
entry.
The class field is `protected` rather than `private` so subclasses can
introspect the cache during debugging without paying the indirection cost
of an additional accessor method — useful when wiring per-tenant audit
trails that need to log every resolved table name on first use.

View File

@@ -0,0 +1,73 @@
# adapter/operations
## Purpose
Hosts the five primitive runners (`runCreate`, `runFindMany`,
`runUpdateMany`, `runDeleteMany`, `runCount`) that translate Better-Auth
method arguments into parameterised SurrealQL and dispatch through the
configured `SurrealClientView`. Decoupling the runners from the
`createBetterAuthSurrealAdapter` shell keeps each Better-Auth public method
trivially thin (it composes runners + OTel instrumentation) and makes per-
operation behaviour testable in isolation.
## Features
- `runCreate(opts, { model, data })` — `CREATE <target> CONTENT $data RETURN
AFTER`. Applies link-field prefixes before binding and strips them on
return. Honours the configured `idStrategy`.
- `runFindMany(opts, args)` — full `SELECT … FROM <table> [WHERE …] [ORDER
BY …] [LIMIT …] [START …]` pipeline. Empty trailing clauses are elided
by post-template whitespace collapse.
- `runUpdateMany(opts, args)` — `UPDATE <table> MERGE $update [WHERE …]
RETURN AFTER` with predicate placeholders namespaced `$p0/$p1/…` to
avoid colliding with the `$update` MERGE binding.
- `runDeleteMany(opts, args)` — `DELETE <table> [WHERE …] RETURN BEFORE`;
returns the number of pre-image rows so the public `delete`/`deleteMany`
methods can derive their semantics.
- `runCount(opts, args)` — `SELECT count() AS c FROM <table> [WHERE …]
GROUP ALL`; tolerates SurrealDB returning an empty result set or a
string-typed count.
## Usage: Basic
```ts
import { runCreate, runFindMany } from "./operations.js";
const row = await runCreate(opts, { model: "user", data: { id: "u1" } });
const list = await runFindMany(opts, {
model: "user",
where: [{ field: "email", value: "x" }],
});
```
## Usage: Advanced
```ts
import { runCount, runUpdateMany } from "./operations.js";
const updated = await runUpdateMany(opts, {
model: "session",
where: [{ field: "expiresAt", value: now, operator: "lt" }],
update: { active: false },
});
const total = await runCount(opts, { model: "session" });
```
## API Reference
- `runCreate(opts, args): Promise<Row>`
- `runFindMany(opts, args): Promise<Row[]>`
- `runUpdateMany(opts, args): Promise<Row[]>`
- `runDeleteMany(opts, args): Promise<number>`
- `runCount(opts, args): Promise<number>`
## Implementation Notes
All runners single-step replace whitespace runs with one space then trim
to keep generated SurrealQL readable in EXPLAIN output without sprinkling
conditionals at every splice site. `runUpdateMany` uses an explicit
`placeholderPrefix: "p"` so the merged binding object can keep `$update`
disjoint from the predicate bindings. `runCount` aliases `count() AS c`
because SurrealDB v3's `GROUP ALL` projection returns the aggregate under
the function name otherwise, which is fragile across point releases.

View File

@@ -0,0 +1,121 @@
# `betterAuthSurrealAdapter` — Better-Auth v1.6 factory
## Purpose
`betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration
surface for `@suaveplan/better-auth-surrealdb`. It returns a
`DBAdapterInstance` — a function `(BetterAuthOptions) => DBAdapter` —
that `betterAuth({ database })` accepts directly. The factory is a thin,
type-safe wrapper around `createBetterAuthSurrealAdapter` (the v1.4 shape
this package has always exported) plus `createAdapterFactory` from
`@better-auth/core/db`, the official escape-hatch Better-Auth provides for
custom database adapters.
The wrapper does no real work of its own beyond schema validation,
forwarding, transaction setup, and exposing the bundled SurrealQL DDL via
`createSchema()` so Better-Auth's CLI generator (`@better-auth/cli
generate`) can emit a canonical schema file.
## Features
- Better-Auth v1.6 `DBAdapterInstance` return type — works with
`betterAuth({ database: betterAuthSurrealAdapter({ client }) })` out of
the box.
- Same `BetterAuthSurrealAdapterOptions` shape and Zod schema as the v1.4
surface — `client`, `modelToTable`, `idStrategy`, `linkFields`,
`logger` are all honoured identically.
- Transaction support: when `options.client` is a `SurrealAdapter`, the
factory wires `config.transaction` to delegate into
`SurrealAdapter.transaction` (a `BEGIN/COMMIT/CANCEL TRANSACTION`
block). The transaction-scoped `DBTransactionAdapter` is built by
re-instantiating the underlying adapter against the transaction's
`DatabaseLike` handle so writes inside `transaction(fn)` participate in
the surrounding transaction.
- `createSchema()` returns `BETTER_AUTH_SURREAL_SCHEMA` verbatim — the
same 17-table DDL string this package ships under `./schema`. Wrapped
in `DBAdapterSchemaCreation` shape (`{ code, path: "./schema.surql",
overwrite: true }`).
- OTel: emits a `better_auth.surrealdb.operations_total{model:
"_transaction", operation: "transaction", outcome}` counter point per
`transaction(...)` invocation. The underlying CRUD methods continue to
emit their own per-operation counter points unchanged; the wrapper
layer never double-counts.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { betterAuth } from "better-auth";
const client = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "auth",
database: "auth",
username: "root",
password: process.env.SURREAL_PASSWORD,
});
await client.connect();
await applyBetterAuthSchema(client);
export const auth = betterAuth({
database: betterAuthSurrealAdapter({ client }),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
});
```
## Usage: Advanced
```ts
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
// Custom model-to-table mapping + link-field overrides
const adapter = betterAuthSurrealAdapter({
client,
modelToTable: (model) => (model === "user" ? "people" : model),
linkFields: {
session: { userId: "people" },
member: { userId: "people", organizationId: "tenant" },
},
idStrategy: "ulid",
});
// Pass to Better-Auth — supports the full v1.6 surface including
// `auth.api.signUpEmail`, `auth.api.signInEmail`, `auth.api.getSession`,
// transactions, schema generation via `@better-auth/cli generate`, etc.
```
## API Reference
- `betterAuthSurrealAdapter(options: BetterAuthSurrealAdapterV16Options):
DBAdapterInstance` — the factory. Options are validated through
`BetterAuthSurrealAdapterOptionsSchema`; failures throw
`BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)`.
- `BetterAuthSurrealAdapterV16Options` — type alias for
`BetterAuthSurrealAdapterOptions`. Re-exported for clarity at the v1.6
call site.
## Implementation Notes
- The wrapper construction is two-step: (1) build the 10-method underlying
adapter via `createBetterAuthSurrealAdapter` once at factory call time;
(2) inside the per-call `(options) => DBAdapter` returned by
`createAdapterFactory`, return a `CustomAdapter` whose methods forward
to the cached underlying adapter. Better-Auth's factory adds
`transformInput`/`transformOutput` field whitelisting, default-value
application, and date/boolean/JSON normalisation on top — that's the
whole point of the wrapper layer.
- `disableIdGeneration: true` is set in the factory config so user-provided
`id` values survive Better-Auth's id-handling layer. The underlying
adapter still applies its `idStrategy` (record-id / plain / ulid).
- `create` calls forward with `forceAllowId: true` so the user-supplied
`id` field is preserved end-to-end (Better-Auth otherwise nulls it on
the v1.6 `create` path).
- `transaction` is only wired when the client is a `SurrealAdapter`
instance. For generic `SurrealClientView` callers, the factory falls
back to `createAsIsTransaction` (sequential execution against the
outer adapter, matching Better-Auth's documented degraded-transaction
behaviour).

View File

@@ -0,0 +1,63 @@
# compile-select
## Purpose
Compiles a Better-Auth `select?: string[]` projection into a SurrealQL
column list. Used inside the adapter's `SELECT` template. Identifier-
validates every entry so the projection cannot inject SurrealQL syntax.
## Features
- `undefined` and `[]` both compile to `"*"`.
- Validates every field against `/^[a-zA-Z_][a-zA-Z0-9_]*$/`.
- Pure / side-effect-free.
## Usage: Basic
```ts
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
compileSelect(["id", "email"]); // "id, email"
compileSelect(undefined); // "*"
compileSelect([]); // "*"
```
## Usage: Advanced
```ts
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
function buildProjection(model: string, select?: string[]) {
const cols = compileSelect(select);
return `SELECT ${cols} FROM ${model}`;
}
```
## API Reference
- `compileSelect(select: ReadonlyArray<string> | undefined): string`
## Implementation Notes
The default `*` keeps Better-Auth's existing first-party adapters'
behaviour: when no projection is supplied, the row is returned in full so
that Better-Auth's read-transform layer can shape it client-side. The
identifier validator runs eagerly so the error surfaces as an
`INVALID_INPUT` near the adapter boundary instead of at the SurrealDB
query layer with a less actionable message. The function is pure and
side-effect-free — both invocations with identical input return the same
string output, which lets the adapter cache projection strings per
`(model, select-fingerprint)` tuple if a future optimisation pass wants
to dedupe across high-traffic call sites.
Identifier validation runs in O(n) over the input array. The check rejects
SurrealDB constructs that would otherwise compile but introduce attack
surface: dotted paths (`foo.bar`), function calls (`count()`), subscripts,
back-tick-quoted identifiers. Operators wanting to project subscripted
fields should compile their own projection string and bypass this helper;
this package treats that as out-of-band and intentionally narrow.
Empty-array vs `undefined` behave identically by design — Better-Auth's
upstream type permits both to mean "all fields". This package collapses
both to `"*"` rather than emitting a zero-column SurrealQL `SELECT` that
would surface as a parse error against SurrealDB v3.

View File

@@ -0,0 +1,63 @@
# compile-sort
## Purpose
Compiles a Better-Auth `sortBy?: { field; direction }` argument into a
SurrealQL `ORDER BY` fragment, validating the field name against the
identifier rule to prevent any injection vector. Returns the empty string
when `sortBy` is `undefined` so the adapter can unconditionally splice the
result into its SELECT template.
## Features
- Default direction handling: only `"desc"` flips to `DESC`; everything
else (including the explicit `"asc"`) emits `ASC`.
- Identifier validation: field MUST match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`,
else `BETTER_AUTH_SURREALDB.INVALID_INPUT` is thrown.
- Pure / side-effect-free.
## Usage: Basic
```ts
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
const fragment = compileSortBy({ field: "createdAt", direction: "desc" });
// "ORDER BY createdAt DESC"
const none = compileSortBy(undefined); // ""
```
## Usage: Advanced
```ts
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
function buildPaginatedSelect(table: string, sortBy?: { field: string; direction: "asc" | "desc" }) {
return `SELECT * FROM ${table} ${compileSortBy(sortBy)} LIMIT 25`.replace(/\s+/g, " ");
}
```
## API Reference
- `compileSortBy(sortBy: SortBy | undefined): string`
- `SortBy` — `{ field: string; direction: "asc" | "desc" }`.
## Implementation Notes
SurrealDB tolerates identifiers in `ORDER BY` only when they match its
identifier regex. We pre-validate rather than wrap in backticks so the
generated query is human-readable in EXPLAIN output and to surface a
typed error close to the offending input rather than at the DB boundary.
Returning the empty string for `undefined` simplifies the adapter's SELECT
template — the splice site can use a single template-literal slot with no
nullish checks.
The direction comparison is strict equality against the literal string
`"desc"` to keep Better-Auth's `"asc" | "desc"` union the authoritative
contract; any other input (e.g. uppercase, abbreviated) silently defaults
to `ASC`. This matches Better-Auth's own first-party adapters which never
emit non-canonical direction strings, but it does mean operators
hand-rolling a `sortBy` object with mistyped direction will not observe
a descending sort — a deliberate trade-off favouring forward-compatibility
against future Better-Auth direction extensions over strict input
validation at this layer.

View File

@@ -0,0 +1,76 @@
# compile-where
## Purpose
Translates Better-Auth's `Where[]` filter union into a parameterised
SurrealQL `WHERE` fragment plus a binding record. Every clause maps to a
unique placeholder (`$w0`, `$w1`, …) so SurrealDB binds values via its safe
parameter path — never via string interpolation. Link-field values are
pre-resolved against the configured `LinkFieldMap` before binding so a
`{ field: "userId", value: "u1" }` clause on `session` compiles to a
`WHERE userId = $w0` clause where `$w0 = "user:u1"`, satisfying the
`record<user>` column type.
## Features
- 11 operators: `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`, `in`,
`not_in`, `contains`, `starts_with`, `ends_with`.
- 2 connectors: `AND` (default), `OR`. The first clause omits the connector.
- Unique placeholder naming via positional index — never collides within
one compile, and the `placeholderPrefix` option lets the adapter keep
`$update`-vs-predicate bindings disjoint when assembling UPDATE queries.
- Link-field pre-resolution: scalar values get `<target>:<v>`; array values
map element-wise (preserving `in` / `not_in` semantics).
- Unknown operators raise `BETTER_AUTH_SURREALDB.INVALID_OPERATOR`.
## Usage: Basic
```ts
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
const { clause, vars } = compileWhere(
[{ field: "age", value: 18, operator: "gte" }],
{ model: "user", linkFields: {} }
);
// clause === "age >= $w0"
// vars === { w0: 18 }
```
## Usage: Advanced
```ts
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "@suaveplan/better-auth-surrealdb/link-fields";
const compiled = compileWhere(
[
{ field: "userId", value: "u1" },
{ field: "expiresAt", value: new Date(), operator: "lt", connector: "OR" },
],
{
model: "session",
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
placeholderPrefix: "p",
}
);
// compiled.clause === "userId = $p0 OR expiresAt < $p1"
// compiled.vars.p0 === RecordId("user", "u1") (pre-resolved record link)
```
## API Reference
- `compileWhere(where, options): { clause: string; vars: Record<string, unknown> }`
- `WhereOperator` — closed union of 11 operators.
- `WhereConnector` — `"AND" | "OR"`.
- `WhereClause` — `{ field; value; operator?; connector? }`.
- `CompileWhereOptions` — `{ model; linkFields; placeholderPrefix? }`.
## Implementation Notes
The operator-to-SurrealQL table covers every Better-Auth `whereOperators`
entry. The function returns `{ clause: "", vars: {} }` for empty input,
which lets the adapter splice the result into a SELECT without conditional
branching at the call site. `placeholderPrefix` defaults to `"w"` to match
the spec's `$w0/$w1/…` convention; the adapter uses `"u"` when compiling
MERGE-side bindings (`$update` is its own scope but predicate placeholders
must stay disjoint from any caller-defined params).

View File

@@ -0,0 +1,78 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of error codes raised from every public path of
`@suaveplan/better-auth-surrealdb`. Stable string codes let downstream callers
branch on failure reasons without depending on stack-trace text or Better-Auth
internals. Each code is namespaced under `BETTER_AUTH_SURREALDB.*` so log
aggregators and OTel dashboards can pivot on a single attribute regardless of
which package raised the failure.
## Features
- 7 fully-qualified codes: `INVALID_INPUT`, `INVALID_MODEL`,
`INVALID_OPERATOR`, `QUERY_FAILED`, `INVALID_ID_FORMAT`,
`INVALID_LINK_FIELD_VALUE`, `CONNECTION_NOT_READY`.
- `BetterAuthSurrealError` extends `SuaveplanError<BetterAuthSurrealErrorCode>`
so `instanceof` narrows the `code` to the union literal.
- Codes built via `defineCodes("BETTER_AUTH_SURREALDB", […])` so the runtime
object is frozen and the type is fully inferred.
- Carry `cause` and `context` from `SuaveplanErrorOptions` — `QUERY_FAILED`
pins the underlying `SurrealError`, `INVALID_INPUT` pins the `ZodError`.
## Usage: Basic
```ts
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "@suaveplan/better-auth-surrealdb/error-codes";
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR,
"unknown operator: weird"
);
```
## Usage: Advanced
```ts
import { ZodError } from "zod";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "@suaveplan/better-auth-surrealdb/error-codes";
function assertValid<T>(parsed: { success: boolean; error?: ZodError; data?: T }): T {
if (!parsed.success) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
"adapter options failed validation",
{ cause: parsed.error }
);
}
return parsed.data as T;
}
```
## API Reference
- `BETTER_AUTH_SURREALDB_CODES` — frozen record `{ [Reason]: "BETTER_AUTH_SURREALDB.<Reason>" }`.
- `BetterAuthSurrealErrorCode` — union literal of the 7 codes.
- `class BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode>`
— public constructor `(code, message, options?)`.
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record so the runtime cannot be
mutated; the type is fully inferred from the tuple literal. The class
constructor is widened to `public` (the base is `protected`) so callers in
sibling feature modules construct errors directly without an intermediate
factory. The class deliberately does NOT carry an OTel span event handle —
instrumentation is the caller's responsibility via `toSpanEvent` from
`@suaveplan/error`.

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,95 @@
# link-fields
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encapsulates the central design decision of this package: SurrealDB stores
record references as typed `record<target>` columns where the value at rest
is `<target>:<bare-id>`, but Better-Auth hands the adapter a bare `userId:
"u1"` regardless of which underlying table declares the field as a record
link. Without per-field prefix handling, writes to record-link columns would
fail SurrealDB's type assertion and reads would return `user:u1` strings
that break Better-Auth's identity comparisons. This module provides the
default field map, the per-value `apply`/`strip` primitives, and the
row-level walkers the adapter dispatches.
## Features
- `DEFAULT_BETTER_AUTH_LINK_FIELDS` — frozen default matching every
`record<...>` field in the bundled schema (9 model entries).
- `linkTargetFor(map, model, field)` — pure lookup returning the configured
target table or `null`.
- `applyLinkPrefix(target, value)` — adds `<target>:` to plain strings;
refuses values already containing `:` with `INVALID_LINK_FIELD_VALUE`.
- `stripLinkPrefix(target, value)` — strips `<target>:` from prefixed
strings; idempotent on bare strings.
- `applyLinkPrefixesToWrite(map, model, data)` — clones `data` and prefixes
every declared link field in one pass.
- `stripLinkPrefixesFromRead(map, model, row)` — clones `row` and strips
every declared link field in one pass.
## Usage: Basic
```ts
import {
DEFAULT_BETTER_AUTH_LINK_FIELDS,
applyLinkPrefixesToWrite,
stripLinkPrefixesFromRead,
} from "@suaveplan/better-auth-surrealdb/link-fields";
const onDisk = applyLinkPrefixesToWrite(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
{ userId: "u1", token: "t" }
);
// onDisk.userId === "user:u1"
const returned = stripLinkPrefixesFromRead(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
{ userId: "user:u1", token: "t" }
);
// returned.userId === "u1"
```
## Usage: Advanced
```ts
import {
applyLinkPrefix,
stripLinkPrefix,
linkTargetFor,
} from "@suaveplan/better-auth-surrealdb/link-fields";
const map = { session: { userId: "user" } } as const;
// Per-value primitives — used by compileWhere for Where[] bindings.
const target = linkTargetFor(map, "session", "userId"); // "user"
if (target !== null) {
const bound = applyLinkPrefix(target, "u1"); // "user:u1"
const bare = stripLinkPrefix(target, bound); // "u1"
}
```
## API Reference
- `DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap`
- `linkTargetFor(map, model, field): string | null`
- `applyLinkPrefix(target, value): unknown` — throws `INVALID_LINK_FIELD_VALUE`
when the value already contains `:`.
- `stripLinkPrefix(target, value): unknown`
- `applyLinkPrefixesToWrite(map, model, data): Record<string, unknown>`
- `stripLinkPrefixesFromRead(map, model, row): Record<string, unknown>`
## Implementation Notes
The default map is built with nested `Object.freeze` so callers cannot
accidentally mutate the shared singleton. Non-string values pass through
both `apply` and `strip` (the schema permits `option<record<user>>` to be
`null`). `applyLinkPrefix` refuses values containing `:` because the
adapter would otherwise emit `user:user:u1` on write — defending against
double-application is critical because Better-Auth's higher layers may
synthesise rows from joined-read output that already carries prefixes.

View File

@@ -0,0 +1,76 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires the package's OpenTelemetry metrics surface and exposes a thin
`instrument(op)` helper the adapter wraps around every CRUD method. The
helper records one counter point (with `outcome: "success" | "error"`)
plus one histogram observation per call, so dashboards can pivot on
model/operation/outcome and also see the per-operation latency
distribution.
## Features
- `better_auth.surrealdb.operations_total` counter — attributes:
`model`, `operation`, `outcome`.
- `better_auth.surrealdb.operation_duration_ms` histogram — attribute:
`operation`.
- Lazy meter resolution: the meter and instruments are constructed on
first use (not at module load) so tests can install a real
`MeterProvider` via `setGlobalMeterProvider` before the adapter
begins issuing operations.
- Universal: uses `performance.now()` when available, falls back to
`Date.now()` when running under bundlers that strip `performance`.
- `resetInstrumentsForTesting()` clears the lazy cache so a re-imported
test environment resolves the new provider.
## Usage: Basic
```ts
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
const row = await instrument({
model: "session",
operation: "create",
run: async () => insertSessionRow(),
});
```
## Usage: Advanced
```ts
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
import { metrics } from "@opentelemetry/api";
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
// Install provider BEFORE first instrument() call:
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
await instrument({ model: "user", operation: "findOne", run: async () => ({ ok: true }) });
await provider.forceFlush();
const collected = exporter.getMetrics();
```
## API Reference
- `instrument<T>(op): Promise<T>` — wraps a `() => Promise<T>` with counter +
histogram observations; rethrows on failure.
- `InstrumentedOp<T>` — `{ model: string; operation: string; run: () => Promise<T> }`.
- `AdapterOutcome` — `"success" | "error"`.
- `resetInstrumentsForTesting()` — invalidates the lazy cache.
## Implementation Notes
The lazy-init pattern is critical: importing OTel `metrics` at module load
returns a noop meter unless `setGlobalMeterProvider` has been called by the
host. Caching the meter+instruments after first use keeps subsequent calls
allocation-free. `performance.now()` is preferred for sub-millisecond
resolution but `Date.now()` is a correct fallback because the histogram
records millisecond observations regardless.

View File

@@ -0,0 +1,75 @@
# schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen
string constant `BETTER_AUTH_SURREAL_SCHEMA` and exposes a one-shot
`applyBetterAuthSchema(client)` helper for callers who do not run their
DDL through `@suaveplan/api-migrations`. Schema content is mirrored
byte-for-byte from the canonical source-of-truth file
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`; future
upstream changes require a coordinated package version bump.
## Features
- 17 DDL targets: `user` field extensions, plus `session`, `account`,
`verification`, `passkey`, `jwks`, `sso_provider`, `oauth_application`,
`oauth_access_token`, `oauth_consent`, `organization`, `member`,
`invitation`, `team`, `team_member`, `two_factor`, `api_key`.
- 13 indexes (account provider, verification identifier, SSO provider id,
oauth client/access/refresh tokens, organization slug, passkey
credential, user phone, api_key prefix/user, member/team uniqueness,
invitation email/org).
- Idempotent on re-application — every `DEFINE TABLE`/`DEFINE FIELD`/
`DEFINE INDEX` uses the `OVERWRITE` flag.
- `applyBetterAuthSchema(client)` executes the schema via the
`SurrealClientView.execute()` surface and wraps any underlying failure
in `BetterAuthSurrealError(QUERY_FAILED)`.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
await client.connect();
await applyBetterAuthSchema(client);
```
## Usage: Advanced
```ts
import { BETTER_AUTH_SURREAL_SCHEMA } from "@suaveplan/better-auth-surrealdb/schema";
// Pipe the schema through @suaveplan/api-migrations
import { createSurrealqlAdapter } from "@suaveplan/api-migrations";
const migrations = createSurrealqlAdapter({
steps: [
{ id: "001-better-auth", up: BETTER_AUTH_SURREAL_SCHEMA },
],
});
await migrations.run(client);
```
## API Reference
- `BETTER_AUTH_SURREAL_SCHEMA: string` — frozen DDL string.
- `applyBetterAuthSchema(client: SurrealClientView): Promise<void>` — executes
the schema via `client.execute(...)`; wraps failures in `QUERY_FAILED`.
## Implementation Notes
The constant is a single string literal so bundlers can tree-shake it out
of consumers that import only `applyBetterAuthSchema` from sibling
modules. Newlines are preserved (`\n`) so EXPLAIN output and DB logs stay
human-readable. The helper executes the whole DDL in one `execute()`
call; SurrealDB v3 parses multi-statement scripts natively and runs them
sequentially within a single response. Failure capture preserves the
underlying `SurrealError` via `cause` for upstream telemetry.

View File

@@ -0,0 +1,75 @@
# schemas/link-fields
## Purpose
Zod boundary schema asserting the structural shape of a `LinkFieldMap`. Used
to validate the optional `linkFields` argument passed to
`createBetterAuthSurrealAdapter` and to gate any callers that hand-roll their
own override map at runtime. Validation rejects non-string targets, empty
keys, and identifiers that would break the generated SurrealQL identifier
rule (`/^[a-zA-Z_][a-zA-Z0-9_]*$/`).
## Features
- Identifier-rule enforcement on model name, field name, and target table.
- Empty-string rejection (model/field/target must be `min(1)`).
- Composable: re-exported as `LinkFieldMapSchema` from `./schemas` and
embedded inside `BetterAuthSurrealAdapterOptionsSchema`.
- Type-level `LinkFieldMapSchemaInput` / `LinkFieldMapSchemaOutput` exports
for downstream consumers that derive their own option shapes.
## Usage: Basic
```ts
import { LinkFieldMapSchema } from "@suaveplan/better-auth-surrealdb/schemas/link-fields";
const parsed = LinkFieldMapSchema.safeParse({
session: { userId: "user" },
});
if (!parsed.success) throw parsed.error;
```
## Usage: Advanced
```ts
import { LinkFieldMapSchema } from "@suaveplan/better-auth-surrealdb/schemas/link-fields";
// Reject non-string targets at the wire boundary
const bad = LinkFieldMapSchema.safeParse({
session: { userId: 5 },
});
console.log(bad.success); // false
// Custom map merging
const merged = LinkFieldMapSchema.parse({
...{ session: { userId: "user" } },
...{ api_key: { userId: "user" } },
});
```
## API Reference
- `LinkFieldMapSchema: z.ZodType` — accepts `Record<string, Record<string, string>>`.
- `LinkFieldMapSchemaInput` — pre-parse type.
- `LinkFieldMapSchemaOutput` — post-parse type.
## Implementation Notes
The schema uses `z.record(IdentString, z.record(IdentString, IdentString))`.
Keys go through Zod's `z.record(keySchema, valueSchema)` 4-arg form so the
identifier check applies to the model/field names as well as the target
table. Empty-object inputs (`{}`) parse successfully — operators commonly
pass `linkFields: {}` to disable prefix manipulation entirely.
The `IdentString` schema delegates to `z.string().min(1).regex(...)` so
the inner regex never has to deal with empty strings (which would slip
through the regex via the implicit anchored-success path). The error
message attached to the regex check is human-readable and surfaces in
`ZodError.issues[i].message` for downstream error reporters, while the
issue path lets log aggregators pivot on offending fields without
parsing the message.
Type-level exports `LinkFieldMapSchemaInput` and `LinkFieldMapSchemaOutput`
are identical in this case (the schema is `transform`-free) but the
distinction is preserved so future extensions (e.g. coercing aliases to
canonical table names) can change one without breaking the other.

View File

@@ -0,0 +1,72 @@
# schemas/options
## Purpose
Zod boundary schema for `BetterAuthSurrealAdapterOptions`. Drives the runtime
validation step inside `createBetterAuthSurrealAdapter` so callers cannot
construct an adapter against a malformed configuration. Failure raises
`BetterAuthSurrealError(INVALID_INPUT)` carrying the `ZodError` as `cause`,
which keeps the original field path + issue list available to error
reporters and OTel adapters.
## Features
- Structural validation of `client` (asserts `query`/`execute` methods).
- Optional-field gating for `modelToTable`, `idStrategy`, `linkFields`,
`logger`.
- Embedded `LinkFieldMapSchema` reuse — invalid link-field shape surfaces
inside the parent schema's `cause.issues`.
- Identifier-safe id-strategy union `"record-id" | "plain" | "ulid"`.
## Usage: Basic
```ts
import { BetterAuthSurrealAdapterOptionsSchema } from "@suaveplan/better-auth-surrealdb/schemas/options";
BetterAuthSurrealAdapterOptionsSchema.parse({
client: surrealAdapterInstance,
});
```
## Usage: Advanced
```ts
import { BetterAuthSurrealAdapterOptionsSchema } from "@suaveplan/better-auth-surrealdb/schemas/options";
const result = BetterAuthSurrealAdapterOptionsSchema.safeParse({
client: surrealAdapterInstance,
idStrategy: "ulid",
linkFields: { session: { userId: "user" } },
modelToTable: (m) => `auth_${m}`,
});
if (!result.success) {
console.error(result.error.issues);
} else {
const opts = result.data; // typed
}
```
## API Reference
- `BetterAuthSurrealAdapterOptionsSchema: z.ZodObject` — boundary schema.
- `BetterAuthSurrealAdapterOptionsInput` — pre-parse type alias.
## Implementation Notes
`client` is validated via `z.custom` with a structural predicate (checks
`query` and `execute` are both functions) rather than `z.instanceof`,
because the spec accepts both `SurrealAdapter` instances and bespoke
`SurrealClientView` objects. The schema strips no keys (the parse pass is
permissive in case Better-Auth v1.x minor releases extend the contract);
the factory still narrows by destructuring only the fields it uses.
The validator deliberately returns the originally-supplied client
reference (Zod's `z.custom` preserves the input identity) rather than
reconstructing it — this preserves method identity and any private
state the supplied `SurrealAdapter` holds (connection pool, retry
counter, OTel context). Failing options surface a `ZodError` with the
full `issues` array, which the `createBetterAuthSurrealAdapter` factory
re-raises as `BetterAuthSurrealError(INVALID_INPUT)` with the `ZodError`
attached as `cause` so downstream log aggregators and OTel exporters get
the field-level diagnostics without rebuilding them from text.

View File

@@ -0,0 +1,86 @@
# testing/run-adapter-conformance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `runAdapterConformance(factory)` — the reusable suite that pins
every Better-Auth Adapter scenario this package implements against a real
SurrealDB testcontainer. Downstream test files (and consumers of the
package who maintain their own adapter implementations) import this
harness once and get an exhaustive contract sweep without rewriting per-
scenario boilerplate.
## Features
- Single `describe("Better-Auth adapter conformance")` block exercising
create, findOne, findMany (with limit/offset/sortBy), update,
updateMany, count, delete, deleteMany, and `consumeOne`.
- Validates link-field roundtrips on `session.userId` (`record<user>`).
- Validates `applyBetterAuthSchema` succeeds against the live container.
- Factory injection: callers supply `{ adapter, client }` so the harness
can hit both surfaces without re-importing module-private state.
- Every `it()` carries `{ timeout: 60000 }` to absorb container startup +
network jitter typical of dockerised SurrealDB.
## Usage: Basic
```ts
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
runAdapterConformance(async () => {
// Caller is responsible for starting the testcontainer, applying the
// schema, and constructing the adapter.
const { adapter, client } = await bootstrapTestEnv();
return { adapter, client };
});
```
## Usage: Advanced
```ts
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
const fixture = new SurrealDBFixture();
let cfg: Awaited<ReturnType<typeof fixture.start>>;
let client: SurrealAdapter;
beforeAll(async () => {
cfg = await fixture.start();
client = new SurrealAdapter(cfg);
await client.connect();
await applyBetterAuthSchema(client);
});
afterAll(async () => {
await client.disconnect();
await fixture.stop();
});
runAdapterConformance(() => ({
client,
adapter: createBetterAuthSurrealAdapter({ client }),
}));
```
## API Reference
- `runAdapterConformance(factory: ConformanceFactory): void`
- `ConformanceFactory = () => Promise<ConformanceContext> | ConformanceContext`
- `ConformanceContext = { adapter: BetterAuthSurrealAdapter; client: SurrealClientView }`
## Implementation Notes
The harness uses the runner-agnostic `@suaveplan/testing/runner` exports
so it works under both Vitest and Bun's `bun:test`. The factory is
invoked inside each `it()` so callers may choose either to share one
adapter across tests (return the same `ctx` reference) or to construct a
fresh adapter per scenario. Tests use stable `id` values prefixed
`u_conf_*` / `s_conf_*` to avoid cross-suite collisions when multiple
harness invocations share a database.

View File

@@ -0,0 +1,81 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Type-only foundation for the entire package. Defines the public option shape
(`BetterAuthSurrealAdapterOptions`), the record-id strategy union
(`IdStrategy`), the per-model record-link configuration (`LinkFieldMap`), and
the structural SurrealDB client view (`SurrealClientView`). Keeping these in a
zero-runtime module lets every other module import types without dragging in
Zod, OTel, or error-class side-effects.
## Features
- `IdStrategy` — closed union `"record-id" | "plain" | "ulid"`.
- `LinkFieldMap` — readonly nested record `{ model: { field: targetTable } }`.
- `BetterAuthSurrealAdapterOptions` — full factory option surface.
- `SurrealClientView` — structural view satisfied by `SurrealAdapter` AND any
hand-rolled client exposing the same `query` / `execute` signatures.
- `Row` — alias for `Record<string, unknown>` reused across method signatures.
## Usage: Basic
```ts
import type { BetterAuthSurrealAdapterOptions } from "@suaveplan/better-auth-surrealdb/types";
import { SurrealAdapter } from "@suaveplan/surrealdb";
const opts: BetterAuthSurrealAdapterOptions = {
client: new SurrealAdapter({ url: "ws://localhost:8000" }),
};
```
## Usage: Advanced
```ts
import type {
BetterAuthSurrealAdapterOptions,
LinkFieldMap,
SurrealClientView,
} from "@suaveplan/better-auth-surrealdb/types";
const customLinks: LinkFieldMap = {
session: { userId: "user" },
api_key: { userId: "user" },
};
const customClient: SurrealClientView = {
isConnected: true,
async query<T>() { return [] as T[]; },
async execute() { return { rowsAffected: 0 }; },
};
const opts: BetterAuthSurrealAdapterOptions = {
client: customClient,
linkFields: customLinks,
idStrategy: "ulid",
};
```
## API Reference
- `type IdStrategy = "record-id" | "plain" | "ulid"`
- `type LinkFieldMap = Readonly<Record<string, Readonly<Record<string, string>>>>`
- `type Row = Record<string, unknown>`
- `interface SurrealClientView { isConnected; query; execute; }`
- `interface BetterAuthSurrealAdapterOptions { client; modelToTable?; idStrategy?; linkFields?; logger? }`
## Implementation Notes
All maps are `Readonly` so callers cannot mutate the link-field config after
construction — the adapter holds the same reference and treats it as
immutable. `SurrealClientView` is intentionally structural so subclasses of
`SurrealAdapter`, test fakes, and bespoke SurrealDB v3 clients all satisfy
it. The `LoggerLike` import is type-only; no runtime dep on `@suaveplan/types`
is introduced by this file. The `Row` alias is exported separately from
`Record<string, unknown>` so future hardening (e.g. branding rows that
have been through the link-prefix stripper to distinguish them from raw
DB output) becomes a single-file change.