Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/quiet-otters-declare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@opensaas/stack-auth': minor
'@opensaas/stack-cli': minor
'@opensaas/stack-core': patch
---

Let an application declare model-level indexes (`db.indexes`) on the derived auth lists (`User`/`Session`/`Account`/`Verification`/`RateLimit`).

Each per-model block in `authPlugin()` now accepts `indexes`, in the same shape as a list's own `db.indexes`:

```typescript
authPlugin({
// Adopt a live constraint's real name instead of Prisma's derived one.
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] },
// Extend a derived column into a composite index.
verification: {
indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
},
})
```

An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index for that column and emits only the app's entry, rather than erroring — the application's declaration wins (ADR-0035). Suppression is per-column: every other derived index on the model is unaffected.

This also fixes a related generator gap: a list's `db.indexes` can now reference `createdAt`/`updatedAt` even when the list has no explicit field for them and relies on `db.timestamps` for the auto-injected columns (previously only a list with an explicitly declared `createdAt`/`updatedAt` field could be indexed on it).
40 changes: 40 additions & 0 deletions docs/content/how-to/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,46 @@ existed: the table name follows `modelName` whenever it differs from the
better-auth default, so a renamed-table install (the `adoptBetterAuthTables()`
default from the previous section) keeps working without any changes.

### Adopting a live constraint name or adding your own index (`indexes`)

