Database Schema Generation#
Better Auth derives its database schema from a single canonical source — BetterAuthDBSchema — which is assembled in packages/core/src/db/get-tables.ts and consumed by adapters, CLI generators, and the migration engine. Understanding how this schema represents the implicit id primary key, merges plugin fields, and maps logical field names to physical columns for compound indexes is essential when extending the schema or debugging generated output.
Schema Assembly (getAuthTables / buildAuthTables)#
getAuthTables is the single entry point for the canonical schema. It delegates to buildAuthTables, which:
- Merges plugin fields — iterates
options.plugins, spreads each plugin'sfieldsinto the matching table entry, and deduplicatesindexesviamergeTableIndexes. - Lays out core tables —
user,session,account, andverificationare defined inline with their built-in fields. Plugin-contributed fields for these same table keys are spread in after the built-in fields . - Appends
additionalFields— top-level config fields foruser,session,account, andverificationare spread last, so they extend but cannot rename core columns .
The implicit id primary key is not declared in BetterAuthDBSchema's fields map. Each adapter and generator injects it independently at generation/migration time. This keeps the schema declaration lean but means consumers that iterate fields will not see id unless they add it themselves.
getAuthTablesWithResolvedIndexes wraps buildAuthTables and immediately calls resolveDatabaseSchemaIndexes to validate and resolve all compound indexes, returning { indexesByTable, tables }. This is what generators and the migration engine actually call.
Field-to-Column Mapping (DBFieldAttribute.fieldName)#
Every field in BetterAuthDBSchema may carry a fieldName property, which overrides the physical column name used in the database. Resolution happens in resolveDatabaseTableIndexes: when building columns from an index's logical fields array, each logical field name is looked up in the fields map and its fieldName (or the logical name if absent) becomes the physical column name .
logical field name ──→ fields[name].fieldName ?? name ──→ physical column name
Generators (Drizzle, Prisma) and the migration engine all consume these resolved ResolvedDBTableIndex objects, so they always work with physical column names .
Compound Index Resolution (database-index.ts)#
packages/core/src/db/database-index.ts is the authoritative resolver for table-level indexes. Key exports:
| Export | Purpose |
|---|---|
getDatabaseIndexName | Generates a portable index name: uses index.name if provided; auto-generates {table}_{fields}_{uidx|idx}, hashing if > 63 UTF-8 bytes |
resolveDatabaseTableIndexes | Validates field count (1–16), uniqueness constraints (no nullable fields, no json/string[]/number[] fields), maps logical fields → physical columns, and deduplicates |
resolveDatabaseSchemaIndexes | Schema-wide pass: merges multi-source aliases to the same table, enforces that index names are unique across all tables (SQLite/PostgreSQL scope indexes globally), and calls resolveDatabaseTableIndexes per table |
getDatabaseIndexStringLength | Computes safe VARCHAR lengths for MySQL (3 072-byte budget, 4 bytes/char) and SQL Server (1 700-byte budget, 1 byte/char) to prevent silent truncation |
The DBTableIndex schema API (defined in packages/core/src/db/type.ts) accepts :
| Property | Type | Notes |
|---|---|---|
fields | [string, ...string[]] | 1–16 logical field names |
name | string? | ≤ 63 UTF-8 bytes, schema-wide unique, [A-Za-z_][A-Za-z0-9_]* |
unique | boolean? | All listed fields must be non-nullable |
Feature introduced in PR #10402; prior to that, only per-field boolean index flags existed .
Drizzle Schema Generator#
packages/cli/src/generators/drizzle.ts converts BetterAuthDBSchema into Drizzle ORM TypeScript. It uses two independent code paths for uniqueness constraints :
- Column-level: appends
.unique()wheneverattr.uniqueis truthy. - Table-level: pushes an
index("…_idx")oruniqueIndex("…_uidx")entry whenattr.indexis truthy; rendered viaassignIndexes.
Known bug: a field with both unique: true and index: true (e.g., organization.slug) triggers both paths, generating a duplicate unique constraint . The proposed fix is to suppress column-level .unique() when a table-level uniqueIndex will already be emitted. Track issue #10332 for status.
For compound indexes (multi-field DBTableIndex entries), the generator emits .index("name").on(col1, col2) / .uniqueIndex("name").on(...) using the resolved physical column names .
Adapter & Migration Behavior#
| Backend | Index creation |
|---|---|
| SQL (pg, mysql, sqlite, mssql) | get-migration.ts introspects existing indexes per dialect, validates name/column/uniqueness match, emits CREATE [UNIQUE] INDEX after table/column creation |
| MongoDB | mongodb-adapter.ts lazily calls createIndex() before the first index-enforcing write; cached per collection |
MySQL and SQL Server enforce indexed string column byte limits; unbounded columns trigger a blocking error with remediation instructions rather than silent truncation .
End-to-end parity across all five backends is verified in e2e/adapter/test/adapter-factory/compound-index-test-suite.ts .
Key Files#
| File | Role |
|---|---|
packages/core/src/db/get-tables.ts | Schema assembly: getAuthTables, buildAuthTables, plugin merging |
packages/core/src/db/database-index.ts | Index validation, name generation, field→column resolution |
packages/core/src/db/type.ts | DBTableIndex, DBFieldAttribute, BetterAuthDBSchema interfaces |
packages/cli/src/generators/drizzle.ts | Drizzle ORM schema generator |
packages/cli/src/generators/prisma.ts | Prisma schema generator |
packages/better-auth/src/db/get-migration.ts | SQL migration engine |