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:
.surqlschema — every plugin (or supporting adapter) that owns SurrealDB tables ships its DDL as a bundled string asset at the./schemasubpath. Operators apply it via@suaveplan/api-migrationscreateSurrealqlAdapter— never hand-coded against a remote source-of-truth file. Schema versions move in lockstep with the package version.- 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 typedSuaveplanErrorcodes with the ZodError ascause. - OpenAPI integration — every route a plugin owns is built with
@suaveplan/api-openapi'sRouteBuilder(preserves Zod types end-to-end) and registered viadefinePluginRoutes(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-AuthAdapterobject satisfying the published v1.4 interface.optionsis Zod-validated viaBetterAuthSurrealAdapterOptionsSchema.BetterAuthSurrealAdapterOptions:{ client: SurrealAdapter | SurrealConnection; modelToTable?: (model) => string; idStrategy?: IdStrategy; linkFields?: LinkFieldMap; logger?: LoggerLike }.clientaccepts either the high-levelSurrealAdapter(preferred — connection-managed) or a rawSurrealConnection.IdStrategy— controls SurrealDB record-ID format:"record-id"(default —idbecomes record component, storage usestable:id),"plain"(idstored 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.
linkFields — the mixed record-link / string-userId fix
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" }onsessioncompiles toWHERE userId = user:abc, notWHERE userId = "abc".
Where-clause compiler
compileWhere(where, options)— pure helper mapping Better-Auth'sWhere[]filter onto a SurrealQLWHEREfragment + parameterised vars. Operators:eq(default),ne,gt,gte,lt,lte,in,not_in,contains,starts_with,ends_with. ConnectorsAND(default),OR. Placeholders$w0,$w1, … prevent injection. Link-field values pre-resolved peroptions.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 AFTERorCREATE <table>:<id>(idStrategy="record-id"). Link fields indataauto-prefixed; returned row'sidand 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) ornull.findMany<T>({ model, where?, limit?, offset?, sortBy?, select? })→ fullSELECT … LIMIT … START … ORDER BY …pipeline.update<T>({ model, where, update })→UPDATE <table> MERGE $update WHERE <compiled> RETURN AFTER→ first updated row ornull.updateMany({ model, where, update })→ same asupdate, 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)
./schemasubpath exports the canonical 17-table Better-Auth SurrealQL schema as a frozen string constantBETTER_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 OVERWRITEandDEFINE FIELD OVERWRITEso re-application is idempotent (matches the genesis target's authoring convention). applyBetterAuthSchema(adapter: SurrealAdapter): Promise<void>— convenience helper that runs the schema string viaadapter.execute. Operators using@suaveplan/api-migrationsapply 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/optionsexportsBetterAuthSurrealAdapterOptionsSchema(Zod) — runtime-validated atcreateBetterAuthSurrealAdapterboundary; invalid input raisesBetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)carrying the ZodError ascause../schemas/link-fieldsexportsLinkFieldMapSchema— Zod-validated structure check on the optionallinkFieldsargument.
Errors + observability
BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode>viadefineCodes("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}, histogrambetter_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-migrationsis the operator's choice (and the recommended path —applyBetterAuthSchemais 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
AdapterAPI doesn't expose transactions to callers; SurrealDB'srunSurrealTransactionis 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_SCHEMAfor the latest, or pin to a package version.
Source mapping
This package is genesis-original (no upstream port). Design intent draws from:
- Better-Auth's published
Adapterinterface (https://better-auth.com/docs/concepts/database#custom-adapter). @suaveplan/surrealdbshipped surface (SurrealAdapter,surrealCreate/surrealSelect/surrealUpdate/surrealDelete,runSurrealTransaction).- SurrealQL
WHERE/ORDER BY/LIMIT/START/SELECT count() GROUP ALLsyntax. - The reference Drizzle adapter (https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts) — used as the canonical example of expected adapter behaviour for
idStrategysemantics andWhere[]compilation. - The canonical 17-table SurrealQL schema bundled at
/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql(the user's source-of-truth schema repo). The genesis package bundles this verbatim asBETTER_AUTH_SURREAL_SCHEMA; future schema changes update both in lockstep.
| 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 newcatalog:authblock in rootpackage.json(peer dep here; runtime dep inapi-plugin-auth).