Database Adapter Schema Resolution#
Overview#
The adapter layer in Better Auth uses a two-layer naming system — schema keys (canonical identifiers like "user", "session", "account") vs. modelName (the physical table/collection name). Schema resolution is the process of translating between these two, and of discovering foreign-key relationships at query time.
Key source files:
packages/core/src/db/adapter/factory.ts— main adapter factory; hoststransformJoinClause,handleFallbackJoin,transformInput,transformOutputpackages/core/src/db/adapter/get-default-model-name.ts— canonical schema-key resolverpackages/core/src/db/type.ts—BetterAuthDBSchema,DBFieldAttribute,referencestypepackages/core/src/db/get-tables.ts— builds the full schema object including FK declarations andadditionalFields
Model Name Resolution#
Every adapter operation goes through two resolver functions instantiated once inside createAdapterFactory:
| Function | Purpose |
|---|---|
getDefaultModelName | Incoming string → canonical schema key (for schema[key] lookups) |
getModelName | Schema key → physical table name sent to the DB |
getDefaultModelName resolution order #
- Exact schema-key match wins — if
schema[candidate]exists, return immediately. modelNamealias fallback — scan all schema entries for a matchingmodelName.usePluralstrip — ifusePluralis enabled and input ends ins, strip it and re-run both checks.- Not found — throws
BetterAuthError: Model "${model}" not found in schema.
Schema-key-first priority is critical. If a user sets user.modelName = "account", the old resolver would have routed every internal "account" lookup to the user table (issue #8111). The fix ensures schema keys always win over modelName aliases.
Foreign Key Declarations#
FK relationships are declared via the references property on DBFieldAttribute:
references?: {
model: string; // canonical schema key, NOT modelName
field: string;
onDelete?: "no action" | "restrict" | "cascade" | "set null" | "set default";
}
The references.model value must be a canonical schema key, never a custom modelName. The built-in tables enforce this: session.userId and account.userId both hard-code "user" as the reference model, not options.user.modelName. This prevents FK mis-routing when users customize model names.
Join Resolution: transformJoinClause#
When a query includes a join option (e.g., { user: true } on a session query), transformJoinClause converts it to a fully-resolved JoinConfig:
- Resolves both the base model and joined model to canonical schema keys via
getDefaultModelName. - Forward FK check — scans
schema[joinedModel].fieldsfor a field whosereferences.modelresolves to the base model . - Backward FK check — if no forward FK found, scans
schema[baseModel].fieldsfor a FK pointing to the joined model . - Derives
from/tocolumn names and infers relationship type (one-to-oneif the FK field hasunique: trueor referencesid; otherwiseone-to-many) . - Assigns a default limit of
100(oroptions.advanced.database.defaultFindManyLimit) to one-to-many joins.
Error cases thrown as BetterAuthError:
No foreign key found for model ${model} and base model ${baseModel} while performing join operation.— no FK exists in either direction .Multiple foreign keys found for model ${model} and base model ${baseModel}...— more than one FK found; only single FKs are supported .
Join Dispatch: Native vs. Fallback#
After transformJoinClause produces a JoinConfig, the factory decides how to execute it :
options.experimental.joins: true— the join config is passed directly to the adapter (Kysely uses SQLLEFT JOIN; MongoDB uses$lookupaggregation pipelines).- Default (joins disabled) — the factory withholds the join from the adapter and instead resolves it via
handleFallbackJoin: a separatefindOneorfindManyquery is issued for each joined model and results are merged in memory.
transformOutput handles the final assembly: after transforming the base model row, it iterates joined models and either appends a single object (one-to-one) or an array (one-to-many) under the default model name key .
additionalFields and Schema Merging#
additionalFields declared in the top-level config for user, session, account, or verification are spread into that table's fields object during schema construction in get-tables.ts. They are merged after built-in fields, so they can extend (but not rename) core columns.
At runtime, additionalFields participate in the full field-resolution pipeline:
transformInputappliesdefaultValue,onUpdate,transform.input, and type coercions for each field defined in the schema .transformOutputreverses the mapping and appliestransform.output.input: falsefields are blocked at the API layer before reaching the adapter;returned: falsefields are stripped from output. See Additional Fields Schema Configuration.
Plugin-contributed fields follow the same path: they are merged into schema[modelName].fields before createAdapterFactory runs. Plugins can declare their own references to create FK relationships to other tables, which transformJoinClause will resolve at query time using the same forward/backward FK scan.
Validation of External Model References#
Any references.model value that does not resolve to a known schema key will throw at runtime when transformJoinClause calls getDefaultModelName(fieldAttributes.references.model). There is no compile-time guard — the error surfaces only when a join query touches the offending field.
The same error fires if a caller passes an unknown model name to any adapter method (create, findOne, etc.), since every entry point calls getDefaultModelName as its first step .