Each per-model block (`user`/`session`/`account`/`verification`/`rateLimit`)
also accepts `indexes`, in the same shape as a list's own `db.indexes` (see
the [`db.indexes` reference](/docs/reference/config-api#dbindexes)) — entries
name the model's own field keys, not raw column names.

The stack already derives some indexes from better-auth's own table
definitions — `User.email` and `Session.token` are unique, for example. If
your live database's constraint has a different name than the one Prisma
would derive, adopting it under its real name is a generate-clean diff away:

```typescript
authPlugin({
user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] },
})
```

An entry covering a column the stack already derives an index for **replaces**
that derived index rather than erroring — your declaration wins. This is the
opposite of `db.indexes` on a list you declare yourself, where a collision
with a field's own `isIndexed` is a config-time error: here, one of the two
declarations is derived, so there's nothing of yours to remove.

The same seam lets you extend a derived column into a composite index — e.g.
a per-identifier resend-cooldown check on the verification table:

```typescript
authPlugin({
verification: {
indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
},
})
```

Comment thread
borisno2 marked this conversation as resolved.
This suppresses the derived single-column index on `identifier` in favor of
the composite, which serves the same lookups. Suppression is per-column: every
other index the stack derives for that model is unaffected.

### Linking your app User to the Auth identity

Because the Auth identity (`AuthUser`) and your domain `User` are separate
Expand Down
4 changes: 3 additions & 1 deletion docs/content/reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ authPlugin({

Derivation keys off `storage` alone, not `enabled` — `{ enabled: false, storage: 'database' }` still produces the `RateLimit` list, since better-auth still expects the table regardless of whether the limiter is currently active (`enabled` is routinely environment-driven, and tying the generated schema to it would make dev and prod schemas differ).

`rateLimit` also carries the same adoption knobs as the other four models — `modelName`, `fields`, `tableName`, `schema` — so an app with an existing database-backed limiter table can adopt it rather than being forced into a new one:
`rateLimit` also carries the same adoption knobs as the other four models — `modelName`, `fields`, `tableName`, `schema`, `indexes` — so an app with an existing database-backed limiter table can adopt it rather than being forced into a new one:

```typescript
authPlugin({
Expand All @@ -375,6 +375,8 @@ authPlugin({

Setting `storage` via the `betterAuthOptions.rateLimit` passthrough is rejected — it has schema consequences (deriving the `RateLimit` list) a passthrough can't also apply to the generated Prisma schema. Other `betterAuthOptions.rateLimit` keys (`customRules`, `customStorage`) still pass through and merge with `enabled`/`window`/`max` as usual.

Every per-model block — `user` / `session` / `account` / `verification` / `rateLimit` — also accepts `indexes`, using the same entry shape as a list's own [`db.indexes`](/docs/reference/config-api#dbindexes): app-authored model-level `@@unique`/`@@index` constraints, naming this model's own field keys. An entry covering a column the stack already derives an index for (e.g. `User.email`) suppresses that derived index and emits only the app's entry — see [Adopting a live constraint name or adding your own index](/docs/how-to/authentication#adopting-a-live-constraint-name-or-adding-your-own-index-indexes) for the full explanation and examples.

## Auto-Generated Lists

The auth plugin automatically generates the following lists:
Expand Down
15 changes: 14 additions & 1 deletion docs/content/reference/config-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,22 @@ AuthVerification: list({
// Generates: @@index([identifier, createdAt(sort: Desc)], map: "AuthVerification_identifier_createdAt_idx")
```

**`createdAt`/`updatedAt` are valid even with no declared field.** An entry may name either as long as the list's auto-timestamps (`db.timestamps`, global or per-list) are enabled for that column — the auto-injected column has no `@map` of its own, so the field name and column name coincide:

```typescript
Verification: list({
fields: { identifier: text() },
db: {
timestamps: true, // no explicit createdAt field
indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
},
})
// Generates: @@index([identifier, createdAt(sort: Desc)])
```

**Errors at `pnpm generate` time** (each names the list and the entry):

- An entry naming a field the list doesn't have, a virtual field, a to-many relationship, or the non-FK side of a one-to-one relationship.
- An entry naming a field the list doesn't have (unless it's `createdAt`/`updatedAt` and auto-timestamps are enabled for that column — see above), a virtual field, a to-many relationship, or the non-FK side of a one-to-one relationship.
- An entry whose `fields` array is empty.
- A single-field entry that indexes the exact column a field-level `isIndexed` on the same list already indexes — the error names both the field/`isIndexed` and the entry, since either one should be removed rather than both left producing the same constraint.

Expand Down
76 changes: 66 additions & 10 deletions packages/auth/src/config/derive-auth-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { getAuthTables } from 'better-auth/db'
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
import type { DBFieldAttribute } from 'better-auth/db'
import type { ListConfig, FieldConfig, FieldAccess } from '@opensaas/stack-core'
import type { ListConfig, FieldConfig, ListIndex, FieldAccess } from '@opensaas/stack-core'
import type { RelationshipField } from '@opensaas/stack-core/fields'
import type { ExtendUserListConfig } from '../lists/index.js'
import type { AuthAccessConfig, NormalizedAuthModelConfig, NormalizedAuthModels } from './types.js'
Expand Down Expand Up @@ -82,7 +82,8 @@ const FIELD_ORDER: Partial<Record<BaseModelKey, string[]>> = {
// `issuer` (better-auth 1.7+, issue #986) groups with accountId/providerId
// as the account's identity fields — together the table-level
// `@@unique([issuer, accountId])` better-auth declares (not yet emitted;
// blocked on #985's table-level `db.indexes` derivation).
// blocked on #986 reading better-auth's own table-level `indexes` through
// the app-supplied `db.indexes` passthrough #985 adds).
'issuer',
'user',
'accessToken',
Expand Down Expand Up @@ -206,6 +207,25 @@ function scalarIsIndexed(upstream: DBFieldAttribute): true | 'unique' | undefine
return undefined
}

/**
* The OpenSaaS field keys an app-supplied `db.indexes` entry (ADR-0035)
* claims on this model — every field named in any entry's `fields` array,
* regardless of that entry's own arity or uniqueness. A claimed column's
* derived `isIndexed` (from better-auth's own `unique`/`index` flags) is
* suppressed so only the app's entry is emitted; suppression is per-column,
* so a composite entry claiming `identifier` leaves every other derived
* index on the model untouched.
*/
function claimedIndexFields(indexes: ListIndex[]): Set<string> {
const claimed = new Set<string>()
for (const index of indexes) {
for (const fieldRef of index.fields) {
claimed.add(typeof fieldRef === 'string' ? fieldRef : fieldRef.field)
}
}
return claimed
}

/**
* `db.isNullable` is set explicitly from `required` rather than left to each
* field builder's own default — `timestamp()` in particular defaults nullable
Expand All @@ -226,9 +246,13 @@ function scalarFieldDb(
}
}

function buildScalarField(fieldKey: string, upstream: DBFieldAttribute): FieldConfig {
function buildScalarField(
fieldKey: string,
upstream: DBFieldAttribute,
suppressIndex: boolean,
): FieldConfig {
const isRequired = upstream.required ?? true
const isIndexed = scalarIsIndexed(upstream)
const isIndexed = suppressIndex ? undefined : scalarIsIndexed(upstream)
const db = scalarFieldDb(fieldKey, upstream)

switch (upstream.type) {
Expand Down Expand Up @@ -310,6 +334,7 @@ function buildForeignKeyField(
upstream: DBFieldAttribute,
targetListKey: string,
reverseFieldName: string,
suppressIndex: boolean,
): RelationshipField {
const references = upstream.references
if (!references) {
Expand All @@ -321,11 +346,16 @@ function buildForeignKeyField(
const isRequired = upstream.required ?? true
const columnName = upstream.fieldName ?? fieldKey
const onDelete = mapOnDelete(references.onDelete ?? 'cascade')
const isIndexed = scalarIsIndexed(upstream)
// A relationship field's own generator defaults its FK index to indexed
// (true) whenever `isIndexed` is *omitted*, unlike a scalar field — so
// suppression can't just drop the property here the way `buildScalarField`
// does; it must set `isIndexed: false` explicitly to actually turn the
// derived index off.
const isIndexed = suppressIndex ? (false as const) : scalarIsIndexed(upstream)

return relationship({
ref: `${targetListKey}.${reverseFieldName}`,
...(isIndexed ? { isIndexed } : {}),
...(isIndexed !== undefined ? { isIndexed } : {}),
db: {
isNullable: !isRequired,
foreignKey: { map: columnName },
Expand All @@ -352,12 +382,14 @@ function buildForeignKeyField(
function listDb(
model: NormalizedAuthModelConfig,
timestamps: boolean,
): { timestamps?: true; map?: string; schema?: string } {
): { timestamps?: true; map?: string; schema?: string; indexes?: ListIndex[] } {
const schema = model.schema
const indexes = model.indexes
return {
...(timestamps ? { timestamps: true as const } : {}),
...(model.tableName !== undefined ? { map: model.tableName } : {}),
...(schema !== undefined ? { schema } : {}),
...(indexes && indexes.length > 0 ? { indexes } : {}),
}
}

Expand Down Expand Up @@ -451,7 +483,12 @@ function buildModelRegistry(
* A fifth `RateLimit` list is included only when `models.rateLimit` is
* present (i.e. `rateLimit.storage === 'database'`).
*
* @param models - Resolved better-auth per-model config (modelName + field column maps)
* Each base model's `indexes` (`AuthModelConfig.indexes`) carries through to
* the derived list's `db.indexes` unchanged, and suppresses this function's
* own derived field-level `isIndexed` for any column an entry names — the
* application's declaration wins over a derived default (ADR-0035).
*
* @param models - Resolved better-auth per-model config (modelName + field column maps + app-supplied indexes)
* @param userConfig - Extra User-list fields/access/hooks supplied via `extendUserList`
* @param accessConfig - App-authored access for each base Auth list, keyed by better-auth model name
* @param plugins - The app's better-auth plugins (`authPlugin({ betterAuthPlugins })`), whose own
Expand All @@ -470,6 +507,16 @@ export function deriveAuthLists(
>
const { keys, registry } = buildModelRegistry(tables, models)

// Only the five base models carry an app-authored `db.indexes` passthrough
// (`AuthModelConfig.indexes`) — plugin tables have no per-model config
// block to declare them through (deliberately out of scope, see the issue
// brief). Computed once per model rather than per field.
const claimedFieldsByModel: Partial<Record<BaseModelKey, Set<string>>> = {}
for (const baseKey of BASE_MODEL_KEYS) {
const model = models[baseKey]
if (model) claimedFieldsByModel[baseKey] = claimedIndexFields(model.indexes ?? [])
}

const scalarFields: Record<string, Record<string, FieldConfig>> = {}
const foreignKeyFields: Record<string, Record<string, RelationshipField>> = {}
const reverseRelationFields: Record<string, Record<string, RelationshipField>> = {}
Expand Down Expand Up @@ -516,6 +563,7 @@ export function deriveAuthLists(
upstream,
targetListKey,
reverseName,
claimedFieldsByModel[modelKey as BaseModelKey]?.has(relationFieldKey) ?? false,
)
;(reverseRelationFields[targetModelKey] ??= {})[reverseName] = relationship({
ref: `${registry.get(modelKey)}.${relationFieldKey}`,
Expand All @@ -531,14 +579,22 @@ export function deriveAuthLists(
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
modelKey,
fieldKey,
buildScalarField(fieldKey, upstream),
buildScalarField(
fieldKey,
upstream,
claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false,
),
)
}
} else {
;(scalarFields[modelKey] ??= {})[fieldKey] = withCredentialAccess(
modelKey,
fieldKey,
buildScalarField(fieldKey, upstream),
buildScalarField(
fieldKey,
upstream,
claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false,
),
)
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function normalizeModelConfig(
tableName,
fields: config?.fields || {},
schema: config?.schema ?? defaultSchema,
indexes: config?.indexes ?? [],
}
}

Expand Down
36 changes: 35 additions & 1 deletion packages/auth/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ListConfig } from '@opensaas/stack-core'
import type { ListConfig, ListIndex } from '@opensaas/stack-core'
import type { BetterAuthOptions, BetterAuthPlugin, User } from 'better-auth'
import type { ExtendUserListConfig } from '../lists/index.js'

Expand Down Expand Up @@ -244,6 +244,38 @@ export type AuthModelConfig = {
* ```
*/
schema?: string
/**
* App-authored model-level `@@unique`/`@@index` constraints for this auth
* model, in the same shape as a list's own {@link ListConfig.db} `indexes`
* (core's {@link ListIndex}). Entries name this model's own OpenSaaS field
* keys (e.g. `identifier`, `createdAt` on `verification`) — the same names
* used in this model's `fields` column map — not raw database column names.
*
* The stack already derives some indexes from better-auth's own table
* definitions (e.g. `User.email` is `@unique`). When an entry here covers a
* column that also carries a derived index, the derived index is
* suppressed for that column and only this entry is emitted — the
* application's declaration wins (ADR-0035). This is what makes adopting a
* live database's real constraint name, or extending a derived column into
* a composite index, expressible.
*
* @example Adopt a live `user.email` unique constraint under its real name
* ```typescript
* authPlugin({
* user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] },
* })
* ```
*
* @example Extend a derived index into a composite (per-identifier resend cooldown)
* ```typescript
* authPlugin({
* verification: {
* indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }],
* },
* })
* ```
*/
indexes?: ListIndex[]
}

export type AuthConfig = {
Expand Down Expand Up @@ -491,6 +523,8 @@ export type NormalizedAuthModelConfig = {
tableName?: string
fields: Record<string, string>
schema?: string
/** App-authored `db.indexes` entries for this model (see {@link AuthModelConfig.indexes}). Defaults to `[]`. */
indexes?: ListIndex[]
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ describe('normalizeAuthConfig', () => {
modelName: 'RateLimit',
tableName: undefined,
fields: {},
schema: undefined,
indexes: [],
})
})

Expand Down Expand Up @@ -279,6 +281,7 @@ describe('normalizeAuthConfig', () => {
tableName: 'rate_limit',
fields: { key: 'limit_key' },
schema: 'auth',
indexes: [],
})
})

Expand Down
Loading
Loading