Files
suaveplan/openspec/archive/2026-05-18-add-better-auth-surrealdb/proposal.md
2026-05-27 23:29:53 +01:00

140 lines
13 KiB
Markdown

# 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.
### 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:
```ts
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:
- Better-Auth's published `Adapter` interface (https://better-auth.com/docs/concepts/database#custom-adapter).
- `@suaveplan/surrealdb` shipped surface (`SurrealAdapter`, `surrealCreate`/`surrealSelect`/`surrealUpdate`/`surrealDelete`, `runSurrealTransaction`).
- SurrealQL `WHERE`/`ORDER BY`/`LIMIT`/`START`/`SELECT count() GROUP ALL` syntax.
- 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 `idStrategy` semantics and `Where[]` 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 as `BETTER_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 new `catalog:auth` block in root `package.json` (peer dep here; runtime dep in `api-plugin-auth`).