21 KiB
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 validSurrealAdapter - THEN the returned object has
id === "@suaveplan/better-auth-surrealdb"and every Better-AuthAdaptermethod is a function
Scenario: invalid options raise INVALID_INPUT
- WHEN
createBetterAuthSurrealAdapter({} as never)is called (missingclient) - THEN
BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)is thrown withcause 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 defaultlinkFields - THEN the stored row's
userIdfield equals the SurrealDB record-linkuser:u1AND the returned object hasuserId === "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
WHEREclause bindsuserId = user:u1(NOTuserId = "u1")
Scenario: empty linkFields disables prefix manipulation
- WHEN
createBetterAuthSurrealAdapter({ client, linkFields: {} })runscreate/findOneonsession - 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 defaultlinkFields - THEN the stored
userIdfield equals the plain string"u1"(NOTuser:u1) —accountis absent fromDEFAULT_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:abcwithemail === "x@y"AND the returned object hasid === "abc"(nouser:prefix)
Scenario: ulid strategy fills missing id
- WHEN
adapter.create({ model: "session", data: { userId: "u1" } })runs withidStrategy: "ulid"anddata.idabsent - THEN the returned object has a non-empty
idmatching/^[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
clausecontainsage >= $w0ANDvars.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
userexists withemail: "old"andadapter.update({ model: "user", where: [{ field: "id", value: "u1" }], update: { email: "new" } })is awaited - THEN the result has
email === "new"ANDid === "u1"(prefix stripped)
Scenario: updateMany returns count
- WHEN 3 users have
role: "free"andadapter.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:s1andadapter.delete({ model: "session", where: [{ field: "id", value: "s1" }] })is awaited - THEN a subsequent
findOnefor that id returnsnull
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", andadapter.count({ model: "session", where: [{ field: "userId", value: "u1" }] })is awaited - THEN the result equals 3 (where-clause pre-resolves
userIdto 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" })runsadapter.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"andemail: "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 })runsadapter.create({ model: "user", data: { … } }) - THEN the row is stored under
bauth_user:<id>
Scenario: invalid table name raises
- WHEN
modelToTablereturns"weird-name"for some model andcreateis 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_SCHEMAforDEFINE 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—usertable 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, thenadapter.createis invoked for asessionwithuserId: "user:u1"and anaccountwithuserId: "u1" - THEN both writes succeed (session enforces
record<user>; account acceptsstring)
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 withcause 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.querythrows aSurrealErrorand an adapter operation is in-flight - THEN the operation rejects with
BetterAuthSurrealError(BETTER_AUTH_SURREALDB_QUERY_FAILED)carrying theSurrealErrorascause
Scenario: INVALID_LINK_FIELD_VALUE on bad link-field write
- WHEN
adapter.create({ model: "session", data: { userId: "abc:def" } })is called with defaultlinkFields(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
createand a failed operation run against a real in-memoryMeterProvider - THEN
operations_total{outcome: "success"}ANDoperations_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.tsinspectspackage.json.exportsand 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"