Files
2026-05-27 23:29:53 +01:00

13 KiB

Add @suaveplan/better-auth-surrealdb — Better-Auth adapter + bundled SurrealQL schema

Package: @suaveplan/better-auth-surrealdb (NEW, server-tier) Status: PROPOSED Type: new-package Position: foundation of the better-auth integration chain — gates add-api-plugin-auth

  • Spec delta: ./specs/better-auth-surrealdb/spec.md

Why

Better-Auth supports any backing store via its Adapter interface (CRUD + count + Where-filter compilation). Its first-party adapters target Drizzle, Prisma, Kysely, in-memory. SurrealDB has no first-party adapter, but genesis already ships @suaveplan/surrealdb (universal SurrealAdapter over a real SurrealDB v3 connection), @suaveplan/api-migrations (the migration runner with createSurrealqlAdapter), and @suaveplan/testing-integration-surrealdb (testcontainers fixture). Writing a thin Better-Auth → SurrealDB adapter is the cleanest path to running Better-Auth on Suaveplan's database of record.

This package is deliberately separate from @suaveplan/api-plugin-auth because Better-Auth adapters are useful outside the plugin framework — any Bun/Node app that wants Better-Auth + SurrealDB can pick this up without dragging in the @suaveplan/api orchestrator.

This package ALSO bundles the canonical 17-table Better-Auth SurrealQL schema as a static asset, so operators get one-stop integration: adapter + schema + conformance harness ship together and stay in lockstep across releases.

Plug-and-play contract (cross-cutting principle for every api-plugin-* package)

This adapter is the first concrete instance of the 3-way plug-and-play contract every @suaveplan/api-plugin-* package follows from here on:

  1. .surql schema — every plugin (or supporting adapter) that owns SurrealDB tables ships its DDL as a bundled string asset at the ./schema subpath. Operators apply it via @suaveplan/api-migrations createSurrealqlAdapter — never hand-coded against a remote source-of-truth file. Schema versions move in lockstep with the package version.
  2. Zod schemas — every public input boundary (adapter options, route bodies, query params, headers) is Zod-validated, exported under ./schemas/<name> so downstream callers consume the same types the producer asserts. Boundary failures raise typed SuaveplanError codes with the ZodError as cause.
  3. OpenAPI integration — every route a plugin owns is built with @suaveplan/api-openapi's RouteBuilder (preserves Zod types end-to-end) and registered via definePluginRoutes(routes, { app, registry, pluginName }). The host emits one unified OpenAPI 3.1 document covering every plugin atomically.

For this adapter (which has no HTTP surface), points 1 and 2 apply: schema bundled, options Zod-validated. Point 3 lands in @suaveplan/api-plugin-auth (the first plugin that consumes this adapter and ships routes).

What changes

Net-new package at 0.1.0. ADDED:

Adapter factory

  • createBetterAuthSurrealAdapter(options): Adapter — factory returning a Better-Auth Adapter object satisfying the published v1.4 interface. options is Zod-validated via BetterAuthSurrealAdapterOptionsSchema.
  • BetterAuthSurrealAdapterOptions: { client: SurrealAdapter | SurrealConnection; modelToTable?: (model) => string; idStrategy?: IdStrategy; linkFields?: LinkFieldMap; logger?: LoggerLike }. client accepts either the high-level SurrealAdapter (preferred — connection-managed) or a raw SurrealConnection.
  • IdStrategy — controls SurrealDB record-ID format: "record-id" (default — id becomes record component, storage uses table:id), "plain" (id stored as a plain field on an auto-named record), or "ulid" (auto-generated when missing).
  • modelToTable(model) — default identity. Validated against /^[a-zA-Z_][a-zA-Z0-9_]*$/ on first use per model.

The genesis target Better-Auth schema (see "Bundled schema" below) uses mixed user-reference conventions: some tables type userId as record<user> (typed link, stored as user:abc), others as plain string. Better-Auth gives the adapter userId: "abc" regardless of which table; without per-field record-link awareness, writes to record<user> columns fail SurrealDB's type assertion and reads return prefixed strings that break Better-Auth's identity comparisons.

  • LinkFieldMap — Record<string /* model */, Record<string /* field */, string /* target table */>>. For each entry, on write the adapter prefixes plain string values with <target>: AND on read the inverse — strips the prefix from those same fields before returning.

  • DEFAULT_BETTER_AUTH_LINK_FIELDS — baked-in default matching the bundled 17-table schema:

    export const DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap = {
      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" },
    };
    
  • Operators can override per-model when their schema differs (e.g. flat-string userId everywhere). Passing linkFields: {} disables prefix manipulation entirely (for "plain" schemas).

  • Where-filter values for link fields are auto-prefixed too: { field: "userId", value: "abc" } on session compiles to WHERE userId = user:abc, not WHERE userId = "abc".

Where-clause compiler

  • compileWhere(where, options) — pure helper mapping Better-Auth's Where[] filter onto a SurrealQL WHERE fragment + parameterised vars. Operators: eq (default), ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with. Connectors AND (default), OR. Placeholders $w0, $w1, … prevent injection. Link-field values pre-resolved per options.linkFields[model] before binding.
  • compileSortBy({ field, direction }) → ORDER BY <field> <ASC|DESC>.
  • compileSelect(model, select?) → projection builder.

