From 7e0e222ec4be8db8d932bc1d79bc55daa8496788 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:46:04 +0000 Subject: [PATCH 1/3] Let apps declare model-level indexes on derived auth lists (#985) authPlugin()'s per-model config (user/session/account/verification/rateLimit) now accepts `indexes`, threaded through to the derived list's `db.indexes`. An entry covering a column the stack already derives an index for suppresses that derived index for that column and emits only the app's entry (ADR-0035), making it possible to adopt a live constraint's real name or extend a derived column into a composite index. Also fixes the Prisma generator to resolve createdAt/updatedAt in db.indexes against a list's auto-timestamp columns, not only an explicitly declared field, which the verification model's composite-index use case depends on. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PLqppkKWzoMZvy3aCDK6DY --- .changeset/quiet-otters-declare.md | 25 +++ docs/content/how-to/authentication.md | 40 +++++ docs/content/reference/auth.md | 4 +- packages/auth/src/config/derive-auth-lists.ts | 69 +++++++- packages/auth/src/config/index.ts | 1 + packages/auth/src/config/types.ts | 36 +++- packages/auth/tests/config.test.ts | 3 + .../auth/tests/generated-fk-shape.test.ts | 156 +++++++++++++++++- packages/cli/src/generator/prisma.test.ts | 54 ++++++ packages/cli/src/generator/prisma.ts | 24 ++- packages/core/src/config/types.ts | 6 + 11 files changed, 403 insertions(+), 15 deletions(-) create mode 100644 .changeset/quiet-otters-declare.md diff --git a/.changeset/quiet-otters-declare.md b/.changeset/quiet-otters-declare.md new file mode 100644 index 00000000..9cb39877 --- /dev/null +++ b/.changeset/quiet-otters-declare.md @@ -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). diff --git a/docs/content/how-to/authentication.md b/docs/content/how-to/authentication.md index d51253e0..55be47af 100644 --- a/docs/content/how-to/authentication.md +++ b/docs/content/how-to/authentication.md @@ -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' }] }], + }, +}) +``` + +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 diff --git a/docs/content/reference/auth.md b/docs/content/reference/auth.md index 03900c6a..0ace869d 100644 --- a/docs/content/reference/auth.md +++ b/docs/content/reference/auth.md @@ -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({ @@ -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: diff --git a/packages/auth/src/config/derive-auth-lists.ts b/packages/auth/src/config/derive-auth-lists.ts index 2d0b145d..0bab2b4f 100644 --- a/packages/auth/src/config/derive-auth-lists.ts +++ b/packages/auth/src/config/derive-auth-lists.ts @@ -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 } from '@opensaas/stack-core' +import type { ListConfig, FieldConfig, ListIndex } 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' @@ -82,7 +82,8 @@ const FIELD_ORDER: Partial> = { // `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', @@ -186,6 +187,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 { + const claimed = new Set() + 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 @@ -206,9 +226,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) { @@ -290,6 +314,7 @@ function buildForeignKeyField( upstream: DBFieldAttribute, targetListKey: string, reverseFieldName: string, + suppressIndex: boolean, ): RelationshipField { const references = upstream.references if (!references) { @@ -301,7 +326,7 @@ function buildForeignKeyField( const isRequired = upstream.required ?? true const columnName = upstream.fieldName ?? fieldKey const onDelete = mapOnDelete(references.onDelete ?? 'cascade') - const isIndexed = scalarIsIndexed(upstream) + const isIndexed = suppressIndex ? undefined : scalarIsIndexed(upstream) return relationship({ ref: `${targetListKey}.${reverseFieldName}`, @@ -332,12 +357,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 } : {}), } } @@ -431,7 +458,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 @@ -450,6 +482,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>> = {} + for (const baseKey of BASE_MODEL_KEYS) { + const model = models[baseKey] + if (model) claimedFieldsByModel[baseKey] = claimedIndexFields(model.indexes ?? []) + } + const scalarFields: Record> = {} const foreignKeyFields: Record> = {} const reverseRelationFields: Record> = {} @@ -496,6 +538,7 @@ export function deriveAuthLists( upstream, targetListKey, reverseName, + claimedFieldsByModel[modelKey as BaseModelKey]?.has(relationFieldKey) ?? false, ) ;(reverseRelationFields[targetModelKey] ??= {})[reverseName] = relationship({ ref: `${registry.get(modelKey)}.${relationFieldKey}`, @@ -508,10 +551,18 @@ export function deriveAuthLists( // relation can't express without pointing Prisma at the wrong // column. Left as a plain scalar column, same as pre-consolidation // behavior (issue #992). - ;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField(fieldKey, upstream) + ;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField( + fieldKey, + upstream, + claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false, + ) } } else { - ;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField(fieldKey, upstream) + ;(scalarFields[modelKey] ??= {})[fieldKey] = buildScalarField( + fieldKey, + upstream, + claimedFieldsByModel[modelKey as BaseModelKey]?.has(fieldKey) ?? false, + ) } } } diff --git a/packages/auth/src/config/index.ts b/packages/auth/src/config/index.ts index 6e8ddba0..b1231547 100644 --- a/packages/auth/src/config/index.ts +++ b/packages/auth/src/config/index.ts @@ -38,6 +38,7 @@ function normalizeModelConfig( tableName, fields: config?.fields || {}, schema: config?.schema ?? defaultSchema, + indexes: config?.indexes ?? [], } } diff --git a/packages/auth/src/config/types.ts b/packages/auth/src/config/types.ts index a0c86b43..b153693a 100644 --- a/packages/auth/src/config/types.ts +++ b/packages/auth/src/config/types.ts @@ -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' @@ -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 = { @@ -491,6 +523,8 @@ export type NormalizedAuthModelConfig = { tableName?: string fields: Record schema?: string + /** App-authored `db.indexes` entries for this model (see {@link AuthModelConfig.indexes}). Defaults to `[]`. */ + indexes?: ListIndex[] } /** diff --git a/packages/auth/tests/config.test.ts b/packages/auth/tests/config.test.ts index 7b6bcaf0..6128114d 100644 --- a/packages/auth/tests/config.test.ts +++ b/packages/auth/tests/config.test.ts @@ -252,6 +252,8 @@ describe('normalizeAuthConfig', () => { modelName: 'RateLimit', tableName: undefined, fields: {}, + schema: undefined, + indexes: [], }) }) @@ -279,6 +281,7 @@ describe('normalizeAuthConfig', () => { tableName: 'rate_limit', fields: { key: 'limit_key' }, schema: 'auth', + indexes: [], }) }) diff --git a/packages/auth/tests/generated-fk-shape.test.ts b/packages/auth/tests/generated-fk-shape.test.ts index 5b184ac5..19c3d0ec 100644 --- a/packages/auth/tests/generated-fk-shape.test.ts +++ b/packages/auth/tests/generated-fk-shape.test.ts @@ -111,15 +111,16 @@ describe('generated auth schema — account.issuer (better-auth 1.7, issue #986) // FIELD_ORDER groups issuer with accountId/providerId — the fields that // together form better-auth's table-level @@unique([issuer, accountId]), - // which this derivation does not yet emit (blocked on #985's table-level - // db.indexes derivation). + // which this derivation does not yet emit (blocked on #986 reading + // better-auth's own table-level `indexes` option; #985 only lands the + // app-supplied `db.indexes` passthrough, exercised below). const providerIdLine = block.indexOf('providerId') const issuerLine = block.indexOf('issuer') expect(providerIdLine).toBeGreaterThan(-1) expect(issuerLine).toBeGreaterThan(providerIdLine) // Not yet emitted — see the migration note in the auth package's - // CHANGELOG and issue #985. + // CHANGELOG and issue #986. expect(block).not.toContain('@@unique([issuer, accountId])') }) }) @@ -476,3 +477,152 @@ describe('generated RateLimit schema mirrors better-auth exactly (issue #909)', expect(schema).toContain('model RateLimit') }) }) + +describe('app-supplied db.indexes on derived auth lists (issue #985)', () => { + it('emits a composite index on Verification and suppresses the derived single-column index on identifier', async () => { + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + verification: { + indexes: [{ fields: ['identifier', { field: 'createdAt', sort: 'desc' }] }], + }, + }), + ], + lists: {}, + }) + + const block = modelBlock(schema, 'Verification') + expect(block).toContain('@@index([identifier, createdAt(sort: Desc)])') + // The derived single-column index on identifier is suppressed — only the + // composite survives (ADR-0035). + expect(block).not.toContain('@@index([identifier])') + }) + + it('adopts a live named unique constraint on User.email, suppressing the derived inline @unique', async () => { + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + }), + ], + lists: {}, + }) + + const block = modelBlock(schema, 'User') + expect(block).toContain('@@unique([email], map: "user_email_key")') + expect(block).not.toMatch(/email\s+String\s+@unique/) + }) + + it('suppresses per-column only — every other derived index on the model still emits', async () => { + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + }), + ], + lists: {}, + }) + + // Session/Account's derived FK index on the User relation is untouched — + // suppression only ever applies to the column(s) an app entry names. + for (const model of ['Session', 'Account']) { + expect(modelBlock(schema, model)).toContain('@@index([userId])') + } + }) + + it("resolves the entry's field key through the model's own column map (@map)", async () => { + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + verification: { + fields: { identifier: 'ident_col' }, + indexes: [{ fields: ['identifier'] }], + }, + }), + ], + lists: {}, + }) + + const block = modelBlock(schema, 'Verification') + // db.indexes names the OpenSaaS field key, not the mapped column name — + // Prisma's @@index references the field, independent of its own @map. + expect(block).toContain('@map("ident_col")') + expect(block).toContain('@@index([identifier])') + }) + + it('#921: user.email and session.token both round-trip under adopted constraint names', async () => { + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + user: { indexes: [{ fields: ['email'], unique: true, name: 'user_email_key' }] }, + session: { indexes: [{ fields: ['token'], unique: true, name: 'session_token_key' }] }, + }), + ], + lists: {}, + }) + + const user = modelBlock(schema, 'User') + expect(user).toContain('@@unique([email], map: "user_email_key")') + expect(user).not.toMatch(/email\s+String\s+@unique/) + + const session = modelBlock(schema, 'Session') + expect(session).toContain('@@unique([token], map: "session_token_key")') + expect(session).not.toMatch(/token\s+String\s+@unique/) + }) + + it('fails generation naming the model, the entry, and the bad field for an unknown field', async () => { + await expect( + generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + verification: { indexes: [{ fields: ['doesNotExist'] }] }, + }), + ], + lists: {}, + }), + ).rejects.toThrow(/Verification.*references unknown field "doesNotExist"/) + }) + + it('names the remapped list key when modelName overrides the derived key', async () => { + await expect( + generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + verification: { + modelName: 'AuthVerification', + indexes: [{ fields: ['doesNotExist'] }], + }, + }), + ], + lists: {}, + }), + ).rejects.toThrow(/AuthVerification.*references unknown field "doesNotExist"/) + }) + + it('leaves existing auth-list derivation unchanged when no indexes are configured', async () => { + const withIndexes = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [authPlugin({ emailAndPassword: { enabled: true } })], + lists: {}, + }) + + for (const model of ['User', 'Session', 'Account', 'Verification']) { + expect(modelBlock(withIndexes, model)).not.toContain('@@unique([') + expect(modelBlock(withIndexes, model)).not.toContain('@@index([identifier, ') + } + }) +}) diff --git a/packages/cli/src/generator/prisma.test.ts b/packages/cli/src/generator/prisma.test.ts index 840ab198..8a86deba 100644 --- a/packages/cli/src/generator/prisma.test.ts +++ b/packages/cli/src/generator/prisma.test.ts @@ -1653,6 +1653,60 @@ describe('Prisma Schema Generator', () => { }) }) + describe('createdAt/updatedAt via auto-timestamps, with no declared field (#985)', () => { + it('resolves createdAt when the list has db.timestamps enabled, even though no field declares it', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Verification: { + fields: { identifier: text() }, + db: { timestamps: true, indexes: [{ fields: ['identifier', 'createdAt'] }] }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + expect(schema).toContain('@@index([identifier, createdAt])') + expect(schema).toContain('createdAt DateTime @default(now())') + }) + + it('resolves updatedAt the same way, and honours a sort direction on it', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { title: text() }, + db: { + timestamps: true, + indexes: [{ fields: ['title', { field: 'updatedAt', sort: 'desc' }] }], + }, + }, + }, + } + + const schema = generatePrismaSchema(config) + + expect(schema).toContain('@@index([title, updatedAt(sort: Desc)])') + }) + + it('still throws referencing unknown field when db.timestamps is not enabled for that column', () => { + const config: OpenSaasConfig = { + db: { provider: 'sqlite' }, + lists: { + Post: { + fields: { title: text() }, + db: { indexes: [{ fields: ['title', 'createdAt'] }] }, + }, + }, + } + + expect(() => generatePrismaSchema(config)).toThrow( + /db\.indexes\[0\].*on list "Post".*references unknown field "createdAt"/, + ) + }) + }) + it('throws naming the list and entry for an empty fields array', () => { const config: OpenSaasConfig = { db: { provider: 'sqlite' }, diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index 602dc4c1..22500481 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -103,6 +103,12 @@ function getFieldIndex( * Prisma-level field name is unaffected by `db.map`), or the owning foreign * key column (`Id`) for a relationship field. * + * `createdAt`/`updatedAt` resolve directly to their own column name when the + * list's auto-timestamps are enabled for that column (`resolveListTimestamps`) + * even though neither is a declared field in that case — the auto-injected + * column carries no `@map` of its own, so the field name and column name + * coincide (issue #985). + * * Throws a descriptive, generate-time error (naming the list, the index * entry, and the bad field) rather than silently dropping the entry or * emitting invalid Prisma, for every case that has no single column to @@ -115,12 +121,19 @@ function resolveIndexFieldColumn( relationResults: Map, entryDescription: string, fieldRef: ListIndex['fields'][number], + autoTimestampColumns: { createdAt: boolean; updatedAt: boolean }, ): { column: string; sort?: 'asc' | 'desc' } { const fieldName = typeof fieldRef === 'string' ? fieldRef : fieldRef.field const sort = typeof fieldRef === 'string' ? undefined : fieldRef.sort const fieldConfig = listConfig.fields[fieldName] if (!fieldConfig) { + if ( + (fieldName === 'createdAt' && autoTimestampColumns.createdAt) || + (fieldName === 'updatedAt' && autoTimestampColumns.updatedAt) + ) { + return { column: fieldName, sort } + } throw new Error( `${entryDescription} on list "${listName}" references unknown field "${fieldName}"`, ) @@ -229,6 +242,7 @@ function generateModelIndexLines( listConfig: ListConfig, relationResults: Map, fieldLevelIndexColumns: Map, + autoTimestampColumns: { createdAt: boolean; updatedAt: boolean }, ): string[] { const indexes = listConfig.db?.indexes if (!indexes || indexes.length === 0) return [] @@ -243,7 +257,14 @@ function generateModelIndexLines( } const resolved = index.fields.map((fieldRef) => - resolveIndexFieldColumn(listName, listConfig, relationResults, entryDescription, fieldRef), + resolveIndexFieldColumn( + listName, + listConfig, + relationResults, + entryDescription, + fieldRef, + autoTimestampColumns, + ), ) if (resolved.length === 1) { @@ -537,6 +558,7 @@ export function generatePrismaSchema(config: OpenSaasConfig, prismaClientOutput? listConfig, relationResults, collectFieldLevelIndexColumns(listName, listConfig, relationResults), + timestamps, ), ) diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 5e93426c..e3f8de28 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -2005,6 +2005,12 @@ export type ListConfig = { * the bad field too — no entry is ever silently dropped or emitted as * invalid Prisma. * + * `createdAt`/`updatedAt` are a valid entry even when the list has no + * matching 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, since the auto-injected column has no `@map` + * of its own and its Prisma field name is exactly `createdAt`/`updatedAt`. + * * @example One audition per student per production (composite unique) * ```typescript * Audition: list({ From aff5ddfa355fb6ddd1bab1c7e0ad82041427d270 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:52:36 +0000 Subject: [PATCH 2/3] docs: document createdAt/updatedAt resolution in db.indexes reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the core db.indexes reference to reflect that an entry can now name createdAt/updatedAt when a list's auto-timestamps are enabled for that column, even with no explicitly declared field — per code review on #1017. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PLqppkKWzoMZvy3aCDK6DY --- docs/content/reference/config-api.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/content/reference/config-api.md b/docs/content/reference/config-api.md index 131787e2..a78d3a44 100644 --- a/docs/content/reference/config-api.md +++ b/docs/content/reference/config-api.md @@ -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. From 0ae9ee2671b0789776caff569a9d78d9eff0fb41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 09:55:30 +0000 Subject: [PATCH 3/3] fix(auth): suppress the derived FK index, not just scalar isIndexed A relationship field's own generator defaults its FK index to indexed whenever isIndexed is omitted, unlike a scalar field. Suppressing a relationship field's derived index therefore has to set isIndexed: false explicitly, or the derived @@index survives and collides with the app's own db.indexes entry. Caught by review on #1017. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PLqppkKWzoMZvy3aCDK6DY --- packages/auth/src/config/derive-auth-lists.ts | 9 ++++++-- .../auth/tests/generated-fk-shape.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/config/derive-auth-lists.ts b/packages/auth/src/config/derive-auth-lists.ts index 0bab2b4f..28dd6341 100644 --- a/packages/auth/src/config/derive-auth-lists.ts +++ b/packages/auth/src/config/derive-auth-lists.ts @@ -326,11 +326,16 @@ function buildForeignKeyField( const isRequired = upstream.required ?? true const columnName = upstream.fieldName ?? fieldKey const onDelete = mapOnDelete(references.onDelete ?? 'cascade') - const isIndexed = suppressIndex ? undefined : 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 }, diff --git a/packages/auth/tests/generated-fk-shape.test.ts b/packages/auth/tests/generated-fk-shape.test.ts index 19c3d0ec..64e34940 100644 --- a/packages/auth/tests/generated-fk-shape.test.ts +++ b/packages/auth/tests/generated-fk-shape.test.ts @@ -536,6 +536,28 @@ describe('app-supplied db.indexes on derived auth lists (issue #985)', () => { } }) + it('suppresses the derived FK index on a relationship field, not just scalars', async () => { + // A relationship field's own generator defaults its FK index to indexed + // whenever isIndexed is *omitted* (unlike a scalar field) — suppression + // must set isIndexed: false explicitly, or the derived @@index([userId]) + // survives and collides with the app's own named entry. + const schema = await generateSchema({ + db: { provider: 'sqlite' }, + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + session: { indexes: [{ fields: ['user'], name: 'session_user_idx' }] }, + }), + ], + lists: {}, + }) + + const block = modelBlock(schema, 'Session') + expect(block).toContain('@@index([userId], map: "session_user_idx")') + // The derived, unnamed @@index([userId]) must not also survive alongside it. + expect(block).not.toContain('@@index([userId])') + }) + it("resolves the entry's field key through the model's own column map (@map)", async () => { const schema = await generateSchema({ db: { provider: 'sqlite' },