Initial Commit
This commit is contained in:
@@ -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`).
|
||||
@@ -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 -->
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user