Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
# 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`).

View File

@@ -0,0 +1,301 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: better-auth-surrealdb (add-better-auth-surrealdb)
**Package**: `@suaveplan/better-auth-surrealdb`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: createBetterAuthSurrealAdapter returns a valid Better-Auth Adapter with Zod-validated options
The package SHALL export `createBetterAuthSurrealAdapter(options): Adapter` returning an object that satisfies Better-Auth's published `Adapter` interface: `{ id: "@suaveplan/better-auth-surrealdb"; create; findOne; findMany; update; updateMany; delete; deleteMany; count }`. Options SHALL be Zod-validated via `BetterAuthSurrealAdapterOptionsSchema` (exported from `./schemas/options`) at the factory boundary; failures SHALL raise `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)` carrying the `ZodError` as `cause`.
#### Scenario: factory produces all required methods
- **WHEN** `createBetterAuthSurrealAdapter({ client })` is called with a valid `SurrealAdapter`
- **THEN** the returned object has `id === "@suaveplan/better-auth-surrealdb"` and every Better-Auth `Adapter` method is a function
#### Scenario: invalid options raise INVALID_INPUT
- **WHEN** `createBetterAuthSurrealAdapter({} as never)` is called (missing `client`)
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/adapter/adapter.test.ts::"factory produces every Better-Auth Adapter method"` AND `src/adapter/adapter.test.ts::"invalid options raise INVALID_INPUT"` AND `src/adapter/adapter.spec-conformance.test.ts::"satisfies Better-Auth Adapter type via expectTypeOf"`
---
### Requirement: linkFields auto-prefix writes and strip prefixes on reads
The package SHALL export `LinkFieldMap` type and `DEFAULT_BETTER_AUTH_LINK_FIELDS` constant. The constant SHALL cover every record-link field in the bundled 17-table schema: `session.userId`, `passkey.userId`, `member.userId/organizationId`, `invitation.inviterId/organizationId/teamId`, `team.organizationId`, `team_member.userId/teamId`, `two_factor.userId`, `api_key.userId`, `sso_provider.userId`. On `create` and `update`, the adapter SHALL prefix string values for declared link fields with `<target_table>:`. On `findOne`, `findMany`, the adapter SHALL strip the matching prefix from those fields before returning rows. On `Where[]` values for declared link fields, the adapter SHALL pre-resolve the prefix before binding.
#### Scenario: session.userId write wraps into record-link form
- **WHEN** `adapter.create({ model: "session", data: { userId: "u1", token: "t" } })` is awaited against a real SurrealDB testcontainer (schema applied) with default `linkFields`
- **THEN** the stored row's `userId` field equals the SurrealDB record-link `user:u1` AND the returned object has `userId === "u1"` (prefix stripped)
#### Scenario: where-filter pre-resolves link prefix
- **WHEN** `adapter.findMany({ model: "session", where: [{ field: "userId", value: "u1" }] })` is awaited
- **THEN** the generated SurrealQL `WHERE` clause binds `userId = user:u1` (NOT `userId = "u1"`)
#### Scenario: empty linkFields disables prefix manipulation
- **WHEN** `createBetterAuthSurrealAdapter({ client, linkFields: {} })` runs `create/findOne` on `session`
- **THEN** values pass through verbatim — no prefix added on write, none stripped on read
#### Scenario: account.userId (string-typed in schema) is NOT prefixed
- **WHEN** `adapter.create({ model: "account", data: { userId: "u1", providerId: "p", accountId: "a", password: "" } })` runs with default `linkFields`
- **THEN** the stored `userId` field equals the plain string `"u1"` (NOT `user:u1`) — `account` is absent from `DEFAULT_BETTER_AUTH_LINK_FIELDS`
**Verified by:** `src/link-fields/link-fields.test.ts::"DEFAULT_BETTER_AUTH_LINK_FIELDS covers every record-link field in the bundled schema"` AND `src/adapter/create.test.ts::"session.userId write wraps into record-link form"` AND `src/adapter/find.test.ts::"where-filter pre-resolves link prefix"` AND `src/link-fields/link-fields.test.ts::"empty linkFields disables prefix manipulation"` AND `src/link-fields/link-fields.test.ts::"account.userId is not prefixed under default config"`
---
### Requirement: create maps to SurrealQL CREATE … CONTENT … RETURN AFTER
`adapter.create<T>({ model, data })` SHALL issue `CREATE <modelToTable(model)> CONTENT $data RETURN AFTER` (or `CREATE <table>:<id> CONTENT $data` when `data.id` is supplied or `idStrategy === "record-id"`) via the configured `SurrealAdapter.query` path, return the first returned row with the `<table>:` prefix stripped from `id` AND from every declared link field. When `idStrategy: "ulid"`, an auto-generated ULID SHALL be injected into `data.id` when Better-Auth does not supply one.
#### Scenario: create injects record-id prefix in storage
- **WHEN** `adapter.create({ model: "user", data: { id: "abc", email: "x@y" } })` is awaited
- **THEN** a row exists at `user:abc` with `email === "x@y"` AND the returned object has `id === "abc"` (no `user:` prefix)
#### Scenario: ulid strategy fills missing id
- **WHEN** `adapter.create({ model: "session", data: { userId: "u1" } })` runs with `idStrategy: "ulid"` and `data.id` absent
- **THEN** the returned object has a non-empty `id` matching `/^[0-9A-Z]{26}$/`
**Verified by:** `src/adapter/create.test.ts::"create stores under table:id and strips prefix on return"` AND `src/adapter/create.test.ts::"ulid strategy fills missing id"` AND `tests/integration/conformance.test.ts::"create roundtrips against real SurrealDB"`
---
### Requirement: findOne and findMany compile Where[] to parameterised SurrealQL
`adapter.findOne<T>({ model, where, select? })` and `adapter.findMany<T>({ model, where?, limit?, offset?, sortBy?, select? })` SHALL build a parameterised `SELECT <projection> FROM <table> [WHERE …] [ORDER BY <field> <dir>] [LIMIT …] [START …]` query. `findOne` appends `LIMIT 1` and returns the first row or `null`. Every returned row's `id` AND declared link fields SHALL have prefixes stripped.
#### Scenario: findOne returns null when no match
- **WHEN** `adapter.findOne({ model: "user", where: [{ field: "email", value: "missing@x" }] })` is awaited
- **THEN** the result is `null`
#### Scenario: findMany honours limit / offset / sortBy
- **WHEN** 5 user rows exist and `adapter.findMany({ model: "user", limit: 2, offset: 1, sortBy: { field: "createdAt", direction: "asc" } })` is awaited
- **THEN** the result has length 2 starting at the second-oldest row
**Verified by:** `src/adapter/find.test.ts::"findOne returns null on no match"` AND `src/adapter/find.test.ts::"findMany honours limit/offset/sortBy"` AND `tests/integration/conformance.test.ts::"findOne and findMany conformance against real SurrealDB"`
---
### Requirement: compileWhere supports the full Better-Auth operator set
`compileWhere(where: Where[], options)` SHALL support operators `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `starts_with`, `ends_with` AND connectors `AND` (default), `OR`. The output SHALL be `{ clause: string; vars: Record<string, unknown> }` with placeholders `$w0`, `$w1`, …. Link-field values are pre-resolved per `options.linkFields[model]` before binding. Unknown operators SHALL raise `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_OPERATOR)`.
#### Scenario: operator maps to SurrealQL
- **WHEN** `compileWhere([{ field: "age", value: 18, operator: "gte" }], { model: "user", linkFields: {} })` is called
- **THEN** the result `clause` contains `age >= $w0` AND `vars.w0 === 18`
#### Scenario: connector composes clauses
- **WHEN** `compileWhere([{ field: "a", value: 1 }, { field: "b", value: 2, connector: "OR" }], { model, linkFields: {} })` is called
- **THEN** the clause is `a = $w0 OR b = $w1`
#### Scenario: unknown operator raises INVALID_OPERATOR
- **WHEN** `compileWhere([{ field: "x", value: 1, operator: "weird" as never }], { model, linkFields: {} })` is called
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_OPERATOR)` is thrown
**Verified by:** `src/compile/compile-where.test.ts::"maps every supported operator"` AND `src/compile/compile-where.test.ts::"AND/OR connectors compose"` AND `src/compile/compile-where.test.ts::"unknown operator raises INVALID_OPERATOR"` AND `src/compile/compile-where.test.ts::"placeholder names never collide"` AND `src/compile/compile-where.test.ts::"link-field where values pre-resolved to record-link form"`
---
### Requirement: update / updateMany merge fields and return the affected count or row
`adapter.update<T>({ model, where, update })` SHALL issue `UPDATE <table> MERGE $update WHERE <compiled> RETURN AFTER` and return the first updated row (with prefixes stripped) or `null`. `adapter.updateMany(args)` SHALL return the number of rows updated.
#### Scenario: update returns the merged row
- **WHEN** a `user` exists with `email: "old"` and `adapter.update({ model: "user", where: [{ field: "id", value: "u1" }], update: { email: "new" } })` is awaited
- **THEN** the result has `email === "new"` AND `id === "u1"` (prefix stripped)
#### Scenario: updateMany returns count
- **WHEN** 3 users have `role: "free"` and `adapter.updateMany({ model: "user", where: [{ field: "role", value: "free" }], update: { role: "pro" } })` is awaited
- **THEN** the result equals 3
**Verified by:** `src/adapter/update.test.ts::"update returns merged row"` AND `src/adapter/update.test.ts::"updateMany returns affected count"` AND `tests/integration/conformance.test.ts::"update conformance against real SurrealDB"`
---
### Requirement: delete / deleteMany remove rows by Where filter
`adapter.delete({ model, where })` SHALL issue `DELETE <table> WHERE <compiled>` and return `void`. `adapter.deleteMany(args)` SHALL return the number of rows deleted.
#### Scenario: delete removes the row
- **WHEN** a session exists at `session:s1` and `adapter.delete({ model: "session", where: [{ field: "id", value: "s1" }] })` is awaited
- **THEN** a subsequent `findOne` for that id returns `null`
#### Scenario: deleteMany returns count
- **WHEN** 4 expired sessions exist and `adapter.deleteMany({ model: "session", where: [{ field: "expiresAt", value: now, operator: "lt" }] })` is awaited
- **THEN** the result equals 4
**Verified by:** `src/adapter/delete.test.ts::"delete removes the row"` AND `src/adapter/delete.test.ts::"deleteMany returns affected count"`
---
### Requirement: count uses SELECT count() GROUP ALL
`adapter.count({ model, where? })` SHALL issue `SELECT count() FROM <table> [WHERE <compiled>] GROUP ALL` and return the numeric count.
#### Scenario: count without where returns total
- **WHEN** 10 sessions exist and `adapter.count({ model: "session" })` is awaited
- **THEN** the result equals 10
#### Scenario: count with where returns filtered
- **WHEN** 10 sessions exist and 3 have `userId: "u1"`, and `adapter.count({ model: "session", where: [{ field: "userId", value: "u1" }] })` is awaited
- **THEN** the result equals 3 (where-clause pre-resolves `userId` to record-link form)
**Verified by:** `src/adapter/count.test.ts::"count without where returns total"` AND `src/adapter/count.test.ts::"count with where returns filtered"`
---
### Requirement: idStrategy controls record-ID format
The package SHALL support 3 `idStrategy` values: `"record-id"` (default), `"plain"`, `"ulid"`. `idStrategy: "record-id"` with an `id` containing a `:` character SHALL raise `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_ID_FORMAT)`.
#### Scenario: plain strategy stores id as a field
- **WHEN** `createBetterAuthSurrealAdapter({ client, idStrategy: "plain" })` runs `adapter.create({ model: "user", data: { id: "u1", email: "x" } })`
- **THEN** the stored record's SurrealDB-native id is auto-generated AND the row has fields `id: "u1"` and `email: "x"`
#### Scenario: invalid record-id format raises
- **WHEN** the default strategy receives `data.id = "abc:def"` on create
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_ID_FORMAT)` is thrown
**Verified by:** `src/id-strategy/id-strategy.test.ts::"plain strategy stores id as a field"` AND `src/id-strategy/id-strategy.test.ts::"record-id strategy strips and reapplies prefix"` AND `src/id-strategy/id-strategy.test.ts::"ulid strategy fills missing id"` AND `src/id-strategy/id-strategy.test.ts::"colon in record-id raises INVALID_ID_FORMAT"`
---
### Requirement: modelToTable mapping is overridable and validated
`options.modelToTable?: (model: string) => string` SHALL default to identity. When supplied, the function output SHALL be cached per model. Returned table names SHALL match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`; mismatches raise `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_MODEL)` on first use.
#### Scenario: custom mapping prefixes every table
- **WHEN** `createBetterAuthSurrealAdapter({ client, modelToTable: (m) => "bauth_" + m })` runs `adapter.create({ model: "user", data: { … } })`
- **THEN** the row is stored under `bauth_user:<id>`
#### Scenario: invalid table name raises
- **WHEN** `modelToTable` returns `"weird-name"` for some model and `create` is called
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_MODEL)` is thrown
**Verified by:** `src/adapter/model-to-table.test.ts::"custom mapping prefixes tables"` AND `src/adapter/model-to-table.test.ts::"invalid table name raises INVALID_MODEL"` AND `src/adapter/model-to-table.test.ts::"mapping cached after first call"`
---
### Requirement: Bundled SurrealQL schema covers all 17 Better-Auth tables
The package SHALL export `BETTER_AUTH_SURREAL_SCHEMA` (frozen string) from `./schema` containing the DDL for all 17 Better-Auth tables: `user` (field extensions: `emailVerified`, `name`, `image`), `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 SHALL use `DEFINE TABLE OVERWRITE` and `DEFINE FIELD OVERWRITE` so re-application is idempotent. `applyBetterAuthSchema(adapter): Promise<void>` SHALL execute the schema string via `adapter.execute`. Schema content matches the canonical Suaveplan target at `/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`.
#### Scenario: schema string contains every required table
- **WHEN** the conformance test parses `BETTER_AUTH_SURREAL_SCHEMA` for `DEFINE TABLE OVERWRITE <name>` occurrences
- **THEN** the set includes session, account, verification, passkey, jwks, sso_provider, oauth_application, oauth_access_token, oauth_consent, organization, member, invitation, team, team_member, two_factor, api_key (16 explicit `DEFINE TABLE` — `user` table is created elsewhere, only fields are extended here)
#### Scenario: applyBetterAuthSchema is idempotent
- **WHEN** `applyBetterAuthSchema(adapter)` is called twice in succession against a real SurrealDB testcontainer
- **THEN** the second call resolves without error AND the table set is unchanged
#### Scenario: applied schema accepts canonical Better-Auth row writes
- **WHEN** `applyBetterAuthSchema(adapter)` is called, then `adapter.create` is invoked for a `session` with `userId: "user:u1"` and an `account` with `userId: "u1"`
- **THEN** both writes succeed (session enforces `record<user>`; account accepts `string`)
**Verified by:** `src/schema/schema.test.ts::"BETTER_AUTH_SURREAL_SCHEMA contains every required table"` AND `src/schema/schema.test.ts::"applyBetterAuthSchema is idempotent against real SurrealDB"` AND `tests/integration/schema-write-compatibility.test.ts::"applied schema accepts canonical Better-Auth row writes"`
---
### Requirement: Zod schemas exported at ./schemas/* subpaths for cross-package consumption
The package SHALL export Zod schemas at dedicated subpaths so downstream callers consume the same types the producer asserts at the boundary: `./schemas/options` exports `BetterAuthSurrealAdapterOptionsSchema`; `./schemas/link-fields` exports `LinkFieldMapSchema`. Boundary failures SHALL raise `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)` carrying the `ZodError` as `cause`.
#### Scenario: link-field structure rejected by LinkFieldMapSchema
- **WHEN** `LinkFieldMapSchema.safeParse({ session: { userId: 5 } })` is called (non-string target table)
- **THEN** `safeParse.success === false`
#### Scenario: createBetterAuthSurrealAdapter rejects malformed linkFields
- **WHEN** `createBetterAuthSurrealAdapter({ client, linkFields: { session: { userId: 5 as never } } })` is called
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
**Verified by:** `src/schemas/options.test.ts::"BetterAuthSurrealAdapterOptionsSchema rejects missing client"` AND `src/schemas/link-fields.test.ts::"LinkFieldMapSchema rejects non-string target table"` AND `src/adapter/adapter.test.ts::"createBetterAuthSurrealAdapter rejects malformed linkFields"`
---
### Requirement: Every BETTER_AUTH_SURREALDB_* code is thrown from a production path
`BETTER_AUTH_SURREALDB_CODES` SHALL define exactly 7 codes (`INVALID_INPUT`, `INVALID_MODEL`, `INVALID_OPERATOR`, `QUERY_FAILED`, `INVALID_ID_FORMAT`, `INVALID_LINK_FIELD_VALUE`, `CONNECTION_NOT_READY`). Every code SHALL be raised by ≥1 production call site.
#### Scenario: QUERY_FAILED wraps underlying SurrealError
- **WHEN** the underlying `SurrealAdapter.query` throws a `SurrealError` and an adapter operation is in-flight
- **THEN** the operation rejects with `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_QUERY_FAILED)` carrying the `SurrealError` as `cause`
#### Scenario: INVALID_LINK_FIELD_VALUE on bad link-field write
- **WHEN** `adapter.create({ model: "session", data: { userId: "abc:def" } })` is called with default `linkFields` (record-link target receives a value containing `:`)
- **THEN** `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_LINK_FIELD_VALUE)` is thrown
**Verified by:** `src/better-auth-surrealdb.spec-conformance.test.ts::"every BETTER_AUTH_SURREALDB_* code is thrown from a production path"`
---
### Requirement: OTel counter + histogram emit per operation
The package SHALL register counter `better_auth.surrealdb.operations_total` (attributes: `model`, `operation`, `outcome: "success" | "error"`) and histogram `better_auth.surrealdb.operation_duration_ms` (attribute: `operation`). Every adapter method emits both.
#### Scenario: counter emits success + error
- **WHEN** a successful `create` and a failed operation run against a real in-memory `MeterProvider`
- **THEN** `operations_total{outcome: "success"}` AND `operations_total{outcome: "error"}` both have observations; the histogram records both durations
**Verified by:** `src/otel/otel.test.ts::"counter emits success"` AND `src/otel/otel.test.ts::"counter emits error"` AND `src/otel/otel.test.ts::"histogram records per operation"`
---
### Requirement: Conformance harness exercises the full Better-Auth Adapter contract
The package SHALL export `runAdapterConformance(factory)` from `./testing`. The harness SHALL apply `BETTER_AUTH_SURREAL_SCHEMA` to the testcontainer-backed connection before running scenarios, then exercise every contract scenario Better-Auth's first-party adapters pass (CRUD + count + Where-filter combinations).
#### Scenario: harness registers every Better-Auth scenario
- **WHEN** `runAdapterConformance(() => createBetterAuthSurrealAdapter({ client }))` is invoked inside a test file backed by a real SurrealDB testcontainer
- **THEN** the resulting test run includes a "Better-Auth adapter conformance" describe block covering create / findOne / findMany / update / updateMany / delete / deleteMany / count, plus per-operator Where-filter coverage and link-field roundtrip checks
**Verified by:** `tests/integration/conformance.test.ts::"runAdapterConformance pins every Better-Auth scenario against real SurrealDB"`
---
### Requirement: Server-tier package shape
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module (`.`, `./adapter`, `./compile`, `./id-strategy`, `./link-fields`, `./schema`, `./schemas/options`, `./schemas/link-fields`, `./testing`, `./error-codes`, `./otel`, `./types`). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage via Istanbul.
#### Scenario: every subpath is reachable
- **WHEN** `tests/dep-graph.test.ts` inspects `package.json.exports` and imports each subpath
- **THEN** each resolves to a real module
**Verified by:** `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"` AND `tests/dep-graph.test.ts::"dependencies match the allowlist"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,59 @@
# Tasks — add-better-auth-surrealdb
## 1. Scaffold
- [ ] 1.1 Create `packages/auth/better-auth-surrealdb/` with `src/{adapter,compile,id-strategy,link-fields,schema,schemas,otel,error-codes,types,testing}/` + `tests/{setup,integration}/`
- [ ] 1.2 Add `better-auth` ^1.4.0 to root `package.json` new `catalogs.auth` block. Re-run `bun install` from repo root.
- [ ] 1.3 `package.json` v0.1.0. Deps: `@suaveplan/error` `@suaveplan/surrealdb` `@suaveplan/types` `tslib`, `zod` (`catalog:forms`), `@opentelemetry/api`. PeerDeps: `better-auth: ^1.4.0`.
- [ ] 1.4 12 subpath exports: `.`, `./adapter`, `./compile`, `./id-strategy`, `./link-fields`, `./schema`, `./schemas/options`, `./schemas/link-fields`, `./testing`, `./error-codes`, `./otel`, `./types`
- [ ] 1.5 tsconfigs + vite (rollup `assetFileNames` config so the `.surql` schema string is bundled into `dist/`) + vitest (100/100/100/100, testTimeout 60000 for integration)
- [ ] 1.6 `tests/setup/polyfills.ts`
## 2. Implementation
- [ ] 2.1 `src/error-codes.ts` + `.md` + test — 7 BETTER_AUTH_SURREALDB.* codes + `BetterAuthSurrealError`
- [ ] 2.2 `src/types/adapter.{ts,md,index.ts}` — `IdStrategy`, `LinkFieldMap`, `BetterAuthSurrealAdapterOptions`; re-exports of Better-Auth `Adapter` + `Where` types
- [ ] 2.3 `src/schemas/options.{ts,test.ts,md,index.ts}` — `BetterAuthSurrealAdapterOptionsSchema` (Zod)
- [ ] 2.4 `src/schemas/link-fields.{ts,test.ts,md,index.ts}` — `LinkFieldMapSchema`
- [ ] 2.5 `src/link-fields/link-fields.{ts,test.ts,md,index.ts}` — `DEFAULT_BETTER_AUTH_LINK_FIELDS` constant + `applyLinkPrefix(model, field, value, map)` + `stripLinkPrefix(model, field, value, map)` helpers
- [ ] 2.6 `src/compile/compile-where.{ts,test.ts,md,index.ts}` — operator + connector mapping; placeholder generator; link-field value pre-resolution via `link-fields` helpers
- [ ] 2.7 `src/compile/compile-select.{ts,test.ts,md}`
- [ ] 2.8 `src/compile/compile-sort.{ts,test.ts,md}`
- [ ] 2.9 `src/id-strategy/id-strategy.{ts,test.ts,md,index.ts}` — 3 strategies; record-id prefix manipulation; ULID generator
- [ ] 2.10 `src/otel/instruments.{ts,test.ts,md,index.ts}` — counter + histogram
- [ ] 2.11 `src/schema/schema.{ts,test.ts,md,index.ts}` — exports `BETTER_AUTH_SURREAL_SCHEMA` (frozen string copied verbatim from `/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`) + `applyBetterAuthSchema(adapter)` helper
- [ ] 2.12 `src/adapter/adapter.{ts,test.ts,md,index.ts}` — `createBetterAuthSurrealAdapter` factory + all 8 methods; integrates `link-fields` helpers in every read/write path
- [ ] 2.13 `src/adapter/model-to-table.{ts,test.ts,md}` — validation + caching
- [ ] 2.14 `src/testing/index.ts` + `.md` — `runAdapterConformance(factory)` that applies `BETTER_AUTH_SURREAL_SCHEMA` before exercising scenarios
- [ ] 2.15 `src/index.ts` — named re-exports only (no `export *`)
## 3. Tests
- [ ] 3.1 Co-located `*.test.ts` per spec scenario (unit tests use a stub SurrealAdapter for shape; integration tests use real testcontainer)
- [ ] 3.2 `src/better-auth-surrealdb.spec-conformance.test.ts` — every BETTER_AUTH_SURREALDB.* code thrown from a production path
- [ ] 3.3 `tests/integration/conformance.test.ts` — real `@suaveplan/testing-integration-surrealdb` testcontainer; calls `applyBetterAuthSchema(adapter)` then runs `runAdapterConformance(...)`
- [ ] 3.4 `tests/integration/schema-write-compatibility.test.ts` — applies `BETTER_AUTH_SURREAL_SCHEMA`, then writes representative rows on `session` (record-link `userId`), `account` (string `userId`), `member` (record-link `userId` AND `organizationId`), `oauth_access_token` (string `userId`). All four writes succeed.
- [ ] 3.5 `tests/property.test.ts` — fast-check (200 runs) on `compileWhere` invariants: placeholder names unique; vars match clause refs; link-field values always pre-resolved to record-link form when configured
- [ ] 3.6 `tests/dep-graph.test.ts`
- [ ] 3.7 All tests import runner from `@suaveplan/testing/runner`
- [ ] 3.8 Every `it()` has `{ timeout: 5000 }` (60000 for integration)
## 4. Documentation
- [ ] 4.1 Sibling `.md` per impl (≥200 words)
- [ ] 4.2 `README.md` — quick-start (`applyBetterAuthSchema` + `createBetterAuthSurrealAdapter`), full table of 17 schema tables + which use `record<user>` vs `string`, `linkFields` override examples, `idStrategy` choice table, conformance harness usage, peer-dep notes
- [ ] 4.3 Note in README that schema content is mirrored from canonical `/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`; changes upstream require a coordinated package version bump
## 5. Gates
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/better-auth-surrealdb --force`
- [ ] 5.2 Coverage 100/100/100/100
- [ ] 5.3 `bun scripts/openspec-conformance.ts @suaveplan/better-auth-surrealdb` exit 0
- [ ] 5.4 Build green
- [ ] 5.5 Banned-pattern grep + no `export *` + no direct vitest/bun:test imports
## 6. Archive
- [ ] 6.1 Fold spec → `openspec/specs/better-auth-surrealdb/spec.md`
- [ ] 6.2 mv change → `openspec/archive/<date>-add-better-auth-surrealdb/`
- [ ] 6.3 DO NOT refresh MANIFEST or STATUS (orchestrator consolidates after 4-package batch)