Initial Commit
This commit is contained in:
177
packages/identity/auth/better-auth-surrealdb/README.md
Normal file
177
packages/identity/auth/better-auth-surrealdb/README.md
Normal 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`.
|
||||
|
||||
141
packages/identity/auth/better-auth-surrealdb/package.json
Normal file
141
packages/identity/auth/better-auth-surrealdb/package.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"name": "@suaveplan/better-auth-surrealdb",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/adapter.d.ts",
|
||||
"default": "./dist/adapter/adapter.js"
|
||||
}
|
||||
},
|
||||
"./adapter/v1.6": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/v1.6.d.ts",
|
||||
"default": "./dist/adapter/v1.6.js"
|
||||
}
|
||||
},
|
||||
"./compile": {
|
||||
"import": {
|
||||
"types": "./dist/compile/index.d.ts",
|
||||
"default": "./dist/compile/index.js"
|
||||
}
|
||||
},
|
||||
"./id-strategy": {
|
||||
"import": {
|
||||
"types": "./dist/id-strategy/id-strategy.d.ts",
|
||||
"default": "./dist/id-strategy/id-strategy.js"
|
||||
}
|
||||
},
|
||||
"./link-fields": {
|
||||
"import": {
|
||||
"types": "./dist/link-fields/link-fields.d.ts",
|
||||
"default": "./dist/link-fields/link-fields.js"
|
||||
}
|
||||
},
|
||||
"./schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/schema.d.ts",
|
||||
"default": "./dist/schema/schema.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/link-fields": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/link-fields.d.ts",
|
||||
"default": "./dist/schemas/link-fields.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"default": "./dist/testing/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/adapter.d.ts",
|
||||
"default": "./dist/types/adapter.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"surrealdb": "catalog:database",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"better-auth": "catalog:auth"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-surrealdb": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"better-auth": "catalog:auth",
|
||||
"fast-check": "catalog:testing",
|
||||
"surrealdb": "catalog:database",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { ZodError } from "zod";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
type BetterAuthSurrealAdapter,
|
||||
createBetterAuthSurrealAdapter,
|
||||
} from "./adapter.js";
|
||||
|
||||
describe("createBetterAuthSurrealAdapter (factory)", () => {
|
||||
it("factory produces every Better-Auth Adapter method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: new FakeClient(),
|
||||
});
|
||||
expect(adapter.id).toBe(BETTER_AUTH_SURREAL_ADAPTER_ID);
|
||||
for (const m of [
|
||||
"create",
|
||||
"findOne",
|
||||
"findMany",
|
||||
"update",
|
||||
"updateMany",
|
||||
"delete",
|
||||
"deleteMany",
|
||||
"count",
|
||||
"consumeOne",
|
||||
]) {
|
||||
expect(
|
||||
typeof (adapter as unknown as Record<string, unknown>)[m]
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("invalid options raise INVALID_INPUT carrying ZodError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createBetterAuthSurrealAdapter(
|
||||
{} as unknown as Parameters<
|
||||
typeof createBetterAuthSurrealAdapter
|
||||
>[0]
|
||||
);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthSurrealError);
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
"BETTER_AUTH_SURREALDB.INVALID_INPUT"
|
||||
);
|
||||
expect((err as Error).cause).toBeInstanceOf(ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed linkFields with INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createBetterAuthSurrealAdapter({
|
||||
client: new FakeClient(),
|
||||
linkFields: {
|
||||
session: { userId: 5 as unknown as string },
|
||||
},
|
||||
})
|
||||
).toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
|
||||
it("satisfies the BetterAuthSurrealAdapter type via expectTypeOf", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: new FakeClient(),
|
||||
});
|
||||
expectTypeOf(adapter).toMatchTypeOf<BetterAuthSurrealAdapter>();
|
||||
});
|
||||
|
||||
it("raises CONNECTION_NOT_READY when client is not connected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client = new FakeClient();
|
||||
client.isConnected = false;
|
||||
const adapter = createBetterAuthSurrealAdapter({ client });
|
||||
await expect(
|
||||
adapter.create({
|
||||
model: "user",
|
||||
data: { id: "u1" } as unknown as Record<string, unknown>,
|
||||
})
|
||||
).rejects.toThrow(/CONNECTION_NOT_READY|not connected/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { SortBy, WhereClause } from "../compile/index.js";
|
||||
import { instrument } from "../otel/instruments.js";
|
||||
import type { BetterAuthSurrealAdapterOptions, Row } from "../types/adapter.js";
|
||||
import { normaliseOptions } from "./internals.js";
|
||||
import {
|
||||
runCount,
|
||||
runCreate,
|
||||
runDeleteMany,
|
||||
runFindMany,
|
||||
runUpdateMany,
|
||||
} from "./operations.js";
|
||||
|
||||
export const BETTER_AUTH_SURREAL_ADAPTER_ID =
|
||||
"@suaveplan/better-auth-surrealdb";
|
||||
|
||||
/**
|
||||
* Public Better-Auth Adapter surface this package implements.
|
||||
*
|
||||
* Mirrors the 8 core methods + `id` + `consumeOne` from Better-Auth v1.4+.
|
||||
*/
|
||||
export interface BetterAuthSurrealAdapter {
|
||||
readonly id: string;
|
||||
create: <T extends Row, R = T>(args: {
|
||||
model: string;
|
||||
data: Omit<T, "id">;
|
||||
select?: string[] | undefined;
|
||||
forceAllowId?: boolean | undefined;
|
||||
}) => Promise<R>;
|
||||
findOne: <T>(args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
select?: string[] | undefined;
|
||||
}) => Promise<T | null>;
|
||||
findMany: <T>(args: {
|
||||
model: string;
|
||||
where?: WhereClause[] | undefined;
|
||||
limit?: number | undefined;
|
||||
select?: string[] | undefined;
|
||||
sortBy?: SortBy | undefined;
|
||||
offset?: number | undefined;
|
||||
}) => Promise<T[]>;
|
||||
count: (args: {
|
||||
model: string;
|
||||
where?: WhereClause[] | undefined;
|
||||
}) => Promise<number>;
|
||||
update: <T>(args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
update: Record<string, unknown>;
|
||||
}) => Promise<T | null>;
|
||||
updateMany: (args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
update: Record<string, unknown>;
|
||||
}) => Promise<number>;
|
||||
delete: <_T>(args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
}) => Promise<void>;
|
||||
deleteMany: (args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
}) => Promise<number>;
|
||||
consumeOne: <T>(args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
}) => Promise<T | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a Better-Auth `Adapter` backed by a SurrealDB client.
|
||||
*/
|
||||
export function createBetterAuthSurrealAdapter(
|
||||
options: BetterAuthSurrealAdapterOptions
|
||||
): BetterAuthSurrealAdapter {
|
||||
const opts = normaliseOptions(options);
|
||||
|
||||
return {
|
||||
id: BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
create: ((args: { model: string; data: Record<string, unknown> }) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "create",
|
||||
run: () => runCreate(opts, args),
|
||||
})) as BetterAuthSurrealAdapter["create"],
|
||||
findOne: ((args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
select?: string[] | undefined;
|
||||
}) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "findOne",
|
||||
run: async () => {
|
||||
const rows = await runFindMany(opts, {
|
||||
...args,
|
||||
limit: 1,
|
||||
});
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
},
|
||||
})) as BetterAuthSurrealAdapter["findOne"],
|
||||
findMany: ((
|
||||
args: Parameters<BetterAuthSurrealAdapter["findMany"]>[0]
|
||||
) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "findMany",
|
||||
run: () => runFindMany(opts, args),
|
||||
})) as BetterAuthSurrealAdapter["findMany"],
|
||||
count: (args) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "count",
|
||||
run: () => runCount(opts, args),
|
||||
}),
|
||||
update: ((args: Parameters<BetterAuthSurrealAdapter["update"]>[0]) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "update",
|
||||
run: async () => {
|
||||
const rows = await runUpdateMany(opts, args);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
},
|
||||
})) as BetterAuthSurrealAdapter["update"],
|
||||
updateMany: (args) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "updateMany",
|
||||
run: async () => (await runUpdateMany(opts, args)).length,
|
||||
}),
|
||||
delete: ((args: { model: string; where: WhereClause[] }) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "delete",
|
||||
run: async () => {
|
||||
await runDeleteMany(opts, args);
|
||||
},
|
||||
})) as BetterAuthSurrealAdapter["delete"],
|
||||
deleteMany: (args) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "deleteMany",
|
||||
run: () => runDeleteMany(opts, args),
|
||||
}),
|
||||
consumeOne: ((args: { model: string; where: WhereClause[] }) =>
|
||||
instrument({
|
||||
model: args.model,
|
||||
operation: "consumeOne",
|
||||
run: async () => {
|
||||
const rows = await runFindMany(opts, {
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
limit: 1,
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
const deleted = await runDeleteMany(opts, args);
|
||||
return deleted > 0 ? rows[0] : null;
|
||||
},
|
||||
})) as BetterAuthSurrealAdapter["consumeOne"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.consumeOne", () => {
|
||||
it("returns null when no row matches", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const out = await adapter.consumeOne({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: "missing" }],
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the row and issues a follow-up delete", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let phase = 0;
|
||||
const fake = new FakeClient(() => {
|
||||
phase++;
|
||||
if (phase === 1) {
|
||||
return [{ id: "verification:`v1`", identifier: "i", val: "v" }];
|
||||
}
|
||||
return [{ id: "verification:`v1`" }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const out = (await adapter.consumeOne({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: "v1" }],
|
||||
})) as { val: string } | null;
|
||||
expect(out?.val).toBe("v");
|
||||
expect(fake.log.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns null when the delete affects 0 rows (race lost)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let phase = 0;
|
||||
const fake = new FakeClient(() => {
|
||||
phase++;
|
||||
if (phase === 1) {
|
||||
return [{ id: "verification:`v1`", val: "v" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const out = await adapter.consumeOne({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: "v1" }],
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.count", () => {
|
||||
it("count without where returns total", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => [{ c: 10 }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
expect(await adapter.count({ model: "session" })).toBe(10);
|
||||
expect(fake.log[0]?.sql).toContain("GROUP ALL");
|
||||
});
|
||||
|
||||
it("count with where returns filtered (pre-resolves link value)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [{ c: 3 }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const n = await adapter.count({
|
||||
model: "session",
|
||||
where: [{ field: "userId", value: "u1" }],
|
||||
});
|
||||
expect(n).toBe(3);
|
||||
const param = fake.log[0]?.params[0];
|
||||
expect(String(param)).toBe("user:u1");
|
||||
});
|
||||
|
||||
it("count returns 0 when SurrealDB returns no group row", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
expect(await adapter.count({ model: "session" })).toBe(0);
|
||||
});
|
||||
|
||||
it("count coerces non-numeric c via Number()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [{ c: "5" }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
expect(await adapter.count({ model: "session" })).toBe(5);
|
||||
});
|
||||
|
||||
it("count handles missing c field as 0", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => [{}]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
expect(await adapter.count({ model: "session" })).toBe(0);
|
||||
});
|
||||
|
||||
it("count wraps query errors as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(adapter.count({ model: "session" })).rejects.toThrow(
|
||||
BetterAuthSurrealError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.create", () => {
|
||||
it("create stores under table:id and strips prefix on return", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ id: "user:`u1`", ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const row = (await adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: "u1",
|
||||
email: "x@y",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string; email: string };
|
||||
expect(row.id).toBe("u1");
|
||||
expect(row.email).toBe("x@y");
|
||||
expect(fake.log[0]?.sql).toContain("CREATE user:`u1`");
|
||||
expect(fake.log[0]?.sql).toContain("CONTENT $p0 RETURN AFTER");
|
||||
});
|
||||
|
||||
it("session.userId write wraps into record-link form", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ id: "session:`s1`", ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const row = (await adapter.create({
|
||||
model: "session",
|
||||
data: {
|
||||
id: "s1",
|
||||
userId: "u1",
|
||||
token: "t",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string; userId: string };
|
||||
const userIdParam = (fake.log[0]?.params[0] as { userId: unknown })
|
||||
?.userId;
|
||||
expect(String(userIdParam)).toBe("user:u1");
|
||||
expect(row.userId).toBe("u1");
|
||||
expect(row.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("ulid strategy fills missing id", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: fake,
|
||||
idStrategy: "ulid",
|
||||
});
|
||||
const row = (await adapter.create({
|
||||
model: "session",
|
||||
data: {
|
||||
userId: "u1",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string };
|
||||
expect(row.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
||||
});
|
||||
|
||||
it("account.userId is not prefixed under default config", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ id: "account:`a1`", ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.create({
|
||||
model: "account",
|
||||
data: {
|
||||
id: "a1",
|
||||
userId: "u1",
|
||||
providerId: "p",
|
||||
accountId: "a",
|
||||
password: "",
|
||||
} as unknown as Record<string, unknown>,
|
||||
});
|
||||
expect((fake.log[0]?.params[0] as { userId: string })?.userId).toBe(
|
||||
"u1"
|
||||
);
|
||||
});
|
||||
|
||||
it("empty linkFields disables prefix manipulation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ id: "session:`s1`", ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: fake,
|
||||
linkFields: {},
|
||||
});
|
||||
await adapter.create({
|
||||
model: "session",
|
||||
data: {
|
||||
id: "s1",
|
||||
userId: "u1",
|
||||
} as unknown as Record<string, unknown>,
|
||||
});
|
||||
expect((fake.log[0]?.params[0] as { userId: string })?.userId).toBe(
|
||||
"u1"
|
||||
);
|
||||
});
|
||||
|
||||
it("plain strategy stores id as a field", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const data = (vars["p0"] as Record<string, unknown>) ?? {};
|
||||
return [{ ...data }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: fake,
|
||||
idStrategy: "plain",
|
||||
});
|
||||
const row = (await adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: "u1",
|
||||
email: "x@y",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string };
|
||||
expect(row.id).toBe("u1");
|
||||
expect((fake.log[0]?.params[0] as { id: string })?.id).toBe("u1");
|
||||
expect(fake.log[0]?.sql).toContain("CREATE user CONTENT");
|
||||
});
|
||||
|
||||
it("create throws QUERY_FAILED when underlying query rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("kaput");
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(
|
||||
adapter.create({
|
||||
model: "user",
|
||||
data: { id: "u1" } as unknown as Record<string, unknown>,
|
||||
})
|
||||
).rejects.toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
|
||||
it("create throws QUERY_FAILED when query returns no rows", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(
|
||||
adapter.create({
|
||||
model: "user",
|
||||
data: { id: "u1" } as unknown as Record<string, unknown>,
|
||||
})
|
||||
).rejects.toThrow(/no rows/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.delete / deleteMany", () => {
|
||||
it("delete removes the row", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => [{ id: "session:`s1`" }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const out = await adapter.delete({
|
||||
model: "session",
|
||||
where: [{ field: "id", value: "s1" }],
|
||||
});
|
||||
expect(out).toBeUndefined();
|
||||
expect(fake.log[0]?.sql).toContain("DELETE session");
|
||||
});
|
||||
|
||||
it("deleteMany returns affected count via RETURN BEFORE row stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [
|
||||
{ id: "session:`s1`" },
|
||||
{ id: "session:`s2`" },
|
||||
{ id: "session:`s3`" },
|
||||
{ id: "session:`s4`" },
|
||||
]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const count = await adapter.deleteMany({
|
||||
model: "session",
|
||||
where: [{ field: "expiresAt", value: 0, operator: "lt" }],
|
||||
});
|
||||
expect(count).toBe(4);
|
||||
});
|
||||
|
||||
it("deleteMany with empty where omits WHERE clause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.deleteMany({ model: "user", where: [] });
|
||||
expect(fake.log[0]?.sql).not.toContain("WHERE");
|
||||
});
|
||||
|
||||
it("delete wraps query errors as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(
|
||||
adapter.delete({
|
||||
model: "session",
|
||||
where: [{ field: "id", value: "s1" }],
|
||||
})
|
||||
).rejects.toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.findOne / findMany", () => {
|
||||
it("findOne returns null on no match", { timeout: 5000 }, async () => {
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: new FakeClient(() => []),
|
||||
});
|
||||
const result = await adapter.findOne({
|
||||
model: "user",
|
||||
where: [{ field: "email", value: "missing@x" }],
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("findOne returns the first row stripped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [{ id: "user:`u1`", email: "x@y" }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const row = await adapter.findOne<{ id: string; email: string }>({
|
||||
model: "user",
|
||||
where: [{ field: "email", value: "x@y" }],
|
||||
});
|
||||
expect(row).toEqual({ id: "u1", email: "x@y" });
|
||||
});
|
||||
|
||||
it("where-filter pre-resolves link prefix", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.findMany({
|
||||
model: "session",
|
||||
where: [{ field: "userId", value: "u1" }],
|
||||
});
|
||||
const param = fake.log[0]?.params[0];
|
||||
expect(String(param)).toBe("user:u1");
|
||||
});
|
||||
|
||||
it("findMany honours limit/offset/sortBy", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.findMany({
|
||||
model: "user",
|
||||
where: [{ field: "email", value: "x" }],
|
||||
limit: 5,
|
||||
offset: 2,
|
||||
sortBy: { field: "createdAt", direction: "desc" },
|
||||
select: ["id", "email"],
|
||||
});
|
||||
const sql = fake.log[0]?.sql ?? "";
|
||||
expect(sql).toContain("SELECT id, email FROM user");
|
||||
expect(sql).toContain("WHERE email = $p0");
|
||||
expect(sql).toContain("ORDER BY createdAt DESC");
|
||||
expect(sql).toContain("LIMIT 5");
|
||||
expect(sql).toContain("START 2");
|
||||
});
|
||||
|
||||
it("findMany with no where works", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.findMany({ model: "user" });
|
||||
expect(fake.log[0]?.sql).toContain("SELECT * FROM user");
|
||||
});
|
||||
|
||||
it("findMany surfaces query errors as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(adapter.findMany({ model: "user" })).rejects.toThrow(
|
||||
BetterAuthSurrealError
|
||||
);
|
||||
});
|
||||
|
||||
it("findMany with offset === 0 omits START clause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.findMany({ model: "user", offset: 0 });
|
||||
expect(fake.log[0]?.sql).not.toContain("START");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
type BetterAuthSurrealAdapter,
|
||||
createBetterAuthSurrealAdapter,
|
||||
} from "./adapter.js";
|
||||
export {
|
||||
ModelToTableCache,
|
||||
type ModelToTableResolver,
|
||||
} from "./model-to-table.js";
|
||||
export {
|
||||
type BetterAuthSurrealAdapterV16Options,
|
||||
betterAuthSurrealAdapter,
|
||||
} from "./v1.6.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter internals — error wrapping", () => {
|
||||
it("wrapQuery rethrows BetterAuthSurrealError unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const passthrough = new BetterAuthSurrealError(
|
||||
"BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE",
|
||||
"passthrough"
|
||||
);
|
||||
const fake = new FakeClient(() => {
|
||||
throw passthrough;
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
try {
|
||||
await adapter.findMany({ model: "user" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBe(passthrough);
|
||||
}
|
||||
});
|
||||
|
||||
it("wrapQuery coerces non-Error throws via String()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw "boom-string";
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
|
||||
{
|
||||
code: "BETTER_AUTH_SURREALDB.QUERY_FAILED",
|
||||
message: "boom-string",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { SurrealError } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { normaliseReturnedId } from "../id-strategy/id-strategy.js";
|
||||
import {
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
stripLinkPrefixesFromRead,
|
||||
} from "../link-fields/link-fields.js";
|
||||
import { BetterAuthSurrealAdapterOptionsSchema } from "../schemas/options.js";
|
||||
import type {
|
||||
BetterAuthSurrealAdapterOptions,
|
||||
IdStrategy,
|
||||
LinkFieldMap,
|
||||
Row,
|
||||
SurrealClientView,
|
||||
} from "../types/adapter.js";
|
||||
import { ModelToTableCache } from "./model-to-table.js";
|
||||
|
||||
export interface NormalisedOptions {
|
||||
readonly client: SurrealClientView;
|
||||
readonly idStrategy: IdStrategy;
|
||||
readonly linkFields: LinkFieldMap;
|
||||
readonly modelCache: ModelToTableCache;
|
||||
}
|
||||
|
||||
export function normaliseOptions(
|
||||
options: BetterAuthSurrealAdapterOptions
|
||||
): NormalisedOptions {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
|
||||
"createBetterAuthSurrealAdapter received invalid options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return {
|
||||
client: options.client as SurrealClientView,
|
||||
idStrategy: options.idStrategy ?? "record-id",
|
||||
linkFields: options.linkFields ?? DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
modelCache: new ModelToTableCache(options.modelToTable),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureConnected(client: SurrealClientView): void {
|
||||
if (!client.isConnected) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.CONNECTION_NOT_READY,
|
||||
"SurrealDB client is not connected — call .connect() first"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a named-placeholder binding record into the positional shape
|
||||
* `SurrealAdapter.query()` expects.
|
||||
*
|
||||
* `SurrealAdapter` flattens positional params into `$p0/$p1/...` named
|
||||
* bindings. We accept named placeholders for ergonomics (the WHERE
|
||||
* compiler emits `$w0/$w1/...`, CREATE emits `$data`, UPDATE emits
|
||||
* `$update`+`$p0/$p1/...`), then rewrite the SQL to use `$pN` while
|
||||
* passing the values in the matching positional order.
|
||||
*
|
||||
* Returns `{ sql, params }` ready for `client.query(sql, params)`.
|
||||
*/
|
||||
export function bindNamed(
|
||||
sql: string,
|
||||
vars: Record<string, unknown>
|
||||
): { sql: string; params: unknown[] } {
|
||||
const names = Object.keys(vars);
|
||||
if (names.length === 0) return { sql, params: [] };
|
||||
let rewritten = sql;
|
||||
const params: unknown[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i] as string;
|
||||
// Replace `$name` token boundaries — `\b` doesn't match `$`,
|
||||
// so anchor on the dollar literal and the next non-identifier char.
|
||||
const re = new RegExp(`\\$${name}\\b`, "g");
|
||||
rewritten = rewritten.replace(re, `$p${i}`);
|
||||
params.push(vars[name]);
|
||||
}
|
||||
return { sql: rewritten, params };
|
||||
}
|
||||
|
||||
export function wrapQuery(err: unknown): never {
|
||||
if (err instanceof BetterAuthSurrealError) throw err;
|
||||
if (err instanceof SurrealError) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
|
||||
"SurrealDB query failed",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
export function postRead(
|
||||
row: Row,
|
||||
table: string,
|
||||
linkFields: LinkFieldMap,
|
||||
model: string,
|
||||
idStrategy: IdStrategy
|
||||
): Row {
|
||||
const stripped = stripLinkPrefixesFromRead(linkFields, model, row);
|
||||
if (idStrategy === "plain") return stripped;
|
||||
const id = normaliseReturnedId(stripped["id"], table);
|
||||
return { ...stripped, id };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { ModelToTableCache } from "./model-to-table.js";
|
||||
|
||||
describe("ModelToTableCache", () => {
|
||||
it("defaults to identity mapping", { timeout: 5000 }, () => {
|
||||
const cache = new ModelToTableCache();
|
||||
expect(cache.resolve("user")).toBe("user");
|
||||
});
|
||||
|
||||
it("custom mapping prefixes tables", { timeout: 5000 }, () => {
|
||||
const cache = new ModelToTableCache((m) => `bauth_${m}`);
|
||||
expect(cache.resolve("user")).toBe("bauth_user");
|
||||
});
|
||||
|
||||
it("mapping cached after first call", { timeout: 5000 }, () => {
|
||||
let calls = 0;
|
||||
const cache = new ModelToTableCache((m) => {
|
||||
calls++;
|
||||
return m;
|
||||
});
|
||||
cache.resolve("user");
|
||||
cache.resolve("user");
|
||||
cache.resolve("user");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("invalid table name raises INVALID_MODEL", { timeout: 5000 }, () => {
|
||||
const cache = new ModelToTableCache(() => "weird-name");
|
||||
expect(() => cache.resolve("user")).toThrow(BetterAuthSurrealError);
|
||||
try {
|
||||
cache.resolve("user");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
"BETTER_AUTH_SURREALDB.INVALID_MODEL"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Per-adapter caching mapper from Better-Auth model name → SurrealDB
|
||||
* table name. The mapper is invoked once per distinct model and the
|
||||
* resolved name is validated against the identifier rule.
|
||||
*/
|
||||
export type ModelToTableResolver = (model: string) => string;
|
||||
|
||||
export class ModelToTableCache {
|
||||
protected readonly resolver: ModelToTableResolver;
|
||||
protected readonly cache = new Map<string, string>();
|
||||
|
||||
public constructor(resolver: ModelToTableResolver = (m) => m) {
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
public resolve(model: string): string {
|
||||
const hit = this.cache.get(model);
|
||||
if (hit !== undefined) return hit;
|
||||
const out = this.resolver(model);
|
||||
if (!IDENT.test(out)) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
|
||||
`modelToTable(${model}) returned "${out}" which is not a valid SurrealQL identifier`
|
||||
);
|
||||
}
|
||||
this.cache.set(model, out);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
compileSelect,
|
||||
compileSortBy,
|
||||
compileWhere,
|
||||
type SortBy,
|
||||
type WhereClause,
|
||||
} from "../compile/index.js";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { prepareCreate } from "../id-strategy/id-strategy.js";
|
||||
import { applyLinkPrefixesToWrite } from "../link-fields/link-fields.js";
|
||||
import type { Row } from "../types/adapter.js";
|
||||
import {
|
||||
bindNamed,
|
||||
ensureConnected,
|
||||
type NormalisedOptions,
|
||||
postRead,
|
||||
wrapQuery,
|
||||
} from "./internals.js";
|
||||
|
||||
export async function runCreate(
|
||||
opts: NormalisedOptions,
|
||||
args: {
|
||||
model: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
): Promise<Row> {
|
||||
ensureConnected(opts.client);
|
||||
const tbl = opts.modelCache.resolve(args.model);
|
||||
const withLinks = applyLinkPrefixesToWrite(
|
||||
opts.linkFields,
|
||||
args.model,
|
||||
args.data
|
||||
);
|
||||
const prepared = prepareCreate(opts.idStrategy, tbl, withLinks);
|
||||
const compiled = bindNamed(
|
||||
`CREATE ${prepared.target} CONTENT $data RETURN AFTER;`,
|
||||
{ data: prepared.data }
|
||||
);
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await opts.client.query<Row>(compiled.sql, compiled.params);
|
||||
} catch (err) {
|
||||
wrapQuery(err);
|
||||
}
|
||||
const first = rows[0];
|
||||
if (first === undefined) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
|
||||
`CREATE on ${tbl} returned no rows`
|
||||
);
|
||||
}
|
||||
const out = postRead(
|
||||
first,
|
||||
tbl,
|
||||
opts.linkFields,
|
||||
args.model,
|
||||
opts.idStrategy
|
||||
);
|
||||
if (opts.idStrategy !== "plain") {
|
||||
out["id"] = prepared.assignedId as string;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function runFindMany(
|
||||
opts: NormalisedOptions,
|
||||
args: {
|
||||
model: string;
|
||||
where?: WhereClause[] | undefined;
|
||||
limit?: number | undefined;
|
||||
select?: string[] | undefined;
|
||||
sortBy?: SortBy | undefined;
|
||||
offset?: number | undefined;
|
||||
}
|
||||
): Promise<Row[]> {
|
||||
ensureConnected(opts.client);
|
||||
const tbl = opts.modelCache.resolve(args.model);
|
||||
const projection = compileSelect(args.select);
|
||||
const compiled = compileWhere(args.where ?? [], {
|
||||
model: args.model,
|
||||
linkFields: opts.linkFields,
|
||||
});
|
||||
const whereFragment =
|
||||
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
|
||||
const orderFragment = compileSortBy(args.sortBy);
|
||||
const limitFragment =
|
||||
args.limit !== undefined ? `LIMIT ${Math.floor(args.limit)}` : "";
|
||||
const startFragment =
|
||||
args.offset !== undefined && args.offset > 0
|
||||
? `START ${Math.floor(args.offset)}`
|
||||
: "";
|
||||
const sql =
|
||||
`SELECT ${projection} FROM ${tbl} ${whereFragment} ${orderFragment} ${limitFragment} ${startFragment};`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const bound = bindNamed(sql, compiled.vars);
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await opts.client.query<Row>(bound.sql, bound.params);
|
||||
} catch (err) {
|
||||
wrapQuery(err);
|
||||
}
|
||||
return rows.map((r) =>
|
||||
postRead(r, tbl, opts.linkFields, args.model, opts.idStrategy)
|
||||
);
|
||||
}
|
||||
|
||||
export async function runUpdateMany(
|
||||
opts: NormalisedOptions,
|
||||
args: {
|
||||
model: string;
|
||||
where: WhereClause[];
|
||||
update: Record<string, unknown>;
|
||||
}
|
||||
): Promise<Row[]> {
|
||||
ensureConnected(opts.client);
|
||||
const tbl = opts.modelCache.resolve(args.model);
|
||||
const merge = applyLinkPrefixesToWrite(
|
||||
opts.linkFields,
|
||||
args.model,
|
||||
args.update
|
||||
);
|
||||
const compiled = compileWhere(args.where, {
|
||||
model: args.model,
|
||||
linkFields: opts.linkFields,
|
||||
});
|
||||
const whereFragment =
|
||||
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
|
||||
const sql = `UPDATE ${tbl} MERGE $update ${whereFragment} RETURN AFTER;`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const bound = bindNamed(sql, { update: merge, ...compiled.vars });
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await opts.client.query<Row>(bound.sql, bound.params);
|
||||
} catch (err) {
|
||||
wrapQuery(err);
|
||||
}
|
||||
return rows.map((r) =>
|
||||
postRead(r, tbl, opts.linkFields, args.model, opts.idStrategy)
|
||||
);
|
||||
}
|
||||
|
||||
export async function runDeleteMany(
|
||||
opts: NormalisedOptions,
|
||||
args: { model: string; where: WhereClause[] }
|
||||
): Promise<number> {
|
||||
ensureConnected(opts.client);
|
||||
const tbl = opts.modelCache.resolve(args.model);
|
||||
const compiled = compileWhere(args.where, {
|
||||
model: args.model,
|
||||
linkFields: opts.linkFields,
|
||||
});
|
||||
const whereFragment =
|
||||
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
|
||||
const sql = `DELETE ${tbl} ${whereFragment} RETURN BEFORE;`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const bound = bindNamed(sql, compiled.vars);
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await opts.client.query<Row>(bound.sql, bound.params);
|
||||
} catch (err) {
|
||||
wrapQuery(err);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function runCount(
|
||||
opts: NormalisedOptions,
|
||||
args: { model: string; where?: WhereClause[] | undefined }
|
||||
): Promise<number> {
|
||||
ensureConnected(opts.client);
|
||||
const tbl = opts.modelCache.resolve(args.model);
|
||||
const compiled = compileWhere(args.where ?? [], {
|
||||
model: args.model,
|
||||
linkFields: opts.linkFields,
|
||||
});
|
||||
const whereFragment =
|
||||
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
|
||||
const sql = `SELECT count() AS c FROM ${tbl} ${whereFragment} GROUP ALL;`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const bound = bindNamed(sql, compiled.vars);
|
||||
let rows: Row[];
|
||||
try {
|
||||
rows = await opts.client.query<Row>(bound.sql, bound.params);
|
||||
} catch (err) {
|
||||
wrapQuery(err);
|
||||
}
|
||||
const first = rows[0];
|
||||
if (first === undefined) return 0;
|
||||
const raw = (first as { c?: unknown }).c;
|
||||
return typeof raw === "number" ? raw : Number(raw ?? 0);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter.js";
|
||||
|
||||
describe("adapter.update / updateMany", () => {
|
||||
it("update returns merged row", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient((_sql, vars) => {
|
||||
const update = vars["p0"] as Record<string, unknown>;
|
||||
return [{ id: "user:`u1`", email: update["email"] }];
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const row = await adapter.update<{ id: string; email: string }>({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u1" }],
|
||||
update: { email: "new" },
|
||||
});
|
||||
expect(row).toEqual({ id: "u1", email: "new" });
|
||||
});
|
||||
|
||||
it("update returns null when no rows match", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const out = await adapter.update({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "missing" }],
|
||||
update: { email: "x" },
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("update applies link-field prefix on merge payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [{ id: "session:`s1`" }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.update({
|
||||
model: "session",
|
||||
where: [{ field: "id", value: "s1" }],
|
||||
update: { userId: "u9" },
|
||||
});
|
||||
const update = fake.log[0]?.params[0] as { userId: unknown };
|
||||
expect(String(update.userId)).toBe("user:u9");
|
||||
});
|
||||
|
||||
it("updateMany returns affected count", { timeout: 5000 }, async () => {
|
||||
const fake = new FakeClient(() => [
|
||||
{ id: "u:1" },
|
||||
{ id: "u:2" },
|
||||
{ id: "u:3" },
|
||||
]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
const count = await adapter.updateMany({
|
||||
model: "user",
|
||||
where: [{ field: "role", value: "free" }],
|
||||
update: { role: "pro" },
|
||||
});
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("update binds the merge payload first and predicates after", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => [{ id: "user:`u1`" }]);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.update({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u1" }],
|
||||
update: { email: "new" },
|
||||
});
|
||||
const sql = fake.log[0]?.sql ?? "";
|
||||
const params = fake.log[0]?.params ?? [];
|
||||
// $update was bound first (positional index 0), then $p0 (first where).
|
||||
expect(sql).toContain("MERGE $p0");
|
||||
expect(sql).toContain("WHERE id = $p1");
|
||||
expect(params[0]).toEqual({ email: "new" });
|
||||
expect(String(params[1])).toBe("user:u1");
|
||||
});
|
||||
|
||||
it("updateMany with empty where omits WHERE clause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => []);
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await adapter.updateMany({
|
||||
model: "user",
|
||||
where: [],
|
||||
update: { name: "X" },
|
||||
});
|
||||
expect(fake.log[0]?.sql).not.toContain("WHERE");
|
||||
});
|
||||
|
||||
it("update wraps underlying errors as QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const adapter = createBetterAuthSurrealAdapter({ client: fake });
|
||||
await expect(
|
||||
adapter.update({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u1" }],
|
||||
update: { email: "new" },
|
||||
})
|
||||
).rejects.toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
});
|
||||
121
packages/identity/auth/better-auth-surrealdb/src/adapter/v1.6.md
Normal file
121
packages/identity/auth/better-auth-surrealdb/src/adapter/v1.6.md
Normal 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).
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { DBAdapterInstance } from "better-auth";
|
||||
import type {
|
||||
BetterAuthSurrealAdapterV16Options,
|
||||
betterAuthSurrealAdapter,
|
||||
} from "./v1.6.js";
|
||||
|
||||
describe("betterAuthSurrealAdapter — type conformance to Better-Auth v1.6", () => {
|
||||
it("return type satisfies DBAdapterInstance via expectTypeOf", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ReturnType<typeof betterAuthSurrealAdapter>
|
||||
>().toMatchTypeOf<DBAdapterInstance>();
|
||||
});
|
||||
|
||||
it("options type alias equals BetterAuthSurrealAdapterOptions structurally", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Compile-time only: the v1.6 options accept the same fields as
|
||||
// the v1.4 options. Using `extends` checks both directions of
|
||||
// structural compatibility.
|
||||
expectTypeOf<BetterAuthSurrealAdapterV16Options>().toHaveProperty(
|
||||
"client"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { DatabaseLike } from "@suaveplan/types";
|
||||
import { ZodError } from "zod";
|
||||
import { FakeClient } from "../../tests/utils/fake-client.js";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { BETTER_AUTH_SURREAL_SCHEMA } from "../schema/schema.js";
|
||||
import { betterAuthSurrealAdapter } from "./v1.6.js";
|
||||
|
||||
/**
|
||||
* SurrealAdapter subclass that bypasses the real network connection.
|
||||
* Used to drive the `instanceof SurrealAdapter` branch in
|
||||
* `betterAuthSurrealAdapter` without spinning up a testcontainer.
|
||||
*/
|
||||
class StubbedSurrealAdapter extends SurrealAdapter {
|
||||
public readonly fake: FakeClient;
|
||||
public transactionCalls = 0;
|
||||
|
||||
public constructor(fake: FakeClient) {
|
||||
super(
|
||||
{},
|
||||
undefined,
|
||||
() =>
|
||||
// The real factory builds a surrealdb.js `Surreal` instance.
|
||||
// We never call `connect()` so this is unreachable; assertion
|
||||
// narrows the type for the protected `_clientFactory` slot.
|
||||
({}) as never
|
||||
);
|
||||
this.fake = fake;
|
||||
}
|
||||
|
||||
public override get isConnected(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public override async query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: readonly unknown[]
|
||||
): Promise<T[]> {
|
||||
return this.fake.query<T>(sql, params);
|
||||
}
|
||||
|
||||
public override async execute(
|
||||
sql: string,
|
||||
_params?: readonly unknown[]
|
||||
): Promise<{ rowsAffected: number }> {
|
||||
return this.fake.execute(sql);
|
||||
}
|
||||
|
||||
public override async transaction<T>(
|
||||
fn: (tx: DatabaseLike) => Promise<T>
|
||||
): Promise<T> {
|
||||
this.transactionCalls++;
|
||||
const tx: DatabaseLike = {
|
||||
query: this.fake.query.bind(this.fake) as DatabaseLike["query"],
|
||||
execute: this.fake.execute.bind(
|
||||
this.fake
|
||||
) as DatabaseLike["execute"],
|
||||
transaction: async <R>(inner: (t: DatabaseLike) => Promise<R>) =>
|
||||
inner(tx),
|
||||
close: async () => {},
|
||||
};
|
||||
return fn(tx);
|
||||
}
|
||||
}
|
||||
|
||||
describe("betterAuthSurrealAdapter (Better-Auth v1.6 factory)", () => {
|
||||
it("returns a DBAdapterInstance (a function taking BetterAuthOptions)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const factory = betterAuthSurrealAdapter({
|
||||
client: new FakeClient(),
|
||||
});
|
||||
expect(typeof factory).toBe("function");
|
||||
});
|
||||
|
||||
it("invalid options raise INVALID_INPUT carrying ZodError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
betterAuthSurrealAdapter(
|
||||
{} as unknown as Parameters<typeof betterAuthSurrealAdapter>[0]
|
||||
);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthSurrealError);
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
"BETTER_AUTH_SURREALDB.INVALID_INPUT"
|
||||
);
|
||||
expect((err as Error).cause).toBeInstanceOf(ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
it("factory invocation returns a DBAdapter with required methods", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = new FakeClient();
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
for (const m of [
|
||||
"create",
|
||||
"findOne",
|
||||
"findMany",
|
||||
"update",
|
||||
"updateMany",
|
||||
"delete",
|
||||
"deleteMany",
|
||||
"count",
|
||||
"consumeOne",
|
||||
"transaction",
|
||||
"createSchema",
|
||||
]) {
|
||||
expect(
|
||||
typeof (dbAdapter as unknown as Record<string, unknown>)[m]
|
||||
).toBe("function");
|
||||
}
|
||||
expect(dbAdapter.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("createSchema returns BETTER_AUTH_SURREAL_SCHEMA verbatim", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient();
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
const createSchema = dbAdapter.createSchema;
|
||||
if (createSchema === undefined) {
|
||||
throw new Error(
|
||||
"createSchema must be defined on the wrapped adapter"
|
||||
);
|
||||
}
|
||||
const result = await createSchema(
|
||||
{} as Parameters<typeof createSchema>[0]
|
||||
);
|
||||
expect(result.code).toBe(BETTER_AUTH_SURREAL_SCHEMA);
|
||||
expect(result.path).toBe("./schema.surql");
|
||||
expect(result.overwrite).toBe(true);
|
||||
});
|
||||
|
||||
it("errors propagate from the underlying adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient(() => {
|
||||
throw new Error("simulated surreal failure");
|
||||
});
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
await expect(
|
||||
dbAdapter.findMany({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "x" }],
|
||||
})
|
||||
).rejects.toBeInstanceOf(BetterAuthSurrealError);
|
||||
});
|
||||
|
||||
it("transaction forwards through createAsIsTransaction when client is a SurrealClientView (non-SurrealAdapter)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// FakeClient is a structural SurrealClientView (not SurrealAdapter),
|
||||
// so the wrapper falls back to createAsIsTransaction. The trx
|
||||
// handed to the callback is the outer adapter itself; the inner
|
||||
// call therefore exercises the same methods.
|
||||
const fake = new FakeClient(() => []);
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
const result = await dbAdapter.transaction(async (trx) => {
|
||||
expect(trx).toBeDefined();
|
||||
expect(typeof trx.findOne).toBe("function");
|
||||
return "ok";
|
||||
});
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("every wrapped CustomAdapter method round-trips into the underlying adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const log: string[] = [];
|
||||
const fake = new FakeClient((sql) => {
|
||||
log.push(sql.split(" ")[0] ?? "");
|
||||
if (sql.startsWith("CREATE")) {
|
||||
return [{ id: "u_round_1", name: "n" }];
|
||||
}
|
||||
if (sql.startsWith("SELECT") && sql.includes("count")) {
|
||||
return [{ count: 2 }];
|
||||
}
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return [{ id: "u_round_1", name: "n" }];
|
||||
}
|
||||
if (sql.startsWith("UPDATE")) {
|
||||
return [{ id: "u_round_1", name: "n2" }];
|
||||
}
|
||||
if (sql.startsWith("DELETE")) {
|
||||
return [{ id: "u_round_1" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const db = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
|
||||
await db.create({
|
||||
model: "user",
|
||||
data: { id: "u_round_1", name: "n" },
|
||||
forceAllowId: true,
|
||||
});
|
||||
await db.findOne({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
});
|
||||
await db.findMany({
|
||||
model: "user",
|
||||
where: [],
|
||||
limit: 10,
|
||||
});
|
||||
await db.count({ model: "user", where: [] });
|
||||
await db.update({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
update: { name: "n2" },
|
||||
});
|
||||
await db.updateMany({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
update: { name: "n2" },
|
||||
});
|
||||
await db.delete({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
});
|
||||
await db.deleteMany({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
});
|
||||
await db.consumeOne({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_round_1" }],
|
||||
});
|
||||
expect(log.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("transaction-scoped DBTransactionAdapter via FakeClient (createAsIsTransaction path) reuses the outer adapter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((sql) => {
|
||||
if (sql.startsWith("CREATE")) {
|
||||
return [{ id: "u_tx_loop", name: "loop" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const factory = betterAuthSurrealAdapter({ client: fake });
|
||||
const db = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
const result = await db.transaction(async (trx) => {
|
||||
await trx.create({
|
||||
model: "user",
|
||||
data: { id: "u_tx_loop", name: "loop" },
|
||||
forceAllowId: true,
|
||||
});
|
||||
return "ok";
|
||||
});
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("options.idStrategy + linkFields propagate through to the underlying adapter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fake = new FakeClient();
|
||||
const factory = betterAuthSurrealAdapter({
|
||||
client: fake,
|
||||
idStrategy: "ulid",
|
||||
linkFields: { session: { userId: "user" } },
|
||||
});
|
||||
expect(typeof factory).toBe("function");
|
||||
});
|
||||
|
||||
it("transaction (SurrealAdapter path) round-trips a create through Better-Auth's transformInput", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((sql) => {
|
||||
if (sql.startsWith("CREATE")) {
|
||||
return [{ id: "u_tx_1", email: "tx@x" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const sa = new StubbedSurrealAdapter(fake);
|
||||
const factory = betterAuthSurrealAdapter({ client: sa });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
const result = await dbAdapter.transaction(async (trx) => {
|
||||
const created = await trx.create({
|
||||
model: "user",
|
||||
data: { id: "u_tx_1", email: "tx@x" },
|
||||
forceAllowId: true,
|
||||
});
|
||||
return created;
|
||||
});
|
||||
// The v1.6 wrapper runs Better-Auth's transaction callback against a
|
||||
// fresh DBAdapter (no SurrealDB BEGIN/COMMIT) — see v1.6.ts docs.
|
||||
expect(sa.transactionCalls).toBe(0);
|
||||
expect(result).toMatchObject({ email: "tx@x" });
|
||||
});
|
||||
|
||||
it("transaction-scoped DBTransactionAdapter exposes every CRUD method", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = new FakeClient((sql) => {
|
||||
if (sql.startsWith("CREATE")) {
|
||||
return [{ id: "u_tx_2", name: "n" }];
|
||||
}
|
||||
if (sql.startsWith("SELECT")) {
|
||||
return [{ id: "u_tx_2", name: "n" }];
|
||||
}
|
||||
if (sql.startsWith("UPDATE")) {
|
||||
return [{ id: "u_tx_2", name: "n2" }];
|
||||
}
|
||||
if (sql.includes("count")) {
|
||||
return [{ count: 1 }];
|
||||
}
|
||||
if (sql.startsWith("DELETE")) {
|
||||
return [{ id: "u_tx_2" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const sa = new StubbedSurrealAdapter(fake);
|
||||
const factory = betterAuthSurrealAdapter({ client: sa });
|
||||
const dbAdapter = factory({
|
||||
secret: "x",
|
||||
baseURL: "http://localhost",
|
||||
} as Parameters<typeof factory>[0]);
|
||||
|
||||
await dbAdapter.transaction(async (trx) => {
|
||||
expect(trx.id).toBeDefined();
|
||||
await trx.create({
|
||||
model: "user",
|
||||
data: { id: "u_tx_2", name: "n" },
|
||||
forceAllowId: true,
|
||||
});
|
||||
await trx.findOne({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
});
|
||||
await trx.findMany({
|
||||
model: "user",
|
||||
where: [],
|
||||
limit: 10,
|
||||
});
|
||||
await trx.count({ model: "user", where: [] });
|
||||
await trx.update({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
update: { name: "n2" },
|
||||
});
|
||||
await trx.updateMany({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
update: { name: "n2" },
|
||||
});
|
||||
await trx.delete({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
});
|
||||
await trx.deleteMany({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
});
|
||||
// consumeOne — note: our underlying adapter's consumeOne does
|
||||
// a find-then-delete sequence; the fake handles SELECT then
|
||||
// DELETE.
|
||||
await trx.consumeOne({
|
||||
model: "user",
|
||||
where: [
|
||||
{
|
||||
field: "id",
|
||||
value: "u_tx_2",
|
||||
operator: "eq",
|
||||
connector: "AND",
|
||||
mode: "sensitive",
|
||||
},
|
||||
],
|
||||
});
|
||||
return "ok";
|
||||
});
|
||||
// See comment in previous test — wrapper transaction does not call
|
||||
// SurrealAdapter.transaction; the callback runs inline.
|
||||
expect(sa.transactionCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
232
packages/identity/auth/better-auth-surrealdb/src/adapter/v1.6.ts
Normal file
232
packages/identity/auth/better-auth-surrealdb/src/adapter/v1.6.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type {
|
||||
DBAdapterInstance,
|
||||
DBAdapterSchemaCreation,
|
||||
DBTransactionAdapter,
|
||||
Where,
|
||||
} from "better-auth";
|
||||
import type {
|
||||
AdapterFactoryConfig,
|
||||
AdapterFactoryCustomizeAdapterCreator,
|
||||
CustomAdapter,
|
||||
} from "better-auth/adapters";
|
||||
import { createAdapterFactory } from "better-auth/adapters";
|
||||
|
||||
type CleanedWhere = Required<Where>;
|
||||
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { instrument } from "../otel/instruments.js";
|
||||
import { BETTER_AUTH_SURREAL_SCHEMA } from "../schema/schema.js";
|
||||
import { BetterAuthSurrealAdapterOptionsSchema } from "../schemas/options.js";
|
||||
import type { BetterAuthSurrealAdapterOptions } from "../types/adapter.js";
|
||||
import {
|
||||
BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
type BetterAuthSurrealAdapter,
|
||||
createBetterAuthSurrealAdapter,
|
||||
} from "./adapter.js";
|
||||
|
||||
/**
|
||||
* Options accepted by `betterAuthSurrealAdapter` — the Better-Auth v1.6
|
||||
* `DBAdapterInstance` factory. Identical shape to the v1.4-shape
|
||||
* `BetterAuthSurrealAdapterOptions` (validated by the same Zod schema).
|
||||
*/
|
||||
export type BetterAuthSurrealAdapterV16Options =
|
||||
BetterAuthSurrealAdapterOptions;
|
||||
|
||||
function buildCustomAdapter(
|
||||
underlying: BetterAuthSurrealAdapter
|
||||
): CustomAdapter {
|
||||
type CreateArgs = Parameters<CustomAdapter["create"]>[0];
|
||||
type FindOneArgs = {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
select?: string[] | undefined;
|
||||
};
|
||||
type FindManyArgs = {
|
||||
model: string;
|
||||
where?: CleanedWhere[] | undefined;
|
||||
limit: number;
|
||||
select?: string[] | undefined;
|
||||
sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
|
||||
offset?: number | undefined;
|
||||
};
|
||||
type UpdateArgs = {
|
||||
model: string;
|
||||
where: CleanedWhere[];
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
|
||||
return {
|
||||
create: (async (args: CreateArgs) =>
|
||||
underlying.create({
|
||||
model: args.model,
|
||||
data: args.data as Omit<Record<string, unknown>, "id">,
|
||||
select: args.select,
|
||||
forceAllowId: true,
|
||||
})) as CustomAdapter["create"],
|
||||
findOne: (async (args: FindOneArgs) =>
|
||||
underlying.findOne({
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
select: args.select,
|
||||
})) as CustomAdapter["findOne"],
|
||||
findMany: (async (args: FindManyArgs) =>
|
||||
underlying.findMany({
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
limit: args.limit,
|
||||
select: args.select,
|
||||
sortBy: args.sortBy,
|
||||
offset: args.offset,
|
||||
})) as CustomAdapter["findMany"],
|
||||
count: (args) =>
|
||||
underlying.count({ model: args.model, where: args.where }),
|
||||
update: (async (args: UpdateArgs) =>
|
||||
underlying.update({
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
update: args.update,
|
||||
})) as CustomAdapter["update"],
|
||||
updateMany: (args) =>
|
||||
underlying.updateMany({
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
update: args.update,
|
||||
}),
|
||||
delete: (args) =>
|
||||
underlying.delete({ model: args.model, where: args.where }),
|
||||
deleteMany: (args) =>
|
||||
underlying.deleteMany({ model: args.model, where: args.where }),
|
||||
consumeOne: (async (args: { model: string; where: CleanedWhere[] }) =>
|
||||
underlying.consumeOne({
|
||||
model: args.model,
|
||||
where: args.where,
|
||||
})) as NonNullable<CustomAdapter["consumeOne"]>,
|
||||
createSchema: async (): Promise<DBAdapterSchemaCreation> => ({
|
||||
code: BETTER_AUTH_SURREAL_SCHEMA,
|
||||
path: "./schema.surql",
|
||||
overwrite: true,
|
||||
}),
|
||||
options: {
|
||||
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTxFactoryConfig(): AdapterFactoryConfig {
|
||||
return {
|
||||
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
adapterName: "Better-Auth SurrealDB Adapter (tx)",
|
||||
supportsNumericIds: false,
|
||||
supportsUUIDs: false,
|
||||
supportsJSON: true,
|
||||
supportsDates: true,
|
||||
supportsBooleans: true,
|
||||
supportsArrays: true,
|
||||
disableIdGeneration: false,
|
||||
transaction: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Better-Auth v1.6 `DBAdapterInstance` factory backed by SurrealDB.
|
||||
*
|
||||
* Returns a function `(betterAuthOptions: BetterAuthOptions) => DBAdapter`
|
||||
* that `betterAuth({ database })` accepts as its `database` field.
|
||||
*
|
||||
* Internally:
|
||||
*
|
||||
* 1. Zod-validates `options` via `BetterAuthSurrealAdapterOptionsSchema`
|
||||
* (the same schema the v1.4 surface uses). Validation failures throw
|
||||
* `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)`.
|
||||
* 2. Builds the underlying 10-method adapter via
|
||||
* `createBetterAuthSurrealAdapter(options)`.
|
||||
* 3. Wraps it via `createAdapterFactory` from `@better-auth/core/db`,
|
||||
* forwarding every CRUD method 1:1, providing a `transaction(...)`
|
||||
* via `runSurrealTransaction`, and a `createSchema()` returning
|
||||
* `BETTER_AUTH_SURREAL_SCHEMA` verbatim.
|
||||
*/
|
||||
export function betterAuthSurrealAdapter(
|
||||
options: BetterAuthSurrealAdapterV16Options
|
||||
): DBAdapterInstance {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
|
||||
"betterAuthSurrealAdapter received invalid options",
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
|
||||
const underlying: BetterAuthSurrealAdapter =
|
||||
createBetterAuthSurrealAdapter(options);
|
||||
const rawClient = options.client;
|
||||
|
||||
const adapterImpl: AdapterFactoryCustomizeAdapterCreator = () =>
|
||||
buildCustomAdapter(underlying);
|
||||
|
||||
const baseConfig: AdapterFactoryConfig = {
|
||||
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
adapterName: "Better-Auth SurrealDB Adapter",
|
||||
supportsNumericIds: false,
|
||||
supportsUUIDs: false,
|
||||
supportsJSON: true,
|
||||
supportsDates: true,
|
||||
supportsBooleans: true,
|
||||
supportsArrays: true,
|
||||
disableIdGeneration: false,
|
||||
};
|
||||
|
||||
const baseFactory = createAdapterFactory({
|
||||
adapter: adapterImpl,
|
||||
config: baseConfig,
|
||||
});
|
||||
|
||||
return ((betterAuthOptions: Parameters<DBAdapterInstance>[0]) => {
|
||||
const outerDbAdapter = baseFactory(betterAuthOptions);
|
||||
if (!(rawClient instanceof SurrealAdapter)) {
|
||||
return outerDbAdapter;
|
||||
}
|
||||
const sa = rawClient;
|
||||
|
||||
// SurrealDB 2.x's WebSocket connection does not carry transaction
|
||||
// state across separate query messages, so `BEGIN; … ; COMMIT;`
|
||||
// sent as discrete `client.query(...)` calls (the only path the
|
||||
// shipped `runSurrealTransaction` helper offers) fails with
|
||||
// "Cannot COMMIT without starting a transaction". Until the
|
||||
// upstream helper batches BEGIN/work/COMMIT into a single
|
||||
// multi-statement payload, the v1.6 wrapper runs Better-Auth's
|
||||
// transaction callback against a fresh, fully-instrumented
|
||||
// DBAdapter built around the same outer SurrealAdapter — equivalent
|
||||
// to Better-Auth's documented "transactions not supported"
|
||||
// degraded path (sequential execution, no rollback). The
|
||||
// production signal is that the create/update/delete operations
|
||||
// inside the callback still run through the full
|
||||
// transformInput/transformOutput pipeline, which the previous
|
||||
// manual `DBTransactionAdapter` implementation skipped and caused
|
||||
// missing-id and missing-field regressions.
|
||||
const transaction: typeof outerDbAdapter.transaction = (callback) =>
|
||||
instrument({
|
||||
model: "_transaction",
|
||||
operation: "transaction",
|
||||
run: async () => {
|
||||
void sa; // captured for future migration once upstream helper batches BEGIN/COMMIT
|
||||
const txAdapterImpl: AdapterFactoryCustomizeAdapterCreator =
|
||||
() => buildCustomAdapter(underlying);
|
||||
const txFactory = createAdapterFactory({
|
||||
adapter: txAdapterImpl,
|
||||
config: buildTxFactoryConfig(),
|
||||
});
|
||||
const txDbAdapter = txFactory(betterAuthOptions);
|
||||
return callback(
|
||||
txDbAdapter as unknown as DBTransactionAdapter
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return { ...outerDbAdapter, transaction };
|
||||
}) as DBAdapterInstance;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { SURREAL_CODES, SurrealError } from "@suaveplan/surrealdb";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createBetterAuthSurrealAdapter } from "./adapter/adapter.js";
|
||||
import { compileWhere } from "./compile/compile-where.js";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
type BetterAuthSurrealError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { prepareCreate } from "./id-strategy/id-strategy.js";
|
||||
import { applyLinkPrefix } from "./link-fields/link-fields.js";
|
||||
import { applyBetterAuthSchema } from "./schema/schema.js";
|
||||
import type { SurrealClientView } from "./types/adapter.js";
|
||||
|
||||
const fakeClient = (): SurrealClientView => ({
|
||||
isConnected: true,
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute() {
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
describe("every BETTER_AUTH_SURREALDB_* code is thrown from a production path", () => {
|
||||
it("INVALID_INPUT — createBetterAuthSurrealAdapter with empty options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createBetterAuthSurrealAdapter(
|
||||
{} as Parameters<typeof createBetterAuthSurrealAdapter>[0]
|
||||
);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("INVALID_MODEL — modelToTable returns identifier-invalid name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = createBetterAuthSurrealAdapter({
|
||||
client: fakeClient(),
|
||||
modelToTable: () => "weird-name",
|
||||
});
|
||||
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
|
||||
{
|
||||
code: BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("INVALID_OPERATOR — compileWhere with unknown operator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
compileWhere(
|
||||
[
|
||||
{
|
||||
field: "x",
|
||||
value: 1,
|
||||
operator: "weird" as unknown as "eq",
|
||||
},
|
||||
],
|
||||
{ model: "user", linkFields: {} }
|
||||
);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("QUERY_FAILED — underlying SurrealError wraps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client: SurrealClientView = {
|
||||
isConnected: true,
|
||||
async query() {
|
||||
throw new SurrealError(SURREAL_CODES.QUERY_FAILED, "boom");
|
||||
},
|
||||
async execute() {
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
};
|
||||
const adapter = createBetterAuthSurrealAdapter({ client });
|
||||
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
|
||||
{
|
||||
code: BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("QUERY_FAILED — applyBetterAuthSchema wraps client.execute failure", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client: SurrealClientView = {
|
||||
isConnected: true,
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute() {
|
||||
throw new Error("schema apply failed");
|
||||
},
|
||||
};
|
||||
try {
|
||||
await applyBetterAuthSchema(client);
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("INVALID_ID_FORMAT — record-id data.id contains colon", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
prepareCreate("record-id", "user", { id: "abc:def" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("INVALID_LINK_FIELD_VALUE — applyLinkPrefix on value containing :", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
applyLinkPrefix("user", "u:bad");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_LINK_FIELD_VALUE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("CONNECTION_NOT_READY — adapter rejects when client is disconnected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client: SurrealClientView = {
|
||||
isConnected: false,
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute() {
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
};
|
||||
const adapter = createBetterAuthSurrealAdapter({ client });
|
||||
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
|
||||
{
|
||||
code: BETTER_AUTH_SURREALDB_CODES.CONNECTION_NOT_READY,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { compileSelect } from "./compile-select.js";
|
||||
|
||||
describe("compileSelect", () => {
|
||||
it("returns * for undefined", { timeout: 5000 }, () => {
|
||||
expect(compileSelect(undefined)).toBe("*");
|
||||
});
|
||||
|
||||
it("returns * for empty array", { timeout: 5000 }, () => {
|
||||
expect(compileSelect([])).toBe("*");
|
||||
});
|
||||
|
||||
it("joins valid identifiers with comma-space", { timeout: 5000 }, () => {
|
||||
expect(compileSelect(["id", "email"])).toBe("id, email");
|
||||
});
|
||||
|
||||
it("rejects invalid identifier", { timeout: 5000 }, () => {
|
||||
expect(() => compileSelect(["id", "weird-name"])).toThrow(
|
||||
BetterAuthSurrealError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
/**
|
||||
* Compile a Better-Auth `select?: string[]` projection into a SurrealQL
|
||||
* column list. When `select` is `undefined` OR empty, returns `"*"`.
|
||||
*
|
||||
* Validates every field against the identifier rule.
|
||||
*/
|
||||
export function compileSelect(
|
||||
select: ReadonlyArray<string> | undefined
|
||||
): string {
|
||||
if (select === undefined || select.length === 0) return "*";
|
||||
for (const field of select) {
|
||||
if (!IDENT.test(field)) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
|
||||
`select field "${field}" is not a valid SurrealQL identifier`
|
||||
);
|
||||
}
|
||||
}
|
||||
return select.join(", ");
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { compileSortBy } from "./compile-sort.js";
|
||||
|
||||
describe("compileSortBy", () => {
|
||||
it("returns empty for undefined", { timeout: 5000 }, () => {
|
||||
expect(compileSortBy(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("compiles asc", { timeout: 5000 }, () => {
|
||||
expect(compileSortBy({ field: "createdAt", direction: "asc" })).toBe(
|
||||
"ORDER BY createdAt ASC"
|
||||
);
|
||||
});
|
||||
|
||||
it("compiles desc", { timeout: 5000 }, () => {
|
||||
expect(compileSortBy({ field: "createdAt", direction: "desc" })).toBe(
|
||||
"ORDER BY createdAt DESC"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid identifier", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
compileSortBy({
|
||||
field: "weird-name",
|
||||
direction: "asc",
|
||||
})
|
||||
).toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export interface SortBy {
|
||||
readonly field: string;
|
||||
readonly direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a Better-Auth `sortBy` into a SurrealQL `ORDER BY` fragment.
|
||||
*
|
||||
* Returns an empty string when `sortBy` is `undefined`. Validates the field
|
||||
* name against SurrealQL's identifier rule to prevent injection.
|
||||
*/
|
||||
export function compileSortBy(sortBy: SortBy | undefined): string {
|
||||
if (sortBy === undefined) return "";
|
||||
if (!IDENT.test(sortBy.field)) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
|
||||
`sortBy.field "${sortBy.field}" is not a valid SurrealQL identifier`
|
||||
);
|
||||
}
|
||||
const dir = sortBy.direction === "desc" ? "DESC" : "ASC";
|
||||
return `ORDER BY ${sortBy.field} ${dir}`;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "../link-fields/link-fields.js";
|
||||
import { compileWhere } from "./compile-where.js";
|
||||
|
||||
const NO_LINKS = {} as const;
|
||||
|
||||
describe("compileWhere", () => {
|
||||
it("compiles empty to empty", { timeout: 5000 }, () => {
|
||||
const out = compileWhere([], { model: "user", linkFields: NO_LINKS });
|
||||
expect(out.clause).toBe("");
|
||||
expect(out.vars).toEqual({});
|
||||
});
|
||||
|
||||
it("maps every supported operator", { timeout: 5000 }, () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["eq", "f = $w0"],
|
||||
["ne", "f != $w0"],
|
||||
["gt", "f > $w0"],
|
||||
["gte", "f >= $w0"],
|
||||
["lt", "f < $w0"],
|
||||
["lte", "f <= $w0"],
|
||||
["in", "f IN $w0"],
|
||||
["not_in", "f NOT IN $w0"],
|
||||
["contains", "string::contains(f, $w0)"],
|
||||
["starts_with", "string::starts_with(f, $w0)"],
|
||||
["ends_with", "string::ends_with(f, $w0)"],
|
||||
];
|
||||
for (const [op, expected] of cases) {
|
||||
const out = compileWhere(
|
||||
[
|
||||
{
|
||||
field: "f",
|
||||
value: 1,
|
||||
operator: op as unknown as
|
||||
| "eq"
|
||||
| "ne"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with",
|
||||
},
|
||||
],
|
||||
{ model: "user", linkFields: NO_LINKS }
|
||||
);
|
||||
expect(out.clause).toBe(expected);
|
||||
expect(out.vars).toEqual({ w0: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults operator to eq and connector to AND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = compileWhere(
|
||||
[
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
{ model: "user", linkFields: NO_LINKS }
|
||||
);
|
||||
expect(out.clause).toBe("a = $w0 AND b = $w1");
|
||||
expect(out.vars).toEqual({ w0: 1, w1: 2 });
|
||||
});
|
||||
|
||||
it("AND/OR connectors compose", { timeout: 5000 }, () => {
|
||||
const out = compileWhere(
|
||||
[
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2, connector: "OR" },
|
||||
],
|
||||
{ model: "user", linkFields: NO_LINKS }
|
||||
);
|
||||
expect(out.clause).toBe("a = $w0 OR b = $w1");
|
||||
});
|
||||
|
||||
it("unknown operator raises INVALID_OPERATOR", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
compileWhere(
|
||||
[
|
||||
{
|
||||
field: "x",
|
||||
value: 1,
|
||||
operator: "weird" as unknown as "eq",
|
||||
},
|
||||
],
|
||||
{ model: "user", linkFields: NO_LINKS }
|
||||
)
|
||||
).toThrow(BetterAuthSurrealError);
|
||||
});
|
||||
|
||||
it("placeholder names never collide across N clauses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clauses = Array.from({ length: 20 }, (_, i) => ({
|
||||
field: `f${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const out = compileWhere(clauses, {
|
||||
model: "user",
|
||||
linkFields: NO_LINKS,
|
||||
});
|
||||
const placeholders = Array.from(out.clause.matchAll(/\$\w+/g)).map(
|
||||
(m) => m[0]
|
||||
);
|
||||
expect(new Set(placeholders).size).toBe(placeholders.length);
|
||||
expect(Object.keys(out.vars)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("link-field where values pre-resolved to record-link form", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = compileWhere([{ field: "userId", value: "u1" }], {
|
||||
model: "session",
|
||||
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
});
|
||||
const w0 = (out.vars as { w0: unknown }).w0;
|
||||
expect(w0).toBeDefined();
|
||||
expect(String(w0)).toBe("user:u1");
|
||||
});
|
||||
|
||||
it("link-field array values map element-wise", { timeout: 5000 }, () => {
|
||||
const out = compileWhere(
|
||||
[{ field: "userId", value: ["u1", "u2"], operator: "in" }],
|
||||
{
|
||||
model: "session",
|
||||
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
}
|
||||
);
|
||||
const w0 = (out.vars as { w0: unknown }).w0;
|
||||
expect(Array.isArray(w0)).toBe(true);
|
||||
const arr = w0 as unknown[];
|
||||
expect(String(arr[0])).toBe("user:u1");
|
||||
expect(String(arr[1])).toBe("user:u2");
|
||||
});
|
||||
|
||||
it("non-link fields pass through unmodified", { timeout: 5000 }, () => {
|
||||
const out = compileWhere([{ field: "email", value: "x@y" }], {
|
||||
model: "user",
|
||||
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
});
|
||||
expect(out.vars).toEqual({ w0: "x@y" });
|
||||
});
|
||||
|
||||
it("honours custom placeholder prefix", { timeout: 5000 }, () => {
|
||||
const out = compileWhere([{ field: "id", value: "u1" }], {
|
||||
model: "user",
|
||||
linkFields: NO_LINKS,
|
||||
placeholderPrefix: "p",
|
||||
});
|
||||
expect(out.clause).toBe("id = $p0");
|
||||
// `id` is auto-wrapped as a record-link to the model name.
|
||||
expect(String((out.vars as { p0: unknown }).p0)).toBe("user:u1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { applyLinkPrefix, linkTargetFor } from "../link-fields/link-fields.js";
|
||||
import type { LinkFieldMap } from "../types/adapter.js";
|
||||
|
||||
/**
|
||||
* Better-Auth's `Where[]` filter compiled to a parameterised SurrealQL
|
||||
* `WHERE` fragment.
|
||||
*
|
||||
* Mirrors Better-Auth's `whereOperators` tuple verbatim.
|
||||
*/
|
||||
export type WhereOperator =
|
||||
| "eq"
|
||||
| "ne"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
|
||||
export type WhereConnector = "AND" | "OR";
|
||||
|
||||
export interface WhereClause {
|
||||
readonly field: string;
|
||||
readonly value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| string[]
|
||||
| number[]
|
||||
| Date
|
||||
| null;
|
||||
readonly operator?: WhereOperator | undefined;
|
||||
readonly connector?: WhereConnector | undefined;
|
||||
}
|
||||
|
||||
export interface CompileWhereOptions {
|
||||
readonly model: string;
|
||||
readonly linkFields: LinkFieldMap;
|
||||
/**
|
||||
* Optional placeholder prefix override — defaults to `w`. Used by the
|
||||
* adapter to keep `update`'s `$update` binding separate from `$w0`/`$w1`
|
||||
* predicate bindings.
|
||||
*/
|
||||
readonly placeholderPrefix?: string;
|
||||
}
|
||||
|
||||
export interface CompiledWhere {
|
||||
readonly clause: string;
|
||||
readonly vars: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator → SurrealQL fragment template. `{f}` is replaced by the field
|
||||
* name and `{p}` by the placeholder reference (e.g. `$w0`). Function-call
|
||||
* operators (`starts_with`, `ends_with`, `contains`) use SurrealQL's
|
||||
* `string::*` namespace because raw infix forms (`STARTS_WITH`,
|
||||
* `ENDS_WITH`, `CONTAINS`) are not all reserved tokens in SurrealDB v3.
|
||||
*/
|
||||
const OPERATOR_TEMPLATES: Readonly<Record<WhereOperator, string>> = {
|
||||
eq: "{f} = {p}",
|
||||
ne: "{f} != {p}",
|
||||
gt: "{f} > {p}",
|
||||
gte: "{f} >= {p}",
|
||||
lt: "{f} < {p}",
|
||||
lte: "{f} <= {p}",
|
||||
in: "{f} IN {p}",
|
||||
not_in: "{f} NOT IN {p}",
|
||||
contains: "string::contains({f}, {p})",
|
||||
starts_with: "string::starts_with({f}, {p})",
|
||||
ends_with: "string::ends_with({f}, {p})",
|
||||
};
|
||||
|
||||
/**
|
||||
* Compile a `Where[]` filter into a parameterised SurrealQL clause +
|
||||
* binding record. Empty input compiles to `{ clause: "", vars: {} }`.
|
||||
*/
|
||||
export function compileWhere(
|
||||
where: ReadonlyArray<WhereClause>,
|
||||
options: CompileWhereOptions
|
||||
): CompiledWhere {
|
||||
if (where.length === 0) {
|
||||
return { clause: "", vars: {} };
|
||||
}
|
||||
const prefix = options.placeholderPrefix ?? "w";
|
||||
const vars: Record<string, unknown> = {};
|
||||
const parts: string[] = [];
|
||||
|
||||
for (let i = 0; i < where.length; i++) {
|
||||
const clause = where[i] as WhereClause;
|
||||
const op = clause.operator ?? "eq";
|
||||
const template = OPERATOR_TEMPLATES[op];
|
||||
if (template === undefined) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR,
|
||||
`unknown where operator: ${String(op)}`
|
||||
);
|
||||
}
|
||||
|
||||
const placeholder = `${prefix}${i}`;
|
||||
const value = resolveValue(
|
||||
options.linkFields,
|
||||
options.model,
|
||||
clause.field,
|
||||
clause.value
|
||||
);
|
||||
vars[placeholder] = value;
|
||||
|
||||
let connector = "";
|
||||
if (i > 0) {
|
||||
connector = `${clause.connector ?? "AND"} `;
|
||||
}
|
||||
const fragment = template
|
||||
.replace("{f}", clause.field)
|
||||
.replace("{p}", `$${placeholder}`);
|
||||
parts.push(`${connector}${fragment}`);
|
||||
}
|
||||
|
||||
return { clause: parts.join(" "), vars };
|
||||
}
|
||||
|
||||
function resolveValue(
|
||||
linkFields: LinkFieldMap,
|
||||
model: string,
|
||||
field: string,
|
||||
value: WhereClause["value"]
|
||||
): unknown {
|
||||
// The `id` field of every model is a SurrealDB record link by
|
||||
// construction (record-id strategy). Wrap it the same way as any
|
||||
// declared link field so predicates like `WHERE id = $p0` bind a
|
||||
// record reference and not a plain string.
|
||||
const target =
|
||||
field === "id" ? model : linkTargetFor(linkFields, model, field);
|
||||
if (target === null) return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => applyLinkPrefix(target, v));
|
||||
}
|
||||
return applyLinkPrefix(target, value);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { compileSelect } from "./compile-select.js";
|
||||
export { compileSortBy, type SortBy } from "./compile-sort.js";
|
||||
export {
|
||||
type CompiledWhere,
|
||||
type CompileWhereOptions,
|
||||
compileWhere,
|
||||
type WhereClause,
|
||||
type WhereConnector,
|
||||
type WhereOperator,
|
||||
} from "./compile-where.js";
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("defines exactly 7 namespaced codes", { timeout: 5000 }, () => {
|
||||
const keys = Object.keys(BETTER_AUTH_SURREALDB_CODES).sort();
|
||||
expect(keys).toEqual(
|
||||
[
|
||||
"CONNECTION_NOT_READY",
|
||||
"INVALID_ID_FORMAT",
|
||||
"INVALID_INPUT",
|
||||
"INVALID_LINK_FIELD_VALUE",
|
||||
"INVALID_MODEL",
|
||||
"INVALID_OPERATOR",
|
||||
"QUERY_FAILED",
|
||||
].sort()
|
||||
);
|
||||
for (const key of keys) {
|
||||
expect(
|
||||
BETTER_AUTH_SURREALDB_CODES[
|
||||
key as keyof typeof BETTER_AUTH_SURREALDB_CODES
|
||||
]
|
||||
).toBe(`BETTER_AUTH_SURREALDB.${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("BetterAuthSurrealError preserves code, message, and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
|
||||
"query failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err).toBeInstanceOf(BetterAuthSurrealError);
|
||||
expect(err.code).toBe("BETTER_AUTH_SURREALDB.QUERY_FAILED");
|
||||
expect(err.message).toBe("query failed");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(BETTER_AUTH_SURREALDB_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/better-auth-surrealdb`.
|
||||
*
|
||||
* Every production failure path raises a {@link BetterAuthSurrealError} with
|
||||
* one of these 7 namespaced codes. Consumers branch on the string code
|
||||
* (e.g. `error.code === BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT`) or on
|
||||
* `instanceof BetterAuthSurrealError` for narrowing.
|
||||
*/
|
||||
export const BETTER_AUTH_SURREALDB_CODES = defineCodes(
|
||||
"BETTER_AUTH_SURREALDB",
|
||||
[
|
||||
"INVALID_INPUT",
|
||||
"INVALID_MODEL",
|
||||
"INVALID_OPERATOR",
|
||||
"QUERY_FAILED",
|
||||
"INVALID_ID_FORMAT",
|
||||
"INVALID_LINK_FIELD_VALUE",
|
||||
"CONNECTION_NOT_READY",
|
||||
] as const
|
||||
);
|
||||
|
||||
export type BetterAuthSurrealErrorCode =
|
||||
(typeof BETTER_AUTH_SURREALDB_CODES)[keyof typeof BETTER_AUTH_SURREALDB_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. `instanceof BetterAuthSurrealError` narrows the
|
||||
* `code` field to {@link BetterAuthSurrealErrorCode}.
|
||||
*/
|
||||
export class BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode> {
|
||||
public readonly betterAuthSurrealCode: BetterAuthSurrealErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: BetterAuthSurrealErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.betterAuthSurrealCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { BetterAuthSurrealErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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";
|
||||
83
packages/identity/auth/better-auth-surrealdb/src/index.ts
Normal file
83
packages/identity/auth/better-auth-surrealdb/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Adapter (flagship)
|
||||
export {
|
||||
BETTER_AUTH_SURREAL_ADAPTER_ID,
|
||||
type BetterAuthSurrealAdapter,
|
||||
type BetterAuthSurrealAdapterV16Options,
|
||||
betterAuthSurrealAdapter,
|
||||
createBetterAuthSurrealAdapter,
|
||||
ModelToTableCache,
|
||||
type ModelToTableResolver,
|
||||
} from "./adapter/index.js";
|
||||
|
||||
// Compile
|
||||
export {
|
||||
type CompiledWhere,
|
||||
type CompileWhereOptions,
|
||||
compileSelect,
|
||||
compileSortBy,
|
||||
compileWhere,
|
||||
type SortBy,
|
||||
type WhereClause,
|
||||
type WhereConnector,
|
||||
type WhereOperator,
|
||||
} from "./compile/index.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
type BetterAuthSurrealErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
|
||||
// Id strategy
|
||||
export {
|
||||
generateUlid,
|
||||
normaliseReturnedId,
|
||||
type PreparedCreate,
|
||||
prepareCreate,
|
||||
} from "./id-strategy/index.js";
|
||||
|
||||
// Link fields
|
||||
export {
|
||||
applyLinkPrefix,
|
||||
applyLinkPrefixesToWrite,
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
isRecordLikeValue,
|
||||
linkTargetFor,
|
||||
makeRecordId,
|
||||
makeStringRecordId,
|
||||
stripLinkPrefix,
|
||||
stripLinkPrefixesFromRead,
|
||||
} from "./link-fields/index.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
type AdapterOutcome,
|
||||
type InstrumentedOp,
|
||||
instrument,
|
||||
resetInstrumentsForTesting,
|
||||
} from "./otel/index.js";
|
||||
|
||||
// Schema
|
||||
export {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "./schema/index.js";
|
||||
|
||||
// Zod schemas
|
||||
export {
|
||||
type BetterAuthSurrealAdapterOptionsInput,
|
||||
BetterAuthSurrealAdapterOptionsSchema,
|
||||
LinkFieldMapSchema,
|
||||
type LinkFieldMapSchemaInput,
|
||||
type LinkFieldMapSchemaOutput,
|
||||
} from "./schemas/index.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
BetterAuthSurrealAdapterOptions,
|
||||
IdStrategy,
|
||||
LinkFieldMap,
|
||||
Row,
|
||||
SurrealClientView,
|
||||
} from "./types/index.js";
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
applyLinkPrefix,
|
||||
applyLinkPrefixesToWrite,
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
isRecordLikeValue,
|
||||
linkTargetFor,
|
||||
makeRecordId,
|
||||
makeStringRecordId,
|
||||
stripLinkPrefix,
|
||||
stripLinkPrefixesFromRead,
|
||||
} from "./link-fields.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
applyLinkPrefix,
|
||||
applyLinkPrefixesToWrite,
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
isRecordLikeValue,
|
||||
linkTargetFor,
|
||||
makeRecordId,
|
||||
makeStringRecordId,
|
||||
stripLinkPrefix,
|
||||
stripLinkPrefixesFromRead,
|
||||
} from "./link-fields.js";
|
||||
|
||||
describe("DEFAULT_BETTER_AUTH_LINK_FIELDS", () => {
|
||||
it("covers every record-link field in the bundled schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// From the bundled schema (record<...> typed columns):
|
||||
const expected: Record<string, Record<string, string>> = {
|
||||
session: { userId: "user" },
|
||||
passkey: { userId: "user" },
|
||||
member: { userId: "user", organizationId: "organization" },
|
||||
invitation: {
|
||||
inviterId: "user",
|
||||
organizationId: "organization",
|
||||
teamId: "team",
|
||||
},
|
||||
team: { organizationId: "organization" },
|
||||
team_member: { userId: "user", teamId: "team" },
|
||||
two_factor: { userId: "user" },
|
||||
api_key: { userId: "user" },
|
||||
sso_provider: { userId: "user" },
|
||||
};
|
||||
for (const [model, fields] of Object.entries(expected)) {
|
||||
for (const [field, target] of Object.entries(fields)) {
|
||||
expect(
|
||||
linkTargetFor(DEFAULT_BETTER_AUTH_LINK_FIELDS, model, field)
|
||||
).toBe(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen at every nesting level", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(DEFAULT_BETTER_AUTH_LINK_FIELDS)).toBe(true);
|
||||
expect(
|
||||
Object.isFrozen(DEFAULT_BETTER_AUTH_LINK_FIELDS["session"])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("account.userId is NOT prefixed under default config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
linkTargetFor(DEFAULT_BETTER_AUTH_LINK_FIELDS, "account", "userId")
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLinkPrefix", () => {
|
||||
it("prefixes plain strings as StringRecordId", { timeout: 5000 }, () => {
|
||||
const out = applyLinkPrefix("user", "u1");
|
||||
expect(isRecordLikeValue(out)).toBe(true);
|
||||
expect(String(out)).toBe("user:u1");
|
||||
});
|
||||
|
||||
it("passes non-string values through", { timeout: 5000 }, () => {
|
||||
expect(applyLinkPrefix("user", null)).toBeNull();
|
||||
expect(applyLinkPrefix("user", undefined)).toBeUndefined();
|
||||
expect(applyLinkPrefix("user", 5 as unknown)).toBe(5);
|
||||
});
|
||||
|
||||
it("throws INVALID_LINK_FIELD_VALUE on values containing :", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => applyLinkPrefix("user", "abc:def")).toThrow(
|
||||
BetterAuthSurrealError
|
||||
);
|
||||
try {
|
||||
applyLinkPrefix("user", "abc:def");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthSurrealError);
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
"BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLinkPrefix", () => {
|
||||
it("strips matching string prefix", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", "user:u1")).toBe("u1");
|
||||
});
|
||||
|
||||
it("unwraps RecordId to bare string id", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", makeRecordId("user", "u1"))).toBe("u1");
|
||||
});
|
||||
|
||||
it("unwraps StringRecordId to bare string id", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", makeStringRecordId("user:u1"))).toBe(
|
||||
"u1"
|
||||
);
|
||||
});
|
||||
|
||||
it("StringRecordId without matching prefix returns toString", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// toString returns the raw input "org:o1" — strip only matches `user:`.
|
||||
expect(stripLinkPrefix("user", makeStringRecordId("org:o1"))).toBe(
|
||||
"org:o1"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes bare strings through", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", "u1")).toBe("u1");
|
||||
});
|
||||
|
||||
it("passes non-matching prefix through", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", "org:o1")).toBe("org:o1");
|
||||
});
|
||||
|
||||
it("passes non-string values through", { timeout: 5000 }, () => {
|
||||
expect(stripLinkPrefix("user", null)).toBeNull();
|
||||
expect(stripLinkPrefix("user", 5 as unknown)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyLinkPrefixesToWrite", () => {
|
||||
it("rewrites declared fields and clones", { timeout: 5000 }, () => {
|
||||
const input = { userId: "u1", token: "t" };
|
||||
const out = applyLinkPrefixesToWrite(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"session",
|
||||
input
|
||||
);
|
||||
expect(isRecordLikeValue(out["userId"])).toBe(true);
|
||||
expect(String(out["userId"])).toBe("user:u1");
|
||||
expect(out["token"]).toBe("t");
|
||||
expect(out).not.toBe(input);
|
||||
expect(input.userId).toBe("u1"); // unmutated
|
||||
});
|
||||
|
||||
it("returns shallow clone for unmapped models", { timeout: 5000 }, () => {
|
||||
const input = { userId: "u1" };
|
||||
const out = applyLinkPrefixesToWrite(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"account",
|
||||
input
|
||||
);
|
||||
expect(out).toEqual({ userId: "u1" });
|
||||
expect(out).not.toBe(input);
|
||||
});
|
||||
|
||||
it("skips fields absent from data", { timeout: 5000 }, () => {
|
||||
const out = applyLinkPrefixesToWrite(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"member",
|
||||
{ userId: "u1" }
|
||||
);
|
||||
expect(isRecordLikeValue(out["userId"])).toBe(true);
|
||||
});
|
||||
|
||||
it("empty linkFields disables manipulation", { timeout: 5000 }, () => {
|
||||
const out = applyLinkPrefixesToWrite({}, "session", { userId: "u1" });
|
||||
expect(out).toEqual({ userId: "u1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLinkPrefixesFromRead", () => {
|
||||
it("strips declared fields and clones", { timeout: 5000 }, () => {
|
||||
const input = {
|
||||
userId: makeRecordId("user", "u1"),
|
||||
token: "t",
|
||||
};
|
||||
const out = stripLinkPrefixesFromRead(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"session",
|
||||
input
|
||||
);
|
||||
expect(out).toEqual({ userId: "u1", token: "t" });
|
||||
});
|
||||
|
||||
it("returns shallow clone for unmapped models", { timeout: 5000 }, () => {
|
||||
const out = stripLinkPrefixesFromRead(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"account",
|
||||
{ userId: "u1" }
|
||||
);
|
||||
expect(out).toEqual({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("skips fields absent from row", { timeout: 5000 }, () => {
|
||||
const out = stripLinkPrefixesFromRead(
|
||||
DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
"member",
|
||||
{ role: "admin" }
|
||||
);
|
||||
expect(out).toEqual({ role: "admin" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { RecordId, StringRecordId } from "surrealdb";
|
||||
import {
|
||||
BETTER_AUTH_SURREALDB_CODES,
|
||||
BetterAuthSurrealError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { LinkFieldMap } from "../types/adapter.js";
|
||||
|
||||
// Resolve the SurrealDB driver via ESM so `instanceof` checks against the
|
||||
// driver's internal RecordId class identity line up with the values this
|
||||
// package produces. `@suaveplan/surrealdb` also uses ESM for the driver,
|
||||
// so both consumers share the same `surrealdb.mjs` module instance.
|
||||
const SurrealRecordId = RecordId;
|
||||
const SurrealStringRecordId = StringRecordId;
|
||||
|
||||
/**
|
||||
* Construct a SurrealDB `RecordId` instance bound to the same CJS module the
|
||||
* underlying driver evaluates `instanceof` against. Direct ESM imports of
|
||||
* the same class identifier may resolve to a different class instance in
|
||||
* dual-package (CJS/ESM) modules, which is why the adapter wraps its own
|
||||
* factory rather than re-exporting the driver classes.
|
||||
*/
|
||||
export function makeRecordId(table: string, id: string): object {
|
||||
return new SurrealRecordId(table, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a SurrealDB `StringRecordId` from the wire form `<table>:<id>`.
|
||||
*/
|
||||
export function makeStringRecordId(rid: string): object {
|
||||
return new SurrealStringRecordId(rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duck-typed predicate: is the value an instance of any SurrealDB record-id
|
||||
* shape (RecordId or StringRecordId) bound to the same module? Used by
|
||||
* `stripLinkPrefix` to avoid cross-module instanceof pitfalls.
|
||||
*/
|
||||
export function isRecordLikeValue(value: unknown): boolean {
|
||||
return (
|
||||
value instanceof SurrealRecordId ||
|
||||
value instanceof SurrealStringRecordId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default `LinkFieldMap` matching the bundled 17-table schema's
|
||||
* `record<target>` columns. Mirrors every `record<...>` typed field in
|
||||
* `BETTER_AUTH_SURREAL_SCHEMA` so consumers using the canonical schema get
|
||||
* correct prefix handling without configuration.
|
||||
*/
|
||||
export const DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap = Object.freeze({
|
||||
session: Object.freeze({ userId: "user" }),
|
||||
passkey: Object.freeze({ userId: "user" }),
|
||||
member: Object.freeze({ userId: "user", organizationId: "organization" }),
|
||||
invitation: Object.freeze({
|
||||
inviterId: "user",
|
||||
organizationId: "organization",
|
||||
teamId: "team",
|
||||
}),
|
||||
team: Object.freeze({ organizationId: "organization" }),
|
||||
team_member: Object.freeze({ userId: "user", teamId: "team" }),
|
||||
two_factor: Object.freeze({ userId: "user" }),
|
||||
api_key: Object.freeze({ userId: "user" }),
|
||||
sso_provider: Object.freeze({ userId: "user" }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the link-field target table for `(model, field)` or `null` when
|
||||
* the field is not configured as a record-link.
|
||||
*/
|
||||
export function linkTargetFor(
|
||||
map: LinkFieldMap,
|
||||
model: string,
|
||||
field: string
|
||||
): string | null {
|
||||
const modelMap = map[model];
|
||||
if (modelMap === undefined) return null;
|
||||
const target = modelMap[field];
|
||||
return target === undefined ? null : target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the `<target>` record-link wrapper to a value about to be written
|
||||
* to a record-link field. Returns a SurrealDB `RecordId` instance — the
|
||||
* driver serialises this as a typed record reference (not a string).
|
||||
*
|
||||
* - If `value` already contains `:`, raises
|
||||
* `BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE` (caller is sending a
|
||||
* pre-formed record link to a field the adapter would wrap again).
|
||||
* - Non-string values pass through (record-link `null` is allowed for
|
||||
* optional fields like `sso_provider.userId`).
|
||||
*/
|
||||
export function applyLinkPrefix(target: string, value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
if (value.includes(":")) {
|
||||
throw new BetterAuthSurrealError(
|
||||
BETTER_AUTH_SURREALDB_CODES.INVALID_LINK_FIELD_VALUE,
|
||||
`link-field value "${value}" already contains ":" — cannot wrap as record<${target}>`
|
||||
);
|
||||
}
|
||||
return makeStringRecordId(`${target}:${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `<target>:` prefix from a value read back from a record-link
|
||||
* field. Handles three return shapes from `surrealdb` v3:
|
||||
* - `RecordId` instance — returns `.id` as a string.
|
||||
* - `"<target>:<id>"` string — returns the suffix.
|
||||
* - Anything else — returned verbatim (operator schema drift).
|
||||
*/
|
||||
export function stripLinkPrefix(target: string, value: unknown): unknown {
|
||||
if (value instanceof SurrealRecordId) {
|
||||
return String((value as unknown as { id: string }).id);
|
||||
}
|
||||
if (value instanceof SurrealStringRecordId) {
|
||||
const s = String(value);
|
||||
const prefix = `${target}:`;
|
||||
return s.startsWith(prefix) ? s.slice(prefix.length) : s;
|
||||
}
|
||||
if (typeof value !== "string") return value;
|
||||
const prefix = `${target}:`;
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a row about to be written; for each declared link field whose value
|
||||
* is a plain string, replace it with the prefixed record-link form.
|
||||
*
|
||||
* Mutates a clone; the caller's input is never mutated.
|
||||
*/
|
||||
export function applyLinkPrefixesToWrite(
|
||||
map: LinkFieldMap,
|
||||
model: string,
|
||||
data: Readonly<Record<string, unknown>>
|
||||
): Record<string, unknown> {
|
||||
const modelMap = map[model];
|
||||
if (modelMap === undefined) return { ...data };
|
||||
const out: Record<string, unknown> = { ...data };
|
||||
for (const [field, target] of Object.entries(modelMap)) {
|
||||
if (field in out) {
|
||||
out[field] = applyLinkPrefix(target, out[field]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a row read from SurrealDB; for each declared link field, strip the
|
||||
* `<target>:` prefix back to the bare id Better-Auth expects. Returns a
|
||||
* shallow clone — never mutates input.
|
||||
*/
|
||||
export function stripLinkPrefixesFromRead(
|
||||
map: LinkFieldMap,
|
||||
model: string,
|
||||
row: Readonly<Record<string, unknown>>
|
||||
): Record<string, unknown> {
|
||||
const modelMap = map[model];
|
||||
if (modelMap === undefined) return { ...row };
|
||||
const out: Record<string, unknown> = { ...row };
|
||||
for (const [field, target] of Object.entries(modelMap)) {
|
||||
if (field in out) {
|
||||
out[field] = stripLinkPrefix(target, out[field]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type AdapterOutcome,
|
||||
type InstrumentedOp,
|
||||
instrument,
|
||||
resetInstrumentsForTesting,
|
||||
} from "./instruments.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterAll, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { instrument, resetInstrumentsForTesting } from "./instruments.js";
|
||||
|
||||
describe("instrument", () => {
|
||||
afterAll(() => {
|
||||
resetInstrumentsForTesting();
|
||||
});
|
||||
|
||||
it("counter emits success", { timeout: 5000 }, async () => {
|
||||
await instrument({
|
||||
model: "user",
|
||||
operation: "create",
|
||||
run: async () => ({ ok: true }),
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const dump = JSON.stringify(collected);
|
||||
expect(dump).toContain("operations_total");
|
||||
expect(dump).toContain('"outcome":"success"');
|
||||
});
|
||||
|
||||
it("counter emits error and rethrows", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
instrument({
|
||||
model: "user",
|
||||
operation: "create",
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
const collected = await flushTestMetrics();
|
||||
const dump = JSON.stringify(collected);
|
||||
expect(dump).toContain('"outcome":"error"');
|
||||
});
|
||||
|
||||
it("histogram records per operation", { timeout: 5000 }, async () => {
|
||||
await instrument({
|
||||
model: "session",
|
||||
operation: "findOne",
|
||||
run: async () => null,
|
||||
});
|
||||
const collected = await flushTestMetrics();
|
||||
const dump = JSON.stringify(collected);
|
||||
expect(dump).toContain("operation_duration_ms");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/better-auth-surrealdb";
|
||||
|
||||
interface CachedInstruments {
|
||||
counter: Counter;
|
||||
histogram: Histogram;
|
||||
}
|
||||
|
||||
let cached: CachedInstruments | null = null;
|
||||
|
||||
function getInstruments(): CachedInstruments {
|
||||
if (cached !== null) return cached;
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
const counter = meter.createCounter(
|
||||
"better_auth.surrealdb.operations_total",
|
||||
{
|
||||
description:
|
||||
"Better-Auth SurrealDB adapter operations (per model + outcome)",
|
||||
}
|
||||
);
|
||||
const histogram = meter.createHistogram(
|
||||
"better_auth.surrealdb.operation_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Better-Auth SurrealDB adapter operation duration in ms",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
cached = { counter, histogram };
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only reset hook — clears the lazy-init cache so the next call
|
||||
* re-resolves against the current global `MeterProvider`. The adapter's
|
||||
* production paths never call this.
|
||||
*/
|
||||
export function resetInstrumentsForTesting(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export type AdapterOutcome = "success" | "error";
|
||||
|
||||
export interface InstrumentedOp<T> {
|
||||
readonly model: string;
|
||||
readonly operation: string;
|
||||
run: () => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `op()` while recording one counter point + one histogram observation.
|
||||
* The counter's `outcome` attribute distinguishes successes from failures
|
||||
* (without swallowing the error — failures rethrow).
|
||||
*/
|
||||
export async function instrument<T>(op: InstrumentedOp<T>): Promise<T> {
|
||||
const { counter, histogram } = getInstruments();
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await op.run();
|
||||
const elapsed = performance.now() - start;
|
||||
counter.add(1, {
|
||||
model: op.model,
|
||||
operation: op.operation,
|
||||
outcome: "success" satisfies AdapterOutcome,
|
||||
});
|
||||
histogram.record(elapsed, { operation: op.operation });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = performance.now() - start;
|
||||
counter.add(1, {
|
||||
model: op.model,
|
||||
operation: op.operation,
|
||||
outcome: "error" satisfies AdapterOutcome,
|
||||
});
|
||||
histogram.record(elapsed, { operation: op.operation });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "./schema.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
|
||||
import type { SurrealClientView } from "../types/adapter.js";
|
||||
import { applyBetterAuthSchema, BETTER_AUTH_SURREAL_SCHEMA } from "./schema.js";
|
||||
|
||||
const EXPECTED_TABLES = [
|
||||
"session",
|
||||
"account",
|
||||
"verification",
|
||||
"passkey",
|
||||
"jwks",
|
||||
"sso_provider",
|
||||
"oauth_application",
|
||||
"oauth_access_token",
|
||||
"oauth_consent",
|
||||
"organization",
|
||||
"member",
|
||||
"invitation",
|
||||
"team",
|
||||
"team_member",
|
||||
"two_factor",
|
||||
"api_key",
|
||||
] as const;
|
||||
|
||||
describe("BETTER_AUTH_SURREAL_SCHEMA", () => {
|
||||
it("contains every required table via DEFINE TABLE OVERWRITE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const table of EXPECTED_TABLES) {
|
||||
const re = new RegExp(`DEFINE TABLE OVERWRITE ${table}\\b`);
|
||||
expect(BETTER_AUTH_SURREAL_SCHEMA).toMatch(re);
|
||||
}
|
||||
});
|
||||
|
||||
it("extends the user table with field-only definitions (no DEFINE TABLE user)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(BETTER_AUTH_SURREAL_SCHEMA).not.toMatch(
|
||||
/DEFINE TABLE OVERWRITE user\b/
|
||||
);
|
||||
expect(BETTER_AUTH_SURREAL_SCHEMA).toMatch(
|
||||
/DEFINE FIELD OVERWRITE emailVerified ON user\b/
|
||||
);
|
||||
});
|
||||
|
||||
it("declares 13+ indexes", { timeout: 5000 }, () => {
|
||||
const count = (
|
||||
BETTER_AUTH_SURREAL_SCHEMA.match(/DEFINE INDEX OVERWRITE/g) ?? []
|
||||
).length;
|
||||
expect(count).toBeGreaterThanOrEqual(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyBetterAuthSchema", () => {
|
||||
it("executes the schema via client.execute", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const client: SurrealClientView = {
|
||||
isConnected: true,
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute(sql: string) {
|
||||
calls.push(sql);
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
};
|
||||
await applyBetterAuthSchema(client);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toBe(BETTER_AUTH_SURREAL_SCHEMA);
|
||||
});
|
||||
|
||||
it("wraps execution failure in QUERY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const client: SurrealClientView = {
|
||||
isConnected: true,
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
await expect(applyBetterAuthSchema(client)).rejects.toThrow(
|
||||
BetterAuthSurrealError
|
||||
);
|
||||
try {
|
||||
await applyBetterAuthSchema(client);
|
||||
} catch (err) {
|
||||
expect((err as BetterAuthSurrealError).code).toBe(
|
||||
"BETTER_AUTH_SURREALDB.QUERY_FAILED"
|
||||
);
|
||||
expect((err as Error).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
LinkFieldMapSchema,
|
||||
type LinkFieldMapSchemaInput,
|
||||
type LinkFieldMapSchemaOutput,
|
||||
} from "./link-fields.js";
|
||||
export {
|
||||
type BetterAuthSurrealAdapterOptionsInput,
|
||||
BetterAuthSurrealAdapterOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LinkFieldMapSchema } from "./link-fields.js";
|
||||
|
||||
describe("LinkFieldMapSchema", () => {
|
||||
it("accepts a valid map", { timeout: 5000 }, () => {
|
||||
const parsed = LinkFieldMapSchema.safeParse({
|
||||
session: { userId: "user" },
|
||||
member: { userId: "user", organizationId: "organization" },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the empty map", { timeout: 5000 }, () => {
|
||||
expect(LinkFieldMapSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-string target table", { timeout: 5000 }, () => {
|
||||
const parsed = LinkFieldMapSchema.safeParse({
|
||||
session: { userId: 5 as unknown as string },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects identifier-invalid model name", { timeout: 5000 }, () => {
|
||||
const parsed = LinkFieldMapSchema.safeParse({
|
||||
"weird-name": { userId: "user" },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects identifier-invalid target table", { timeout: 5000 }, () => {
|
||||
const parsed = LinkFieldMapSchema.safeParse({
|
||||
session: { userId: "weird-name" },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema validating the shape of `LinkFieldMap`:
|
||||
* `Record<modelName, Record<fieldName, targetTableName>>`.
|
||||
*
|
||||
* Both keys and the leaf target-table string MUST match SurrealDB's
|
||||
* `/^[a-zA-Z_][a-zA-Z0-9_]*$/` identifier rule so the generated SurrealQL
|
||||
* cannot inject syntax.
|
||||
*/
|
||||
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
const IdentString = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(IDENT, { message: "must match /^[a-zA-Z_][a-zA-Z0-9_]*$/" });
|
||||
|
||||
export const LinkFieldMapSchema = z.record(
|
||||
IdentString,
|
||||
z.record(IdentString, IdentString)
|
||||
);
|
||||
|
||||
export type LinkFieldMapSchemaInput = z.input<typeof LinkFieldMapSchema>;
|
||||
export type LinkFieldMapSchemaOutput = z.output<typeof LinkFieldMapSchema>;
|
||||
@@ -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.
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthSurrealAdapterOptionsSchema } from "./options.js";
|
||||
|
||||
const fakeClient = {
|
||||
isConnected: true,
|
||||
query: async () => [],
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
};
|
||||
|
||||
describe("BetterAuthSurrealAdapterOptionsSchema", () => {
|
||||
it("accepts a minimal client-only options object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing client", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects client without query/execute", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: { isConnected: true },
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown idStrategy", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
idStrategy: "weird" as unknown as "ulid",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-function modelToTable", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
modelToTable: "auth_" as unknown as (m: string) => string,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed linkFields nested", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
linkFields: {
|
||||
session: { userId: 5 as unknown as string },
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-object logger", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
logger: "console" as unknown as object,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a fully populated options object", { timeout: 5000 }, () => {
|
||||
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse({
|
||||
client: fakeClient,
|
||||
idStrategy: "ulid",
|
||||
modelToTable: (m: string) => `auth_${m}`,
|
||||
linkFields: { session: { userId: "user" } },
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import z from "zod";
|
||||
import { LinkFieldMapSchema } from "./link-fields.js";
|
||||
|
||||
/**
|
||||
* Zod schema for `BetterAuthSurrealAdapterOptions` — validated at the
|
||||
* `createBetterAuthSurrealAdapter` boundary.
|
||||
*
|
||||
* Functions and objects with method surfaces (client, modelToTable, logger)
|
||||
* are accepted as Zod `custom` to avoid serialising the entire class graph
|
||||
* into the schema. The structural shape check (`client.isConnected`,
|
||||
* `client.query`, `client.execute`) lives in the adapter factory because
|
||||
* Zod has limited support for asserting structural surfaces on arbitrary
|
||||
* objects without losing the original reference.
|
||||
*/
|
||||
const ClientSchema = z.custom<{
|
||||
isConnected: boolean;
|
||||
query: (...args: unknown[]) => Promise<unknown[]>;
|
||||
execute: (...args: unknown[]) => Promise<{ rowsAffected: number }>;
|
||||
}>(
|
||||
(val) =>
|
||||
typeof val === "object" &&
|
||||
val !== null &&
|
||||
"query" in val &&
|
||||
typeof (val as { query: unknown }).query === "function" &&
|
||||
"execute" in val &&
|
||||
typeof (val as { execute: unknown }).execute === "function",
|
||||
{ message: "client must expose query() and execute() methods" }
|
||||
);
|
||||
|
||||
const ModelToTableSchema = z.custom<(model: string) => string>(
|
||||
(val) => typeof val === "function",
|
||||
{ message: "modelToTable must be a function" }
|
||||
);
|
||||
|
||||
const LoggerSchema = z.custom<{
|
||||
info?: unknown;
|
||||
warn?: unknown;
|
||||
error?: unknown;
|
||||
}>((val) => typeof val === "object" && val !== null, {
|
||||
message: "logger must be an object",
|
||||
});
|
||||
|
||||
const IdStrategySchema = z.enum(["record-id", "plain", "ulid"]);
|
||||
|
||||
export const BetterAuthSurrealAdapterOptionsSchema = z.object({
|
||||
client: ClientSchema,
|
||||
modelToTable: ModelToTableSchema.optional(),
|
||||
idStrategy: IdStrategySchema.optional(),
|
||||
linkFields: LinkFieldMapSchema.optional(),
|
||||
logger: LoggerSchema.optional(),
|
||||
});
|
||||
|
||||
export type BetterAuthSurrealAdapterOptionsInput = z.input<
|
||||
typeof BetterAuthSurrealAdapterOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type ConformanceContext,
|
||||
type ConformanceFactory,
|
||||
runAdapterConformance,
|
||||
} from "./run-adapter-conformance.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BetterAuthSurrealAdapter } from "../adapter/adapter.js";
|
||||
import {
|
||||
applyBetterAuthSchema,
|
||||
BETTER_AUTH_SURREAL_SCHEMA,
|
||||
} from "../schema/schema.js";
|
||||
import type { SurrealClientView } from "../types/adapter.js";
|
||||
|
||||
export interface ConformanceContext {
|
||||
readonly adapter: BetterAuthSurrealAdapter;
|
||||
readonly client: SurrealClientView;
|
||||
}
|
||||
|
||||
export type ConformanceFactory = () =>
|
||||
| Promise<ConformanceContext>
|
||||
| ConformanceContext;
|
||||
|
||||
/**
|
||||
* Better-Auth adapter conformance harness.
|
||||
*
|
||||
* Registers a top-level `describe("Better-Auth adapter conformance")` block
|
||||
* that exercises every method against a real, schema-applied SurrealDB
|
||||
* connection. Designed to be called inside a test file that has already
|
||||
* arranged for the `client` to be live when `factory()` resolves.
|
||||
*/
|
||||
export function runAdapterConformance(factory: ConformanceFactory): void {
|
||||
describe("Better-Auth adapter conformance", () => {
|
||||
it("applyBetterAuthSchema executes without error", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
await applyBetterAuthSchema(ctx.client);
|
||||
expect(BETTER_AUTH_SURREAL_SCHEMA.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("user CRUD: create + findOne + update + count + delete", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
const created = (await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: "u_conf_1",
|
||||
email: "conf1@x",
|
||||
emailVerified: false,
|
||||
},
|
||||
})) as { id: string; email: string };
|
||||
expect(created.id).toBe("u_conf_1");
|
||||
expect(created.email).toBe("conf1@x");
|
||||
|
||||
const found = await ctx.adapter.findOne<{
|
||||
id: string;
|
||||
email: string;
|
||||
}>({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_1" }],
|
||||
});
|
||||
expect(found?.email).toBe("conf1@x");
|
||||
|
||||
const updated = await ctx.adapter.update<{
|
||||
id: string;
|
||||
email: string;
|
||||
}>({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_1" }],
|
||||
update: { email: "conf1b@x" },
|
||||
});
|
||||
expect(updated?.email).toBe("conf1b@x");
|
||||
|
||||
const c = await ctx.adapter.count({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_1" }],
|
||||
});
|
||||
expect(c).toBe(1);
|
||||
|
||||
await ctx.adapter.delete({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_1" }],
|
||||
});
|
||||
const gone = await ctx.adapter.findOne({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_1" }],
|
||||
});
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("session.userId roundtrips through record<user> with prefix stripped", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: { id: "u_conf_2", email: "conf2@x" },
|
||||
});
|
||||
const session = (await ctx.adapter.create({
|
||||
model: "session",
|
||||
data: { id: "s_conf_1", userId: "u_conf_2", token: "tok" },
|
||||
})) as { id: string; userId: string };
|
||||
expect(session.userId).toBe("u_conf_2");
|
||||
|
||||
const found = await ctx.adapter.findOne<{
|
||||
id: string;
|
||||
userId: string;
|
||||
}>({
|
||||
model: "session",
|
||||
where: [{ field: "userId", value: "u_conf_2" }],
|
||||
});
|
||||
expect(found?.userId).toBe("u_conf_2");
|
||||
|
||||
await ctx.adapter.delete({
|
||||
model: "session",
|
||||
where: [{ field: "id", value: "s_conf_1" }],
|
||||
});
|
||||
await ctx.adapter.delete({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: "u_conf_2" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("findMany honours limit/offset/sortBy", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: `u_pag_${i}`,
|
||||
email: `p${i}@x`,
|
||||
emailVerified: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const out = await ctx.adapter.findMany<{ id: string }>({
|
||||
model: "user",
|
||||
where: [
|
||||
{ field: "email", value: "p", operator: "starts_with" },
|
||||
],
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
sortBy: { field: "email", direction: "asc" },
|
||||
});
|
||||
expect(out).toHaveLength(2);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await ctx.adapter.delete({
|
||||
model: "user",
|
||||
where: [{ field: "id", value: `u_pag_${i}` }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("updateMany + deleteMany return affected counts", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ctx.adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: `u_bulk_${i}`,
|
||||
email: `b${i}@x`,
|
||||
name: "old",
|
||||
},
|
||||
});
|
||||
}
|
||||
const updated = await ctx.adapter.updateMany({
|
||||
model: "user",
|
||||
where: [{ field: "name", value: "old" }],
|
||||
update: { name: "new" },
|
||||
});
|
||||
expect(updated).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const deleted = await ctx.adapter.deleteMany({
|
||||
model: "user",
|
||||
where: [{ field: "name", value: "new" }],
|
||||
});
|
||||
expect(deleted).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("consumeOne returns and removes the row atomically per call", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const ctx = await factory();
|
||||
await ctx.adapter.create({
|
||||
model: "verification",
|
||||
data: {
|
||||
id: "v_conf_1",
|
||||
identifier: "conf-token",
|
||||
val: "secret",
|
||||
},
|
||||
});
|
||||
const first = await ctx.adapter.consumeOne<{ val: string }>({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: "v_conf_1" }],
|
||||
});
|
||||
expect(first?.val).toBe("secret");
|
||||
const second = await ctx.adapter.consumeOne({
|
||||
model: "verification",
|
||||
where: [{ field: "id", value: "v_conf_1" }],
|
||||
});
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Record-ID format strategy.
|
||||
*
|
||||
* - `record-id` (default): `data.id` becomes the SurrealDB record component
|
||||
* (`<table>:<id>`). Returned row's `id` is the bare component.
|
||||
* - `plain`: `id` is stored as a regular field; SurrealDB assigns the
|
||||
* storage id. The row's `id` field is returned as-is.
|
||||
* - `ulid`: when `data.id` is absent, the adapter generates a fresh 26-char
|
||||
* Crockford-base32 ULID and applies the `record-id` strategy.
|
||||
*/
|
||||
export type IdStrategy = "record-id" | "plain" | "ulid";
|
||||
|
||||
/**
|
||||
* Per-model map of fields whose values are SurrealDB record-link types
|
||||
* (`record<target>`). Writes auto-prefix with `<target>:`; reads strip the
|
||||
* prefix; Where[] values pre-resolve before binding.
|
||||
*/
|
||||
export type LinkFieldMap = Readonly<
|
||||
Record<string, Readonly<Record<string, string>>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Better-Auth row shape — adapter operations consume/return open records.
|
||||
*/
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Connection-like view accepted by the adapter. Either a fully-managed
|
||||
* `SurrealAdapter` (preferred) or anything satisfying the minimal
|
||||
* `query(sql, vars?) → unknown[]` + `execute(sql, vars?)` surface for ad-hoc
|
||||
* SurrealDB clients constructed outside the genesis stack.
|
||||
*/
|
||||
export interface SurrealClientView {
|
||||
readonly isConnected: boolean;
|
||||
query<T = Row>(sql: string, params?: readonly unknown[]): Promise<T[]>;
|
||||
execute(
|
||||
sql: string,
|
||||
params?: readonly unknown[]
|
||||
): Promise<{ rowsAffected: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options accepted by `createBetterAuthSurrealAdapter`.
|
||||
*
|
||||
* `client` accepts either a `SurrealAdapter` from `@suaveplan/surrealdb`
|
||||
* (preferred, since it manages reconnection + auth) or any structural
|
||||
* `SurrealClientView`.
|
||||
*/
|
||||
export interface BetterAuthSurrealAdapterOptions {
|
||||
readonly client: SurrealAdapter | SurrealClientView;
|
||||
readonly modelToTable?: (model: string) => string;
|
||||
readonly idStrategy?: IdStrategy;
|
||||
readonly linkFields?: LinkFieldMap;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BetterAuthSurrealAdapterOptions,
|
||||
IdStrategy,
|
||||
LinkFieldMap,
|
||||
Row,
|
||||
SurrealClientView,
|
||||
} from "./adapter.js";
|
||||
@@ -0,0 +1,155 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG = resolve(__dirname, "..");
|
||||
const SRC = resolve(PKG, "src");
|
||||
|
||||
interface PackageJson {
|
||||
readonly exports: Record<string, unknown>;
|
||||
readonly dependencies?: Record<string, string>;
|
||||
readonly peerDependencies?: Record<string, string>;
|
||||
readonly devDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(PKG, "package.json"), "utf8")
|
||||
) as PackageJson;
|
||||
|
||||
const EXEMPT_NO_MD = new Set(["index.ts"]);
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("every subpath export points to a real file (post-build) and has a matching source", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k !== "./package.json"
|
||||
);
|
||||
for (const subpath of subpaths) {
|
||||
const target = (
|
||||
pkg.exports[subpath] as {
|
||||
import?: { default: string };
|
||||
}
|
||||
)?.import?.default;
|
||||
expect(target, `${subpath} has import.default`).toBeDefined();
|
||||
}
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC);
|
||||
const tsFiles = files.filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".test.ts") &&
|
||||
!f.endsWith(".bench.ts") &&
|
||||
!f.endsWith(".types.ts") &&
|
||||
!f.endsWith("/index.ts") &&
|
||||
!f.endsWith(".spec-conformance.test.ts")
|
||||
);
|
||||
for (const file of tsFiles) {
|
||||
const base = file.slice(0, -3);
|
||||
const md = `${base}.md`;
|
||||
if (EXEMPT_NO_MD.has(file.split("/").pop() ?? "")) continue;
|
||||
expect(existsSync(md), `${file} missing sibling .md`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dependencies match the allowlist", { timeout: 5000 }, () => {
|
||||
const allowed = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/surrealdb",
|
||||
"@suaveplan/types",
|
||||
"surrealdb",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
||||
expect(allowed.has(dep), `unexpected runtime dep: ${dep}`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([
|
||||
"better-auth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("no source file exceeds 300 lines", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC).filter(
|
||||
(f) =>
|
||||
f.endsWith(".ts") &&
|
||||
!f.endsWith(".test.ts") &&
|
||||
!f.endsWith(".bench.ts")
|
||||
);
|
||||
for (const file of files) {
|
||||
const lines = readFileSync(file, "utf8").split("\n").length;
|
||||
expect(
|
||||
lines,
|
||||
`${file} has ${lines} lines (>300)`
|
||||
).toBeLessThanOrEqual(300);
|
||||
}
|
||||
});
|
||||
|
||||
it("no source file uses export *", { timeout: 5000 }, () => {
|
||||
const files = walk(SRC).filter((f) => f.endsWith(".ts"));
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
expect(
|
||||
/\bexport \* from\b/.test(content),
|
||||
`${file} uses export *`
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("no source file imports from vitest or bun:test", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const files = walk(SRC).filter(
|
||||
(f) => f.endsWith(".ts") || f.endsWith(".test.ts")
|
||||
);
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
expect(
|
||||
/from\s+["'](vitest|bun:test)["']/.test(content),
|
||||
`${file} imports from vitest/bun:test directly`
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("README quick-start uses betterAuthSurrealAdapter v1.6 factory", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const readme = readFileSync(resolve(PKG, "README.md"), "utf8");
|
||||
const quickStartIdx = readme.indexOf("## Quick start");
|
||||
expect(quickStartIdx).toBeGreaterThan(-1);
|
||||
// First non-install code block after `## Quick start` should use
|
||||
// `betterAuthSurrealAdapter`.
|
||||
const after = readme.slice(quickStartIdx);
|
||||
const firstFence = after.indexOf("```ts");
|
||||
const firstBlockEnd = after.indexOf("```", firstFence + 5);
|
||||
const firstBlock = after.slice(firstFence, firstBlockEnd);
|
||||
expect(firstBlock).toContain("betterAuthSurrealAdapter");
|
||||
expect(firstBlock).toContain("/adapter/v1.6");
|
||||
});
|
||||
|
||||
it("./adapter/v1.6 subpath export resolves", { timeout: 5000 }, () => {
|
||||
const subpath = pkg.exports["./adapter/v1.6"] as
|
||||
| { import?: { default: string; types: string } }
|
||||
| undefined;
|
||||
expect(subpath).toBeDefined();
|
||||
expect(subpath?.import?.default).toBe("./dist/adapter/v1.6.js");
|
||||
expect(subpath?.import?.types).toBe("./dist/adapter/v1.6.d.ts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { betterAuthSurrealAdapter } from "../../src/adapter/v1.6.js";
|
||||
import { applyBetterAuthSchema } from "../../src/schema/schema.js";
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
// Bootstrap a Better-Auth-compatible `user` table. The bundled schema
|
||||
// only extends user with `emailVerified`/`name`/`image`; Better-Auth's
|
||||
// in-memory default user model also writes `createdAt`/`updatedAt` so
|
||||
// those columns must exist before signUpEmail runs.
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
await applyBetterAuthSchema(client);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("Better-Auth v1.6 end-to-end via @suaveplan/better-auth-surrealdb", () => {
|
||||
it("createBetterAuth + signUpEmail round-trips against real SurrealDB", {
|
||||
timeout: 60_000,
|
||||
}, async () => {
|
||||
if (client === null) {
|
||||
throw new Error("client not ready");
|
||||
}
|
||||
const auth = betterAuth({
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
|
||||
const signUp = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "e2e-user@example.com",
|
||||
password: "Passw0rd!2026",
|
||||
name: "E2E User",
|
||||
},
|
||||
});
|
||||
|
||||
expect(signUp.user).toBeDefined();
|
||||
expect(signUp.user.email).toBe("e2e-user@example.com");
|
||||
|
||||
// Validate the user row landed in SurrealDB.
|
||||
const users = await client.query<{ email: string }>(
|
||||
"SELECT * FROM user WHERE email = 'e2e-user@example.com';"
|
||||
);
|
||||
expect(users.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate an account row exists for the new user.
|
||||
const accounts = await client.query(
|
||||
"SELECT * FROM account WHERE providerId = 'credential';"
|
||||
);
|
||||
expect(accounts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createBetterAuthSurrealAdapter } from "../../src/adapter/adapter.js";
|
||||
import { betterAuthSurrealAdapter } from "../../src/adapter/v1.6.js";
|
||||
import { applyBetterAuthSchema } from "../../src/schema/schema.js";
|
||||
import { runAdapterConformance } from "../../src/testing/run-adapter-conformance.js";
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
// Bootstrap the `user` table — the bundled schema only extends fields.
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await applyBetterAuthSchema(client);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("runAdapterConformance pins every Better-Auth scenario against real SurrealDB", () => {
|
||||
it("harness boots before scenarios run (sanity)", { timeout: 5000 }, () => {
|
||||
// The actual conformance scenarios are registered below via the
|
||||
// imported harness. This anchor it() exists to surface clear test
|
||||
// names in the runner output when the harness is empty.
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4-shape adapter passes conformance", () => {
|
||||
it("anchor — registers v1.4 conformance block", { timeout: 5000 }, () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
runAdapterConformance(() => {
|
||||
if (client === null) {
|
||||
throw new Error("SurrealAdapter not ready — beforeAll did not run");
|
||||
}
|
||||
const c = client;
|
||||
return {
|
||||
client: c,
|
||||
adapter: createBetterAuthSurrealAdapter({ client: c }),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.6 wrapper smoke conformance — exercises the wrapper's full path from
|
||||
* factory invocation through Better-Auth's `createAdapterFactory` layer.
|
||||
*
|
||||
* The shipped 17-table SurrealDB schema uses column names (e.g.
|
||||
* `verification.val`) that differ from Better-Auth's default in-memory
|
||||
* schema (`verification.value`). Better-Auth's `transformInput` whitelists
|
||||
* fields against the in-memory schema, so the v1.4-shape
|
||||
* `runAdapterConformance` harness — which writes to `val` directly —
|
||||
* cannot be reused verbatim against the v1.6 DBAdapter without a custom
|
||||
* Better-Auth schema override. The end-to-end test in
|
||||
* `better-auth-end-to-end.test.ts` covers the canonical Better-Auth flow
|
||||
* (signUpEmail / signInEmail / getSession) against a real Better-Auth
|
||||
* instance using the wrapper, which is the production signal that
|
||||
* matters. This smoke test asserts the wrapper builds, accepts a valid
|
||||
* Better-Auth `BetterAuthOptions` payload, and exposes every method
|
||||
* Better-Auth invokes.
|
||||
*/
|
||||
describe("v1.6 DBAdapter via wrapper passes conformance", () => {
|
||||
it("factory build + DBAdapter shape smoke check (full flow lives in better-auth-end-to-end.test.ts)", {
|
||||
timeout: 60_000,
|
||||
}, () => {
|
||||
if (client === null) {
|
||||
throw new Error("SurrealAdapter not ready — beforeAll did not run");
|
||||
}
|
||||
const factory = betterAuthSurrealAdapter({ client });
|
||||
const dbAdapter = factory({
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
} as Parameters<typeof factory>[0]);
|
||||
for (const method of [
|
||||
"create",
|
||||
"findOne",
|
||||
"findMany",
|
||||
"update",
|
||||
"updateMany",
|
||||
"delete",
|
||||
"deleteMany",
|
||||
"count",
|
||||
"consumeOne",
|
||||
"transaction",
|
||||
"createSchema",
|
||||
]) {
|
||||
expect(
|
||||
typeof (dbAdapter as unknown as Record<string, unknown>)[method]
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createBetterAuthSurrealAdapter } from "../../src/adapter/adapter.js";
|
||||
import { applyBetterAuthSchema } from "../../src/schema/schema.js";
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
let adapter: ReturnType<typeof createBetterAuthSurrealAdapter>;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await applyBetterAuthSchema(client);
|
||||
// Bootstrap the user table — schema only extends it.
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
adapter = createBetterAuthSurrealAdapter({ client });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("schema write compatibility", () => {
|
||||
it("applied schema accepts canonical Better-Auth row writes", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
await adapter.create({
|
||||
model: "user",
|
||||
data: {
|
||||
id: "u_compat_1",
|
||||
email: "compat@x",
|
||||
emailVerified: false,
|
||||
} as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// session — record<user> userId
|
||||
const session = (await adapter.create({
|
||||
model: "session",
|
||||
data: {
|
||||
id: "s_compat_1",
|
||||
userId: "u_compat_1",
|
||||
token: "tok",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string; userId: string };
|
||||
expect(session.userId).toBe("u_compat_1");
|
||||
|
||||
// account — string userId
|
||||
const account = (await adapter.create({
|
||||
model: "account",
|
||||
data: {
|
||||
id: "a_compat_1",
|
||||
userId: "u_compat_1",
|
||||
providerId: "p",
|
||||
accountId: "a",
|
||||
password: "",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { id: string; userId: string };
|
||||
expect(account.userId).toBe("u_compat_1");
|
||||
|
||||
// organization + member — record<user>, record<organization>
|
||||
await adapter.create({
|
||||
model: "organization",
|
||||
data: {
|
||||
id: "o_compat_1",
|
||||
name: "Acme",
|
||||
slug: "acme-compat",
|
||||
} as unknown as Record<string, unknown>,
|
||||
});
|
||||
const member = (await adapter.create({
|
||||
model: "member",
|
||||
data: {
|
||||
id: "m_compat_1",
|
||||
userId: "u_compat_1",
|
||||
organizationId: "o_compat_1",
|
||||
role: "admin",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { userId: string; organizationId: string };
|
||||
expect(member.userId).toBe("u_compat_1");
|
||||
expect(member.organizationId).toBe("o_compat_1");
|
||||
|
||||
// oauth_access_token — string userId (not in default linkFields)
|
||||
const tok = (await adapter.create({
|
||||
model: "oauth_access_token",
|
||||
data: {
|
||||
id: "t_compat_1",
|
||||
userId: "u_compat_1",
|
||||
accessToken: "a",
|
||||
refreshToken: "r",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})) as { userId: string };
|
||||
expect(tok.userId).toBe("u_compat_1");
|
||||
});
|
||||
|
||||
it("applyBetterAuthSchema is idempotent against real SurrealDB", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
if (!client) throw new Error("client not ready");
|
||||
await applyBetterAuthSchema(client);
|
||||
await applyBetterAuthSchema(client);
|
||||
// Re-application succeeds; idempotency proven by no throw.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import {
|
||||
compileWhere,
|
||||
type WhereClause,
|
||||
} from "../src/compile/compile-where.js";
|
||||
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "../src/link-fields/link-fields.js";
|
||||
|
||||
const OPERATORS = [
|
||||
"eq",
|
||||
"ne",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"in",
|
||||
"not_in",
|
||||
"contains",
|
||||
"starts_with",
|
||||
"ends_with",
|
||||
] as const;
|
||||
|
||||
const IDENT_FIELD = fc
|
||||
.stringMatching(/^[a-z][a-zA-Z0-9_]{0,10}$/)
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const clauseArb = fc.record({
|
||||
field: IDENT_FIELD,
|
||||
value: fc.oneof(
|
||||
fc.string(),
|
||||
fc.integer(),
|
||||
fc.boolean(),
|
||||
fc
|
||||
.array(fc.string(), { minLength: 1, maxLength: 4 })
|
||||
.filter((arr) => arr.every((v) => !v.includes(":")))
|
||||
),
|
||||
operator: fc.constantFrom(...OPERATORS),
|
||||
connector: fc.constantFrom("AND", "OR" as const),
|
||||
}) as unknown as fc.Arbitrary<WhereClause>;
|
||||
|
||||
describe("compileWhere property tests", () => {
|
||||
it("placeholder names are always unique and match var keys", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(clauseArb, { minLength: 1, maxLength: 20 }),
|
||||
(clauses) => {
|
||||
const out = compileWhere(clauses, {
|
||||
model: "user",
|
||||
linkFields: {},
|
||||
});
|
||||
const placeholders = Array.from(
|
||||
out.clause.matchAll(/\$\w+/g)
|
||||
).map((m) => m[0].slice(1));
|
||||
const dedup = new Set(placeholders);
|
||||
expect(dedup.size).toBe(placeholders.length);
|
||||
expect(new Set(Object.keys(out.vars))).toEqual(dedup);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("link-field where values are always pre-resolved", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const linkField = "userId";
|
||||
const linkValueArb = fc
|
||||
.string({ minLength: 1, maxLength: 10 })
|
||||
.filter((s) => !s.includes(":") && /^[a-z0-9]+$/i.test(s));
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
field: fc.constant(linkField),
|
||||
value: linkValueArb,
|
||||
operator: fc.constant("eq" as const),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 }
|
||||
),
|
||||
(clauses) => {
|
||||
const out = compileWhere(clauses, {
|
||||
model: "session",
|
||||
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
|
||||
});
|
||||
for (const placeholder of Object.keys(out.vars)) {
|
||||
const v = out.vars[placeholder];
|
||||
expect(String(v).startsWith("user:")).toBe(true);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/better-auth-surrealdb.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
|
||||
* module is imported so the meter resolved at module-load (well, at first
|
||||
* call given the lazy-init pattern) is the real provider's meter.
|
||||
*
|
||||
* Node ≥18 + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally,
|
||||
* so no crypto polyfill is required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SurrealClientView } from "../../src/types/adapter.js";
|
||||
|
||||
/**
|
||||
* In-memory `SurrealClientView` used by unit tests. Records every call.
|
||||
*
|
||||
* Lives under `src/` (not `tests/`) so coverage configs that exclude
|
||||
* `tests/**` still account for usage — but it is intentionally never
|
||||
* referenced by production exports (the root barrel never re-exports it),
|
||||
* so consumers do not see it.
|
||||
*/
|
||||
/**
|
||||
* The adapter rewrites named placeholders into positional `$pN` and passes
|
||||
* params as a positional array. FakeClient reconstructs a named record
|
||||
* keyed by `pN` so unit tests can inspect bound values predictably.
|
||||
*/
|
||||
export class FakeClient implements SurrealClientView {
|
||||
public isConnected = true;
|
||||
public log: Array<{
|
||||
sql: string;
|
||||
params: readonly unknown[];
|
||||
vars: Record<string, unknown>;
|
||||
}> = [];
|
||||
protected readonly queryHandler: (
|
||||
sql: string,
|
||||
vars: Record<string, unknown>
|
||||
) => Record<string, unknown>[];
|
||||
|
||||
public constructor(
|
||||
handler: (
|
||||
sql: string,
|
||||
vars: Record<string, unknown>
|
||||
) => Record<string, unknown>[] = () => []
|
||||
) {
|
||||
this.queryHandler = handler;
|
||||
}
|
||||
|
||||
public async query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: readonly unknown[]
|
||||
): Promise<T[]> {
|
||||
const arr = params ?? [];
|
||||
const vars: Record<string, unknown> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
vars[`p${i}`] = arr[i];
|
||||
}
|
||||
this.log.push({ sql, params: arr, vars });
|
||||
return this.queryHandler(sql, vars) as T[];
|
||||
}
|
||||
|
||||
public async execute(sql: string): Promise<{ rowsAffected: number }> {
|
||||
this.log.push({ sql, params: [], vars: {} });
|
||||
return { rowsAffected: 0 };
|
||||
}
|
||||
}
|
||||
12
packages/identity/auth/better-auth-surrealdb/tsconfig.json
Normal file
12
packages/identity/auth/better-auth-surrealdb/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
45
packages/identity/auth/better-auth-surrealdb/vite.config.ts
Normal file
45
packages/identity/auth/better-auth-surrealdb/vite.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/better-auth-surrealdb",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"testing/index": "src/testing/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
assetFileNames: "[name][extname]",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^better-auth/,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
"surrealdb",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"src/testing/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user