8 Better-Auth Adapter methods

All implementations dispatch through the configured SurrealAdapter's query path:

  • create<T>({ model, data }) → CREATE <table> CONTENT $data RETURN AFTER or CREATE <table>:<id> (idStrategy="record-id"). Link fields in data auto-prefixed; returned row's id and link fields stripped of <table>:.
  • findOne<T>({ model, where, select? }) → SELECT <projection> FROM <table> WHERE <compiled> LIMIT 1. Returns the first row (with id+link-field prefixes stripped) or null.
  • findMany<T>({ model, where?, limit?, offset?, sortBy?, select? }) → full SELECT … LIMIT … START … ORDER BY … pipeline.
  • update<T>({ model, where, update }) → UPDATE <table> MERGE $update WHERE <compiled> RETURN AFTER → first updated row or null.
  • updateMany({ model, where, update }) → same as update, returns numeric count.
  • delete({ model, where }) → DELETE <table> WHERE <compiled> → void.
  • deleteMany({ model, where }) → same, returns count.
  • count({ model, where? }) → SELECT count() FROM <table> [WHERE <compiled>] GROUP ALL → numeric.

Bundled SurrealQL schema (point 1 of the plug-and-play contract)

  • ./schema subpath exports the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant BETTER_AUTH_SURREAL_SCHEMA. Tables: user (field extensions only — the base table is created by the host's user schema), session, account, verification, passkey, jwks, sso_provider, oauth_application, oauth_access_token, oauth_consent, organization, member, invitation, team, team_member, two_factor, api_key. Plus 13 indexes (covering email uniqueness, phone uniqueness, provider+account combos, etc.).
  • DDL uses DEFINE TABLE OVERWRITE and DEFINE FIELD OVERWRITE so re-application is idempotent (matches the genesis target's authoring convention).
  • applyBetterAuthSchema(adapter: SurrealAdapter): Promise<void> — convenience helper that runs the schema string via adapter.execute. Operators using @suaveplan/api-migrations apply it as a migration step instead; this helper is for callers who haven't adopted that runner.
  • The schema content matches the canonical Suaveplan target schema at /mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql. Schema version moves in lockstep with this package's version; consumers pin both together.

Zod boundary schemas (point 2 of the plug-and-play contract)

  • ./schemas/options exports BetterAuthSurrealAdapterOptionsSchema (Zod) — runtime-validated at createBetterAuthSurrealAdapter boundary; invalid input raises BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT) carrying the ZodError as cause.
  • ./schemas/link-fields exports LinkFieldMapSchema — Zod-validated structure check on the optional linkFields argument.

Errors + observability

  • BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode> via defineCodes("BETTER_AUTH_SURREALDB", [...]) — codes: INVALID_INPUT, INVALID_MODEL, INVALID_OPERATOR, QUERY_FAILED, INVALID_ID_FORMAT, INVALID_LINK_FIELD_VALUE, CONNECTION_NOT_READY.
  • OTel: counter better_auth.surrealdb.operations_total{model, operation, outcome}, histogram better_auth.surrealdb.operation_duration_ms{operation}.

Subpath exports

., ./adapter, ./compile, ./id-strategy, ./link-fields, ./schema, ./schemas/options, ./schemas/link-fields, ./error-codes, ./otel, ./types, ./testing. The ./testing subpath re-exports runAdapterConformance(factory) — runs Better-Auth's full adapter contract against the factory backed by a real SurrealDB testcontainer (applies BETTER_AUTH_SURREAL_SCHEMA first).

Out of scope

  • Schema migration framework integration. This package ships the schema as a string; running it via @suaveplan/api-migrations is the operator's choice (and the recommended path — applyBetterAuthSchema is a convenience escape hatch).
  • Multi-tenant tenant-id auto-injection. If Better-Auth needs tenant scoping, the operator passes a tenant-scoped SurrealAdapter (constructed against a tenant-specific namespace). The adapter does not interpolate tenant context.
  • Transactions across Better-Auth operations. Better-Auth's Adapter API doesn't expose transactions to callers; SurrealDB's runSurrealTransaction is reachable to operators who need cross-adapter atomicity directly.
  • Other Better-Auth-supported databases. This package is SurrealDB only.
  • Schema diffing / generation. Operators read BETTER_AUTH_SURREAL_SCHEMA for the latest, or pin to a package version.

Source mapping

This package is genesis-original (no upstream port). Design intent draws from:

Requirement Source
Adapter interface (8 methods + id) Better-Auth docs
Where[] compilation with operator + connector Better-Auth Where type
Record-ID prefix normalisation SurrealDB table:id convention
linkFields per-field record-link awareness Genesis target schema's mixed record<user> vs string conventions
17-table bundled schema /mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql verbatim
Plug-and-play contract (.surql + Zod + OpenAPI) Genesis architectural principle established in this proposal
Zod boundary validation + structured errors + OTel CLAUDE.md enterprise policy

Impact

Affected dependents

None today — net-new. First consumer: @suaveplan/api-plugin-auth (sibling change add-api-plugin-auth). Reusable by any Bun/Node app wanting Better-Auth + SurrealDB.

Catalog additions

  • better-auth ^1.4.0 added to a new catalog:auth block in root package.json (peer dep here; runtime dep in api-plugin-auth).